字符串拼接 + 与 intern 在底层做了什么?
结论:+ 拼接分两阶段处理。编译期,常量表达式(字面量与 final 常量)直接折叠成一个字面量;运行期,JDK 8 及以前编译成 StringBuilder.append 链,JDK 9 起改写为 invokedynamic + StringConcatFactory,按预编译的 recipe 一次算长度、一次分配、逐段写入,少了一次中间 StringBuilder 对象。intern() 则是把堆上的字符串登记进字符串常量池并返回池中引用(JDK 7+ 池在堆内,登记的是引用而不是副本)。
1. 编译期折叠:只有常量才享受
| 写法 | 编译产物 | 运行期新建对象 |
|---|---|---|
"a" + "b" | 直接是 "ab" | 0 |
final String A = "a"; A + "b" | 折叠为 "ab"(常量变量) | 0 |
String a = "a"; a + "b" | invokedynamic 拼接 | 1 |
循环里 s += i | 每轮一次拼接 | 每轮至少 1 |
编译期折叠(constant folding)依赖 JLS 的"常量表达式"定义:基本类型与 String 的字面量、final 且以常量初始化的变量、以及它们的运算组合。final 变量如果不是常量表达式(如 final String A = getA()),就不折叠。
2. 运行期拼接近年来的两种实现
JDK 8: s = a + b + c
new StringBuilder()
.append(a).append(b).append(c).toString()
→ 1 个 StringBuilder + 1 个 char[](可扩容)+ 1 个结果 String
JDK 9+: s = a + b + c
invokedynamic makeConcatWithConstants(Ljava/lang/String;...)Ljava/lang/String;
BootstrapMethod: StringConcatFactory
recipe = "\u0001\u0001\u0001"
运行期:遍历参数算总长度 → 一次分配 byte[] → 逐段 copy → new String
→ 0 个 StringBuilder,1 个 byte[],1 个结果 String
用 javap -v 能看到 BootstrapMethods 里的 StringConcatFactory.makeConcatWithConstants,以及 InvokeDynamic #0:makeConcatWithConstants。也就是说,"字符串拼接一定用 StringBuilder"这句话在 JDK 9+ 已经不完全准确,但在循环里依然要手动复用缓冲区。
3. 代码:拼接与 intern
public class ConcatDemo {
public static void main(String[] args) {
String x = "world";
String s = "hello " + x + "!";
System.out.println(s); // hello world!
// 循环拼接:编译器帮不了你,必须手动复用
StringBuilder sb = new StringBuilder(64);
for (int i = 0; i < 3; i++) {
sb.append("item").append(i).append(';');
}
System.out.println(sb); // item0;item1;item2;
// intern:适合高重复度的枚举型字符串(状态码、币种、配置键)
String raw = new String("PAID");
String pooled = raw.intern();
System.out.println(pooled == "PAID"); // true
}
}
高频重复字符串先 intern() 再当 Map 的 key,可以减少重复对象、让比较退化为地址比较;但字符串种类成千上万时,intern() 会让 StringTable 变成长链表,反而拖慢。
4. 常见追问
| 追问 | 回答要点 |
|---|---|
intern() 会复制字符串吗? | JDK 7+ 不会,池里存的是堆对象的引用;JDK 6 会复制到永久代 |
intern() 有什么代价? | 需要查 StringTable(哈希表),并可能触发 GC 清理;高基数场景慎用 |
| 拼接结果会自动进池吗? | 不会,a + b 的结果是普通堆对象,除非显式 intern() |
为什么 final String 拼接不新建对象? | 编译期常量折叠,字节码里只有一条 ldc |
StringBuilder 和 invokedynamic 哪个快? | 单次少量拼接 indy 略快(少一个对象);循环/大量拼接手动 StringBuilder 更可控 |
拼接含 null 会怎样? | String.valueOf(null) 得到 "null",不会 NPE(+ 语义如此) |
| 拼接性能问题的排查信号? | 火焰图里 StringBuilder.append / StringConcatHelper 占比高,或 YGC 频繁且存活对象小 |
补充一个容易忽略的点:String.join、StringJoiner 内部同样是一次分配,适合带分隔符的场景;而 String.format 走的是解析 + 反射式格式化,性能明显更差,日志里应优先用占位符(如 SLF4J 的 {})而不是先拼接。
5. 一个反直觉的例子
final String A = "a";
String b = "b";
System.out.println((A + b) == "ab"); // false:b 是变量,运行期拼接产生新对象
final String C = "b";
System.out.println((A + C) == "ab"); // true:全部是常量,编译期折叠为 "ab"
两行的差别只在 b 是不是 final 且以常量初始化。判断是否折叠看的是表达式里是否全部为常量,而不是"看起来像常量";final 变量若由方法调用初始化,同样不参与折叠。
小结
记住三条:常量折叠在编译期、变量拼接在运行期(JDK 9+ 走 indy)、intern 只是登记引用。写代码时,常量拼接随意,循环拼接手动 StringBuilder,intern 只给高重复度的少量字符串用。