在 Java 后端开发中,String 毫无疑问是我们使用最频繁的类。但在高并发场景下,如果不了解 String 的底层复用机制,大量的字符串对象会瞬间塞满堆内存,引发频繁的 GC,甚至导致 OOM。
为了优化内存,JVM 引入了 StringTable(字符串常量池/串池) 。今天我们就通过一段经典的面试代码,把常量池、编译期优化以及 intern() 方法的底层逻辑扒得干干净净。
一、 经典代码开局:你能全对吗?
先来看一段非常经典的面试题代码(请先在脑海中给出答案):
Java
public static void main(String[] args) {
String s1 = "a";
String s2 = "b";
String s3 = "a" + "b";
String s4 = s1 + s2;
String s5 = "ab";
String s6 = s4.intern();
// 问:以下输出什么?
System.out.println(s3 == s4);
System.out.println(s3 == s5);
System.out.println(s3 == s6);
String x2 = new String("c") + new String("d");
x2.intern();
String x1 = "cd";
// 问:如果是 JDK 1.8,输出什么?如果是 JDK 1.6 呢?
System.out.println(x1 == x2);
}
如果你对上面的输出有一丝犹豫,或者不知道 1.6 和 1.8 的区别,那么请看接下来我的分析。
二、 剥开底层:编译期优化 vs 运行期拼接
我们逐行来分析上半部分代码(s1 到 s6)。
1. 字符串的延迟加载
当类加载完成后,"a"、"b"、"ab" 这些字面量其实只是存在于运行时常量池 中的符号,并没有真正在内存中生成对象。只有当代码执行到 String s1 = "a"; 这行指令时,JVM 才会去 StringTable(底层是一个基于 C++ 实现的 HashTable)中找有没有 "a",如果没有,就创建对象并放入池中。这就叫延迟加载。
2. 编译期优化:字面量的拼接
Java
String s3 = "a" + "b";
String s5 = "ab";
对于 s3,由于 "a" 和 "b" 都是写死的常量,javac 编译器在编译期就会极其聪明地进行常量折叠优化 。它知道这两个拼起来肯定是 "ab",所以编译后的字节码直接就是找 "ab"。 执行时,s3 会把 "ab" 放入串池并指向它。等到执行 s5 = "ab" 时,发现串池里已经有 "ab" 了,直接复用。 结论: s3 == s5****返回 true**。**
3. 变量的拼接:隐藏的 StringBuilder
Java
String s1 = "a";
String s2 = "b";
String s4 = s1 + s2;
对于 s4,因为 s1 和 s2 是变量,运行期间引用的值可能会变,编译器无法优化。 在 JDK 1.8 中,底层会自动将其转换为:new StringBuilder().append("a").append("b").toString()。 重点来了!StringBuilder.toString() 的底层其实是 new String("ab")。这就意味着,s4****最终指向的是堆内存中一块全新的对象地址,而没有进入串池。 结论: s3****在池中, s4****在堆中, s3 == s4****返回 false**。**
三、 深渊巨坑:intern() 方法的跨版本恩怨
intern() 方法的作用一言以蔽之:尝试将这个字符串对象放入 StringTable 中。如果池中已经有了,就返回池中的对象;如果没有,就把自己放进去,并返回池中的对象。
上半段的 String s6 = s4.intern();,因为池中已经有了 "ab"(s3 放进去的),所以 s6 直接拿到了池中的引用。 结论: s3 == s6****返回 true**。**
但真正的难点在于截图下半段的代码:
Java
String x2 = new String("c") + new String("d"); // 此时 x2 指向堆中的 "cd",并且此时池中没有 "cd"
x2.intern(); // 尝试把 "cd" 放入串池
String x1 = "cd"; // 从串池拿 "cd" 的引用
System.out.println(x1 == x2);
这里为什么会分 JDK 版本呢?这与我们上一篇讲到的 StringTable 物理位置的搬家息息相关。
JDK 1.6:物理复制(返回 false)
在 1.6 时,StringTable 还在方法区(永久代),而 x2 的对象在堆区。 当执行 x2.intern() 时,由于池中没有 "cd",1.6 的做法是:把堆中的 x2****深度拷贝一份,创建一个全新的对象扔进方法区的常量池里。 所以,x1 拿到的是方法区的新对象,x2 是堆里的老对象,两者地址截然不同。结果是 false。
JDK 1.8:地址引用(返回 true)
在 1.8(确切说是 1.7 及之后)时,StringTable 已经搬到了堆内存。 当执行 x2.intern() 时,因为池中没有 "cd",而且池子和对象都在堆里,为了节省内存,1.8 的做法是:不再复制整个对象,而是直接把 x2****的内存地址引用存入 StringTable 中。 也就是说,此时 StringTable 里的 "cd" 实际上就是堆里的 x2!随后执行 String x1 = "cd" 时,拿到的自然也是 x2 的引用。 所以,两者指向同一个堆内存地址,结果是 true!
🤔 变体思考: 如果我们把顺序换一下,先 String x1 = "cd";,再 x2.intern();,结果是什么? (答案:此时无论 1.6 还是 1.8 都是 false。因为 x1 执行时已经把真正的 "cd" 塞进常量池了,后续 x2.intern() 发现池里有了,就不会再存入自己的引用,x2 依然是那个孤零零的堆对象)。
四、 性能调优
既然 StringTable 如今在堆中,它同样受垃圾收集器(GC)的管理。当内存紧张且字符串没有强引用时,它们会被顺利回收。
但如果我们系统中有数以百万计的字符串(比如解析超大的日志文件、JSON 数据),我们该如何调优?
- 善用 **intern()**去重: 在读取大量重复度高的字符串对象(如省份、城市名称、商品分类)时,不要直接保留 new 出来的对象,统一过一遍
intern()。这样能在内存中只保留一份实例,极大降低内存占用。 - 调整 StringTableSize**:** StringTable 的底层是一个 Hash 表。如果放进去的字符串极其多,会导致 Hash 冲突严重,链表变长,
intern()的性能急剧下降。在启动参数中可以通过-XX:StringTableSize=桶个数来调大桶的数量(默认是 60013),用空间换时间。
总结
上半场:常规拼接与复用
- s3 == s4**->** false
-
- 原因:
s3是纯字符串常量拼接,编译期直接优化成"ab"放入串池;s4是变量拼接,底层隐式new StringBuilder()在堆里生成了新对象。串池地址 != 堆地址。
- 原因:
- s3 == s5**->** true
-
- 原因: 编译期发现串池里已经有
s3放进去的"ab"了,s5直接拿来复用。
- 原因: 编译期发现串池里已经有
- s3 == s6**->** true
-
- 原因:
s4.intern()尝试把堆里的"ab"放进串池,发现池里早就有s3的"ab"了,所以只能把池里的引用返回给s6。s3和s6指向同一个池中对象。
- 原因:
下半场:跨版本的 intern() 巨坑
- x1 == x2**(JDK 1.8) ->** true
-
- 原因:
x2在堆中创建"cd",执行intern()时发现串池为空,1.8 为了省内存,直接把堆中 x2****的地址引用存进了串池 。所以x1从池中拿到的,其实就是堆里的x2本尊。
- 原因:
- x1 == x2**(JDK 1.6) ->** false
-
- 原因: 1.6 的串池在方法区,执行
intern()发现池里为空,会把堆里的 x2****深度复制一份全新的对象扔进串池 。x1拿到的是池里的副本,当然不等于堆里的x2。
- 原因: 1.6 的串池在方法区,执行
彩蛋:如果调换 x1 和 x2.intern() 的位置?
- 先
String x1 = "cd";,后x2.intern();-> false**(无论 1.6 还是 1.8)**
-
- 原因:
x1已经提前把真身"cd"塞进串池了。x2.intern()一看池里有了,啥也不干。x2依然是孤零零的堆对象,地址永远不等于池里的x1。
- 原因:
从一行简单的 "a" + "b",到复杂的 intern() 引用变更,背后折射出的是 Java 编译器和 JVM 在性能与内存开销之间所做的不懈努力。
掌握这些底层逻辑,不仅能让你在面试时成竹在胸,更能让你在日常开发中对内存占用保持极度敏锐的技术直觉。