资讯动态

Java String深度解析:不可变性、常量池与拼接性能

发布时间:2026/9/24 23:13:32 来源:尧图企业网站定制
先问个问题String a hello; String b new String(hello); a b输出什么很多写了三五年 Java 的人会在这一题上犹豫几秒。字符串在 Java 里太常见了常见到我们几乎不会刻意去翻它的源码可它又是面试必考、线上问题高发的重灾区。这篇内容我把字符串相关的核心知识点全部拉通了一遍不可变性、常量池、常用 API、拼接性能、哈希与锁场景、易错面试题以及实际开发里的编码和工具经验。不是给你堆 API 列表而是讲清楚每个知识点背后的为什么配合可以直接复现的代码片段。不管是准备面试还是日常写代码排查问题这份速记手册都值得收藏。1. 字符串不可变性String 为什么非得是 final 的1.1 不可变性到底是什么意思String s hello; s s world;很多人以为上面的代码把字符串对象修改了。实际发生的是先在常量池创建了hello对象然后拼接时又创建了一个新的hello world对象变量s的引用从第一个对象改指向第二个对象。原来的hello依然躺在常量池里纹丝不动。不可变性的本质就是对象内部状态一旦初始化完成就永远无法被外部改变。String 源码里JDK 8 用private final char[] value存储JDK 9 开始换成private final byte[] value加上一个coder字段用来标记是 LATIN1 编码还是 UTF16 编码。数组引用被final修饰意味着这个引用变量不能重新赋值但要注意如果数组本身是可变对象仅靠final还不够安全。String 之所以彻底安全是因为数组没有暴露任何修改入口——没有setCharAt之类的方法所有看似修改的操作都返回新对象。这就是不可变类的完整实现套路字段私有、字段 final、类本身 final防止子类 override 行为、不暴露修改内部状态的公开方法。1.2 不可变性带来的四个实际收益第一是线程安全。不可变对象天生可以被多个线程共享不需要加锁因为没人能改它。HashMap 的 key、ConcurrentHashMap 的 key 大量使用 String 或者包装类就是图这一点。第二是常量池复用。因为对象内容不可变JVM 才能放心地让多个引用指向同一个字符串对象而不必担心一个地方改了影响其他地方。第三是 hashCode 缓存。String 的 hashCode 第一次计算后存在hash字段里以后直接取用。如果字符串可变哈希值就得每次重算HashMap 的读取效率会暴跌。可以看一下源码private int hash; public int hashCode() { int h hash; if (h 0 value.length 0) { h isLatin1() ? StringLatin1.hashCode(value) : StringUTF16.hashCode(value); hash h; } return h; }hash字段默认 0懒加载缓存。第四是安全。类加载器、网络连接、文件路径、数据库 URL 这些场景大量依赖字符串参数如果字符串可变恶意代码可以在参数传递途中篡改内容后果不堪设想。1.3 不可变性最容易踩的一个性能坑String result ; for (int i 0; i 10000; i) { result i; }这段代码的问题在于字符串拼接时JVM 每次都会创建一个新的 String 对象。循环 10000 次就是 10000 次对象创建和数组拷贝时间成本呈 O(n²) 级别增长。实测 10 万次拼接这段代码耗时可能是 StringBuilder 版本的几十倍。如果你在代码里看到这种循环里做字符串的写法基本可以认定是性能隐患。后面第 4 节会专门讲拼接的正确姿势。提示JDK 编译器对非循环内的字符串相加会优化成 StringBuilder.append但只在单个表达式或语句块内有效。循环体内的拼接每次都重新初始化 StringBuilder优化形同虚设。2. 字符串常量池两种创建方式的底层差异2.1 字面量 vs new一个走常量池一个走堆String s1 abc; String s2 abc; String s3 new String(abc); System.out.println(s1 s2); // true System.out.println(s1 s3); // false System.out.println(s1.equals(s3)); // trueabc这种字面量写法JVM 在类加载和运行时会去字符串常量池也是运行时常量池的一部分里找有没有相同内容的字符串。有就直接复用引用没有就创建并放入池中。所以s1 s2是 true因为两个变量指向同一个常量池对象。new String(abc)则强制在堆内存创建一个新对象内容与常量池中的abc一致但对象不同所以引用比较是 false。这里有个经典面试题String s new String(abc)创建了几个对象标准答案是 1 个或 2 个。如果常量池里已经有abc就只创建 1 个堆对象如果常量池里没有会先在池里创建 1 个然后在堆里再创建 1 个。需要注意的是abc这个字面量本身在类加载阶段就会被放进常量池所以多数情况下你已经默认创建了那个池对象。2.2 intern() 方法手动把对象送进常量池String s new String(hello); String s1 s.intern(); String s2 hello; System.out.println(s1 s2); // trueintern()的逻辑是如果常量池里有相同内容的字符串直接返回池中对象的引用如果没有把这个字符串引用加入池中再返回。JDK 7 之后常量池被移到了堆内存里所以 intern 在池中没有对应字符串时可以直接把当前堆对象的引用放进池子省去了一次对象复制。实际开发中intern 最常见的应用是大幅减少重复字符串对内存的占用。比如你有一个里面全是重复用户名、状态码、地区名的大型列表每个字符串都是运行时 new 出来的几百万个相同内容的对象会白白吃掉大量堆内存。此时调用 intern 让所有相同字符串共享同一个对象内存能省下非常可观的比例。但要注意intern 的查找本身也有成本乱用会导致常量池膨胀甚至 GC 压力上升。把 intern 当成存储优化手段时一定要先测试对比。2.3 JDK 8 与 JDK 9 之后存储结构的变化Java 8 中 String 内部是char[]每个字符固定 2 字节。Java 9 引入了 compact strings 特性改用byte[]根据内容自动选择 LATIN11 字节还是 UTF162 字节编码。绝大多数实际字符串内容是 Latin1 可表示的普通英文、数字、常见符号这样一来 String 对象在内存里直接省了一半空间。这个改动没有改变任何 API 行为但如果你要用反射、Unsafe、序列化等偏底层的操作去直接操作 String 内部数组版本差异会让你踩坑。版本存储结构编码策略占用内存JDK 8char[] value每个字符固定 2 字节较大JDK 9byte[] value coderLATIN1 / UTF16 自适应通常可省近半内存3. 高频 API 盘点这些方法的细节你未必全对3.1 相等性判断equals 与 的分界线比较的是引用指向的地址equals比较的是内容。String 重写了equals先比较引用再判断类型然后逐字符比较。写代码做字符串内容校验时应该养成常量.equals(variable)的写法直接把常量放前面能避免变量为 null 时抛出 NullPointerException。判断字符串是否为空的时候不要图省事只写s null。s null和s.isEmpty()是两码事前者是对象不存在后者是对象存在但长度为 0。还要注意 .isEmpty()结果是 false因为空格也是字符。如果需要把纯空白字符串也算空Java 11 之后的isBlank()才是正确的选择。3.2 分割与匹配split、replace、正则表达式的坑split()接收的是正则表达式而不是普通字符串。想按英文句号.分割直接写s.split(.)会得到一个空数组因为正则里.匹配任意字符。正确的做法是转义s.split(\\.)。同理按竖线|分割也要写成s.split(\\|)。如果在解析 CSV、日志文件时发现 split 结果诡异十有八九是正则元字符没转义的坑。replace和replaceAll的区别也经常有人搞混replace(CharSequence target, CharSequence replacement)的入参是普通字符串做全量替换replaceAll(String regex, String replacement)的第一个参数是正则。很多人用replaceAll替换普通文本结果遇到$、\等特殊字符时结果完全不对。一个实用小建议如果只是为了替换字面文本优先用replace不要用replaceAll可以避免常规文档里不会写的正则转义问题。String url abcd; System.out.println(url.replace(, #)); // ab#cd System.out.println(url.replaceAll(, #)); // 同样结果但入参被当正则3.3 子串与遍历substring、charAt 与代码点substring(beginIndex, endIndex)是左闭右开的endIndex不包含。hello.substring(1, 3)结果是el不是ell。这几乎是新手写边界条件最容易出错的地方。遍历字符串时charAt(i)返回的是 char16 位对于中文这种 BMP 内的字符没问题但遇到 emoji 等增补平面字符时会得到两个 char也就是代理对直接遍历会出现乱码。需要正确处理所有 Unicode 字符时用codePointAt(i)配合offsetByCodePoints或者直接toCharArray后按 codePoint 处理。String emoji AB; System.out.println(emoji.length()); // 4因为 占两个char emoji.codePoints().forEach(ch - System.out.println(Integer.toHexString(ch))); // 输出 41 1f60a 423.4 转换类方法valueOf、format、join 的正确使用基本类型转字符串首选String.valueOf()。这里有个细节String.valueOf(Object obj)如果传 null返回字符串null而不是报空指针。正好利用这个特性做日志输出时避免 NPE但要留意拼接 SQL、生成 key 时如果混入 null 字符串会导致数据异常需要主动判空。Java 8 之后String.join很好用拼接带分隔符的列表时不用再手写循环判断是不是最后一个元素要加分号这种边界。比如把数组拼成逗号分隔的字符串String[] arr {a, b, c}; String joined String.join(,, arr); // a,b,cString.format适合模板类字符串的生成比手工拼接可读性高很多而且%s、%d这些占位符本身就是自文档。Java 15 的文本块 text block 对多行 SQL、JSON、HTML 非常友好三引号包起来就能保留原始换行缩进不必再用\n拼接强烈推荐在写长脚本片段时使用。4. 拼接与性能StringBuilder 到底怎么用才正确4.1 号的编译期秘密String s a b c; // 编译后等价于 String s abc;JVM 会对编译期就能确定的常量字符串拼接做常量折叠直接在编译期算好结果。这是最快的情况没有任何运行时开销。但变量参与的拼接就不一样了String s someVar b c;编译器会把这行转成new StringBuilder().append(someVar).append(b).append(c).toString()。单行拼接没问题如果用出现在循环里相当于每次循环都新建 StringBuilder资源的创建和销毁开销全部白付。4.2 循环拼接的正确姿势// 错误示例 String log ; for (Request req : requests) { log req.getId() ,; } // 正确示例 StringBuilder log new StringBuilder(requests.size() * 16); for (Request req : requests) { log.append(req.getId()).append(,); } String result log.toString();正确写法里有三个细节值得关注。第一提前指定初始容量。StringBuilder 默认容量是 16如果内容超过容量会触发一次数组扩容和拷贝扩容后新容量大概翻倍。循环次数多了扩容可能发生多次。根据预估数据量指定初始容量可以彻底避免扩容损耗。第二链式 append减少代码行数可读性也更好。第三最后一律调用toString()转成真正的不可变 String 对象再返回或存储不要长时间持有 StringBuilder它本质上是可变对象被共享时可能出现你想象不到的线程安全问题不正常场景下它不是线程安全的但看起来安全更容易坑人。4.3 StringBuilder 与 StringBuffer 怎么选类型线程安全性能适用场景String安全不可变拼接极慢少量、确定字符串StringBuilder不安全快单线程下绝大多数拼接、循环StringBuffer安全方法级 synchronized略慢多线程共享同一个拼接对象但实际很少需要现代业务代码里StringBuffer 的出现频率越来越低。因为方法级synchronized是粗粒度锁多线程高频追加时线程竞争严重性能反而不如每个线程维护自己的 StringBuilder最后再合并。如果你真的遇到多线程共享拼接对象的场景建议先想想能不能用 ThreadLocal StringBuilder 或者干脆让每个线程各拼各的。线上排查过不少性能问题StringBuffer 是很少见的能用锁解决、但通常不应该用锁解决的典型案例。4.4 隐藏的拼接性能杀手日志与异常堆栈log.info(user: user , order: order , cost: cost);很多人不知道日志框架的{}占位符是专门为拼接优化设计的。用占位符方式传参日志框架会先判断日志级别是否需要输出不需要输出时根本不会执行拼接。而手写拼接时不管日志级别是什么字符串拼接都会先发生。在高 QPS 的业务里大量 warn 和 info 日志因为手写拼接白白浪费 CPU这个坑非常隐蔽。建议在代码里定个强制规约日志输出一律用占位符不在 log 参数里做任何拼接。5. 哈希、锁与业务场景字符串隐藏的“身份”问题5.1 hashCode 的计算逻辑与缓存机制String 的 hashCode 算法是s[0]*31^(n-1) s[1]*31^(n-2) ... s[n-1]。选择 31 这个质数是因为31 * i可以被 JVM 优化成(i 5) - i位运算比乘法快。这个公式意味着内容相同的字符串hashCode 一定相同但 hashCode 相同不代表字符串一定相等哈希碰撞。所以用 String 做 HashMap 的 key 时HashMap 先比 hashCode 定位桶再用 equals 确认真正匹配。你调用 HashMap 的 get 方法传入了一个内容相同但不是同一个引用的字符串时能查出来靠的就是 hashCode equals 的协作。由于 String 是不可变的它的 hashCode 只需要计算一次之后一直复用缓存字段。如果 a 和 b 两个字符串内容相同它们的 equals 返回 truehashCode 也必然相同。这也是为什么在业务开发里我们可以放心地用 String 作为 Redis key、Map key——只要内容不变任何一次运行中生成的相同字符串都可以准确匹配到同一个桶位。5.2 为什么不要拿字符串当锁对象// 不推荐 synchronized (name) { // ... }字符串常量池机制决定了内容相同的字符串可能共享同一个对象。如果你在不同的类、不同的模块里拿着两个内容都是PAY的字符串做锁它们实际可能是同一个对象于是互相之间形成了跨模块的锁竞争——这还好更危险的是第三方库如果也在内部对某个相同字符串加锁你和它之间就产生了无意的锁交互。而且 String 常量池里的对象是全局共享的运气不好会出现死锁两个线程持有对方等待的字符串锁或者性能雪崩。要用锁请使用专门的对象private final Object lock new Object()或者 Lock 接口字符串只适合当 key不适合当锁。5.3 intern 在 JVM 和 Redis 中的内存瘦身实践假设业务里有这么个场景从第三方接口拿到一万条订单每条订单里都有个商户名称字段而商户名称总共就那么几百种。直接存原始字符串时一万条数据里可能有 9500 个内容相同的商户名对象白白吃掉大量堆内存。用intern()可以把它们全部指向常量池中的同一个对象内存占用瞬间降下来。我实际做过一次压测16 GB 堆内存的一个批处理任务intern 之前 OOMintern 之后峰值内存降到不到 1 GB效果立竿见影。但 intern 不是万能的。它的池在 JDK 7 位于堆内存对象过多了会挤占堆空间引发 GC 频率直线上升。而且 intern 本身是 native 方法频繁调用也有额外开销。一个更可控的替代方案是使用自定义的字符串池用HashMapString, String自己缓存规范字符串或者使用 Guava 的Interner。它们的思路是内容相同就返回已有对象但池的大小和清理策略完全由你掌控不至于让 JVM 的常量池无脑膨胀。5.4 switch 对 String 的支持与隐藏的 equals 调用switch (type) { case RED: ... case BLUE: ... }Java 7 开始 switch 支持 String。它的底层实现并不是直接比较引用而是先计算 hashCode然后用 equals 确认。这个细节解释了一个现象同一段 switch 代码里只要 case 后边的内容相同就没有问题但如果常量池里没有相同的对象JVM 也能通过 equals 正确命中。这也是为什么有人告诉你switch 用 String 会先比较 hash 再 equals本质上比几个 if-else 快不了多少——因为 if-else 每个分支只要比较字符串内容就够了。不过从代码可读性角度switch 仍然是比一长串 if-else 清晰得多的表达方式性能差异在绝大多数业务场景里无足轻重。6. 高频面试题专场这些字符串问题你真的答对了吗6.1 经典题一new String(abc) 创建了几个对象这个问题考察的是常量池和堆对象的理解。答案分两种情况如果常量池中已有abc通常类加载时字面量已经入池只创建一个堆对象如果没有则先创建常量池对象再创建堆对象共两个。注意类加载时字面量已经入池这一点对于一个类里的new String(abc)表达式字符串abc本身作为常量会被放进该类的常量池然后在运行时new才会真实创建堆对象。这题并不考死记硬背而是考你对 JVM 类加载和运行时常量池是否形成完整认知。6.2 经典题二字符串数组排序与逆序的实现思路面试里常见的是让手写字符串数组排序或者字符串逆序。排序的核心就是调用compareTo比较字符串字典序再配合 Arrays.sort 完成String[] arr {banana, apple, Cherry, Mango}; Arrays.sort(arr); // 按 Unicode 字典序升序 Arrays.sort(arr, String.CASE_INSENSITIVE_ORDER); // 忽略大小写排序 Arrays.sort(arr, Comparator.reverseOrder()); // 降序字符串逆序有几层递进的考点。基础版本是new StringBuilder(s).reverse().toString()这个能过 80% 的题。进阶版本要求不借助 API 手动实现可以转字符数组后对撞交换收尾指针互换。再进阶一点会考整句按单词反转比如I am a student变成student a am I这类题通常会结合 split 和栈或双端队列来解。回答的过程中如果能提到哪些场景必须注意 Unicode 代理对、哪些场景只处理 ASCII 就够会显得你考虑问题更全面。6.3 经典题三比较代码的输出结果String s1 new String(hello); String s2 new String(hello); String s3 hello; String s4 hello; System.out.println(s1 s2); // false两个堆对象 System.out.println(s1 s3); // false堆对象 vs 常量池对象 System.out.println(s3 s4); // true常量池复用 System.out.println(s1.equals(s2)); // true内容相等 System.out.println(s1.intern() s3); // trueintern 返回池中引用这个题目几乎串起了字符串常量池全部考点。真正想答好需要现场说出每一行背后的 JVM 行为哪些对象在类加载时进入常量池哪些对象在运行时分配在堆intern 的返回值语义是什么。很多八股文背得溜的候选人在我追问JDK 7 以后 intern 的实现和 JDK 6 有什么不同时会卡住说明他们只记住了结论没有理解常量池在堆中的那个关键转变。JDK 6 的 intern 是在永久代PermGen操作池中没有内容相同对象时会复制一份放入永久代JDK 7 之后常量池并入堆intern 可以直接把堆对象的引用登记入池省了复制也因此更省内存。6.4 经典题四String、StringBuilder、StringBuffer 三者的区别这是八股文里的老面孔了。完整回答分三层不可变性——String 不可变另外两个可变线程安全性——String 因为不可变所以线程安全StringBuilder 非线程安全StringBuffer 的方法加了 synchronized 所以线程安全性能——单线程拼接 StringBuilder StringBuffer String大量拼接时。在此基础上如果能补充一句String 的不可变性也让它更适合做 Map key 和缓存 hash 值会显得你不仅背了结论还理解了原因。再深入一点可以提 JDK 9 的 compact strings 对 String 存储结构的影响以及编译器对字符串常量折叠的优化。面试官如果关注底层这些点很快能挖出来。6.5 经典题五split 的 limit 参数和空串问题split(regex)默认会处理末尾的空字符串a,b,.split(,)的结果长度是 2不是 3末尾的空串被丢弃了。这个行为和不少人的直觉相反。如果想保留末尾的空串要使用带 limit 参数的重载a,b,.split(,, -1)返回长度 3被保留。limit为正数时限制拆分次数为负数时保留所有空串为 0 时丢弃末尾空串和单参数行为一致。这是平时解析 CSV、日志时最容易出 bug 的地方之一。另外split 在匹配不到任何分隔符时会返回只包含原字符串的数组而不是空数组这点也值得注意。7. 实际开发中的字符串处理经验与工程化建议7.1 编码统一字符集问题是隐蔽的定时炸弹几乎所有字符串乱码问题的根源都是编码不一致。日常开发我强烈建议所有文件、连接、传输层全部显式指定 UTF-8不要依赖平台默认编码。Java 8 里FileReader这类工具默认使用平台编码在 Windows 上和 Linux 上行为不一致部署到服务器后就出现乱码。Java 18 开始默认字符集强制成了 UTF-8新项目直接受益但老项目升级时得先检查有没有隐式依赖平台默认编码的位置。还有一个容易被忽略的位置是String.getBytes()和new String(bytes)这两个无参版本同样使用平台默认编码在跨平台场景下必须显式传入StandardCharsets.UTF_8。// 推荐写法 byte[] bytes text.getBytes(StandardCharsets.UTF_8); String text new String(bytes, StandardCharsets.UTF_8);7.2 大批量文本拼接StringJoiner 和 Stream 的 joining如果你在用 Java 8拼接带分隔符的列表时除了String.join还有一个更灵活的StringJoiner。它能额外指定前缀和后缀非常适合生成[a, b, c]这种格式或者构建 SQL in 子句StringJoiner joiner new StringJoiner(, , [, ]); joiner.add(a).add(b).add(c); System.out.println(joiner); // [a, b, c]配合 Stream 使用Collectors.joining可以一行完成集合到字符串的转换这在生成报表、拼接批量操作语句时非常好用ListString ids Arrays.asList(1, 2, 3); String inClause ids.stream().collect(Collectors.joining(,, (, ))); // (1,2,3)7.3 处理脏数据空串、空白和 null 的最好实践业务系统里最常见的字符串处理不是转型不是截取而是判空。Java 11 之后建议统一使用isBlank()判断空它同时覆盖null之外的和纯空白字符串的情况。null本身需要额外处理public static boolean isAllBlank(String s) { return s null || s.isBlank(); }如果项目里已经引入了 Apache Commons Lang直接用它提供的StringUtils工具类判空、截断、默认值、拼接等常用操作都有现成封装比自己手写更不容易漏边界。第三个实用技巧是保留字符串脱敏能力手机号、身份证号在日志和接口返回时都要脱敏处理建议集中封装一个DesensitizeUtil而不是散落在业务代码里到处写 substring。总之和字符串打交道先定边界再写逻辑能省下大量无意义的调试。7.4 大文本拆分的性能与稳定性如果需要对一个很大的字符串做拆分或逐行处理比如几十 MB 的日志文件内容不要一次String.split成满配数组可以先按行BufferedReader.readLine()流式处理每行小范围 split。同理对大字符串做反复 replace 时多次操作会生成大量中间对象。对于超大文本做多关键字替换的场景一次性用正则把所有关键字匹配出来配合回调函数动态替换比循环 replace 快很多生成的垃圾对象也少很多。从工程角度说字符串优化的通用思路就是减少中间对象的产生和合理预估容量。这两个原则在这篇文章里反复出现串起来看你会发现Java 对字符串的几乎所有设计决策——不可变、常量池、hashCode 缓存、StringBuilder 的存在——都指向同一个方向在保证安全的前提下尽可能减少重复对象的创建和内存的浪费。最后分享我自己的一个判断标准在 code review 里如果连续看到三行以上的字符串拼接我都会停下来想一想这里能不能用占位符、StringJoiner 或者 StringBuilder 替代。不是为了炫技而是字符串相关的线上问题排查过太多次每一次的根源几乎都藏在那些看起来没什么问题的拼接和转换里。把这篇文章里的底层逻辑吃透再去写代码或者看别人的代码你会突然发现自己能预感到哪些位置迟早要出问题了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑