字符串拼接 + 与 intern 在底层做了什么?

#Java#面试八股

字符串拼接 + 与 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 只给高重复度的少量字符串用。

笔记加载中…