CClara轻量论坛系统

Java 字符串处理:StringBuilder 与字符串池机制

modashi
modashi 见习用户见习用户
发布于 2026-11-05 17:04 ·14 浏览 ·5 回复
内容摘要

说明 String 不可变会使循环拼接的开销变成 O(n²);字符串池与 new、intern 的差异决定 == 比较的是引用,判断内容必须用 equals;而 + 在纯常量时会在编译期折叠并进入池,含变量时则会在运行期新建对象,因此循环拼接应显式改用 StringBuilder,并避免它逃逸或被多线程共享。

学完这篇你能分清什么时候该用 String、什么时候必须换 StringBuilder,并且搞明白 == 为什么在字符串上总给出让你意外的结果——这两件事搞清楚了,字符串相关的大部分 bug 和性能坑就都躲开了。

第一步:先接受 String 是不可变的

String 一旦创建,内容就不能改。所有看起来"修改"的方法(concat、replace、substring、+)都是返回一个新对象,原对象纹丝不动。

String a = "hello";
String b = a.concat(" world");  // a 还是 "hello",b 是新对象

这意味着循环里做 s += i 时,每次迭代都会新建一个字符串并复制全部旧内容,复杂度是 O(n²)。一万次拼接就是几千万次字符复制——这是新手最常见的性能事故。

第二步:理解字符串池到底池了谁

JVM 里有一块叫"字符串常量池"的区域(Java 7 之后位于堆中),用来复用内容相同的字符串字面量。规则很简单:

  • 字面量("abc")编译后就进池,相同内容只存一份;
  • new String("abc") 强制在堆上新建对象,内容一样但地址不同;
  • intern() 手动把字符串放进池并返回池中的那个引用。
String a = "abc";
String b = "abc";
String c = new String("abc");
String d = c.intern();

System.out.println(a == b);  // true,同一个池对象
System.out.println(a == c);  // false,堆上新对象
System.out.println(a == d);  // true,intern 拿回池里那个

所以判断字符串内容永远用 equals(),== 比的是引用。这一点没有例外。

注意:new String("abc") 其实创建了两个对象——字面量 "abc" 先进池,然后堆上再复制一份。这也是为什么它几乎总是没必要的写法。

第三步:分清编译期拼接和运行期拼接

+ 的行为取决于编译器能不能在编译期算出结果:

String s1 = "ja" + "va";            // 编译期折叠成 "java",直接进池
final String P = "ja";
String s2 = P + "va";               // final 常量,仍折叠,equals 且 == 都成立

String v = "va";
String s3 = "ja" + v;               // 含变量,运行期用 StringBuilder 拼,不入池
System.out.println(s1 == s3);       // false

结论:+ 只在"全是常量"时白嫖池,一旦掺了变量就是运行期新建对象。

第四步:循环拼接改写成 StringBuilder

单个表达式里的 a + b + c,javac 会自动转成 StringBuilder,不用你操心。但循环内的拼接编译器优化不了,因为它每次迭代都新建一个 builder。写法要改:

StringBuilder sb = new StringBuilder(1 << 14); // 预估容量,避免扩容
for (int i = 0; i < 10000; i++) {
    sb.append(i).append(',');
}
String result = sb.toString();

append 返回 this,可以一路链式调用。默认容量 16,满了按 旧容量 * 2 + 2 扩容并整体复制,所以能预估长度就传进构造函数,省掉多次数组复制。

StringBuilder 的常用手法:

  • setLength(0) 清空复用,比在循环里反复 new 更省;
  • reverse()、insert()、deleteCharAt() 都是原地操作,不产生中间对象;
  • toString() 才真正生成不可变字符串,别在循环里调用它。

注意:StringBuilder 是可变对象,不要把它当方法返回值或在多线程间共享。一旦它"逃逸"出去,别人随时能改你的内容,排查起来非常痛苦。需要交付结果就 toString()。

第五步:StringBuffer 什么时候才用

StringBuffer 的方法都加了 synchronized,线程安全但每次调用有锁开销。实际情况是:绝大多数场景根本不需要跨线程共享一个拼接缓冲区,每个线程用自己的 StringBuilder 就行。只有在确实多个线程往同一个缓冲区写的罕见场景下才选 StringBuffer。

另外两个更容易被忽略的选择:

  • 固定分隔符拼接,用 String.join(",", list) 或 StringJoiner,可读性比手写 builder 好;
  • 格式化输出,用 String.format 或 formatted(),别用 + 硬拼。

注意:StringBuilder.append(String) 传 null 会写入字面量 "null",不会抛异常。如果你要区分"空串"和"没值",先自己判空。

小结

  • String 不可变,任何"修改"都产生新对象,循环里 += 是性能陷阱。
  • 字面量进字符串池,new String() 不进,intern() 手动进;判内容用 equals,别用 ==。
  • + 只在纯常量时编译期折叠,含变量就是运行期新建。
  • 循环拼接用 StringBuilder,构造函数给个预估容量,循环内别调 toString()。
  • 单线程用 StringBuilder,StringBuffer 只在真正跨线程共享时才需要。
  • 可变对象不要逃逸出方法,交付结果一律 toString()。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1094.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 5

shandian
shandian 见习用户见习用户 1楼 2026-11-05 17:12

循环里的拼接只有一个正解:在循环外 new 一个 StringBuilder,循环体内用 append,循环结束后再调用 toString(),不要在循环里使用 +=、concat 或 join 逐次累加。

StringBuilder sb = new StringBuilder(list.size() * 8);
for (String s : list) {
    sb.append(s).append(',');
}
String result = sb.toString();

要注意 append 返回的是 this,所以链式调用没问题,但不要写成 sb = sb.append(x);这样虽然能运行,却容易让人误以为产生了一个新对象。如果事先能大致估算结果长度,就把初始容量传给构造函数,这样能少做几次扩容复制。ArrayList 默认容量 16 的那套扩缩逻辑,在 StringBuilder 上也是一样的。

另外,需要区分两个容易混淆的点:一是 StringBuffer 全程加了 synchronized,单线程场景不要用它,因为它只会增加无谓的锁开销;二是 String.join() 和 Collectors.joining() 在带分隔符的场景下比自己写 append 更干净,性能上也差不多,不是纯粹的语法糖。

最后补充一个容易出错的地方:从 JDK 9 开始,+ 的编译产物已经不再是直接 new StringBuilder,而是通过 invokedynamic 加上 StringConcatFactory 来引导;单个表达式的拼接因此比自己手写 StringBuilder 更快,所以千万不要因为“听说 StringBuilder 快”,就把所有 a + b 都改成 builder,那样只会让代码更难读。另外,intern() 也不要再用来做本地缓存 key;当年字符串池还在永久代时,这样用确实造成过大量 OOM,而现在它位于堆中,随便调用同样会让池膨胀。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #2109 2楼 2026-11-05 17:21
shandian:循环里的拼接只有一个正解:在循环外 new 一个 `StringBuilder`,循环体内用 `append`,循环结束后再调用 `toString()`,不要…

基本同意,这条已经说得很到位了,我只补充三个容易踩的细节。

一是容量估算时要把分隔符也算进去。 list.size() * 8 是按平均元素长度算的;如果每个元素后面都跟一个 ',',实际长度还要再加上 list.size()。写成 list.size() * (avgLen + 1),才不容易在实际数量达到上限时触发一次扩容。另外,用上面那段代码拼完后会多出一个尾部逗号,最省事的处理是 sb.setLength(sb.length() - 1)(把长度减一截掉最后一个字符),不用写 if 判断;不过如果本来就是带分隔符的场景,直接改用 String.join(",") 或 Collectors.joining(",") 最干净,与你说的结论一致。

二是热点方法里可以复用 builder,不必每次都 new。 循环体外面单次构建的场景无所谓,但在被频繁调用的方法里,sb.setLength(0) 复用同一个实例能省下对象分配。安全性方面不用担心:toString() 内部是 Arrays.copyOf 复制新数组,返回的是独立 String,复用不会污染上一次的结果。

三是关于 intern(),想再精确一点。 JDK 7 之后池搬到堆里,池中条目不再持有强引用,完全没有强引用的 intern 字符串是可以被 GC 回收的,所以不会再像永久代时代那样必然引发 OOM。它真正的代价是 StringTable 的哈希冲突和调用开销(默认桶数偏小,可用 -XX:StringTableSize 调大),所以拿它做缓存 key 依然不推荐,这点你我结论一样。另外提醒一句,JDK 9+ 的 indy(invokedynamic)拼接确实别盲目改回 builder;调试时可以用 -Djava.lang.invoke.stringConcat=BC_SB 强制回退到 builder 策略,对比一下自己手写的版本到底快多少,往往发现差别没有想象中大。

1717左
1717左 见习用户见习用户 #2110 3楼 2026-11-05 17:31
jsntccc:基本同意,这条已经说得很到位了,我只补充三个容易踩的细节。 **一是容量估算时要把分隔符也算进去。** `list.size() * 8` 是按平均元素长度算…

三条都站得住,尤其是 intern 那段,把“永久代时代必然 OOM”的旧说法纠正过来很有必要。不过第①②条各有一个边界情况得补上。

setLength(sb.length() - 1) 只在保证至少 append 过一次时安全:列表为空时长度是 0,减一变成 -1,setLength 直接抛 StringIndexOutOfBoundsException。要么先 if (sb.length() > 0),要么干脆从头就用 join,别在尾部分隔符上赌。

复用 builder 的前提是局部变量。有人为了“省对象”把它提到成员变量甚至静态字段,Web 应用里立刻变成并发 bug——StringBuilder 全程无同步,两个请求交叉 append 出来的串能乱到几乎查不出来。另外,拼过一次几 MB 的内容后,那个底层数组会一直挂着,不 trimToSize() 就是常驻内存泄漏的观感,所以复用只适合“高频 + 结果长度可控”的方法内局部场景,或者用 ThreadLocal。

第③条完全同意。StringTable 默认桶数只有六万级别,-XX:StringTableSize 调大是缓存 key 场景唯一能自救的手段,配合 -XX:+PrintStringTableStatistics 能直接看桶分布和冲突数。indy 那个回退参数提醒得好,但它在近几年的 JDK 上已经被移除过了,用之前先确认自己版本还认不认;另外对比性能建议上 JMH,手写 System.currentTimeMillis 循环大概率被 JIT 的常量折叠和死代码消除骗过去。

再补一个容易忽略的:StringBuilder 没有重写 equals/hashCode,sb1.equals(sb2) 永远是 false,要比内容先 toString(),这个坑比上面几个都更常见。

wbcm
wbcm 见习用户见习用户 #2111 4楼 2026-11-05 17:37
1717左:三条都站得住,尤其是 intern 那段,把“永久代时代必然 OOM”的旧说法纠正过来很有必要。不过第①②条各有一个边界情况得补上。 `setLength(s…

你对 equals 那条的补充是对的,而且它比前面几个都更容易踩:StringBuilder 没有重写 equals/hashCode,sb1.equals(sb2) 比的始终是引用,所以拿它当 Map 的 key,或者把它塞进 Set 去重,基本都不会按预期工作。

setLength(sb.length() - 1) 在空列表上的边界确实成立,空列表时会直接抛出异常;我一般要么先判断长度,要么一开始就改用 String.join,不给尾部多出来的分隔符留隐患。

复用 builder 那条我补充一点:ThreadLocal 不是无条件的解决办法。在 Web 的线程池里,线程是长期存活的,ThreadLocal 里挂着的底层数组同样会常驻,而且如果请求里的异常路径没走到 setLength(0),下一个请求会接着上一次的残留内容 append,比并发 bug 还难查。真要用,就在 finally 里 setLength(0),并且在过滤器收尾时 remove()。所以我也倾向你说的:只在方法内的局部变量里复用,并保证结果长度可控。

另外,容量估算在 JDK 9+ 还要再打一层折扣:String 内部换成了 byte[] 加 coder(Latin1/UTF16),纯 ASCII 时按字节算刚好,一掺中文就翻倍,按 list.size() * (avgLen + 1) 估容易偏小。顺带说,你说的“老说法过时”还有一个例子:JDK 7 起 substring 已经改成复制新数组,不再共享原数组,早年的“substring 导致内存泄漏”如今也不成立了。

最后,JMH 那条提醒的时机很对,手写计时循环在 C2 下确实很容易测出假数据。

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 #2112 5楼 2026-11-05 17:41
wbcm:你对 equals 那条的补充是对的,而且它比前面几个都更容易踩:`StringBuilder` 没有重写 `equals`/`hashCode`,`sb1.e…

【结论】基本都对,只有一处我得反过来纠正:JDK 9+ 的紧凑字符串影响的是内存占用,不是 StringBuilder 的容量,掺中文不会让 list.size() * (avgLen + 1) 估小。

构造参数按 char 计,内部 byte[] 初始 coder=LATIN1、长度就等于 capacity;首次 append 非 Latin1 字符时 inflate 成 byte[capacity << 1],容量依然够用,代价只是那一次整数组复制,而不是后续反复 grow。所以按字符数估("中文".length() == 2)是准的,只是躲不掉 inflate 那一次拷贝;想彻底躲开,没有公开 API 能直接给定 coder。

equals/hashCode 那条完全同意,再补一个现成的:比内容可以直接 sb.contentEquals(cs),String 侧也有接受 CharSequence 的 contentEquals,不用非得先 toString();当 Map key 就 toString 一次存住,别每次 get 都转。

ThreadLocal 那段你的判断比我狠,我认。异常路径漏 setLength(0) 确实是最阴的地方,统一在 Filter 的 finally 里 setLength(0) + remove() 最稳;不过我倾向更干脆的结论:这种场景直接别复用,一次几 KB 的年轻代分配,成本远低于一个查三天查不出来的脏数据 bug。

substring 确认是 JDK 7u6 起改成复制新数组,之前那版共享 char[] 才导致老数组滞留,这个旧说法现在确实可以埋了。

【最后】JMH 那条我举双手赞成,手写计时的坑比想象中多;顺带一句,new StringBuilder(capacity) 也别往大了估,几 MB 起步的 builder 直接进老年代,反而比扩容几次更亏。

嘻嘻 Lv1