「JVM」 深入理解 StringTable:从底层编译优化到 intern 核心解密

在 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 运行期拼接

我们逐行来分析上半部分代码(s1s6)。

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,因为 s1s2 是变量,运行期间引用的值可能会变,编译器无法优化。 在 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 数据),我们该如何调优?

  1. 善用 **intern()**去重: 在读取大量重复度高的字符串对象(如省份、城市名称、商品分类)时,不要直接保留 new 出来的对象,统一过一遍 intern()。这样能在内存中只保留一份实例,极大降低内存占用。
  2. 调整 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" 了,所以只能把池里的引用返回给 s6s3s6 指向同一个池中对象。

下半场:跨版本的 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

彩蛋:如果调换 x1 和 x2.intern() 的位置?

  • String x1 = "cd";,后 x2.intern(); -> false**(无论 1.6 还是 1.8)**
    • 原因: x1 已经提前把真身 "cd" 塞进串池了。x2.intern() 一看池里有了,啥也不干。x2 依然是孤零零的堆对象,地址永远不等于池里的 x1

从一行简单的 "a" + "b",到复杂的 intern() 引用变更,背后折射出的是 Java 编译器和 JVM 在性能与内存开销之间所做的不懈努力。

掌握这些底层逻辑,不仅能让你在面试时成竹在胸,更能让你在日常开发中对内存占用保持极度敏锐的技术直觉。

相关推荐
JavaLearnerZGQ2 小时前
Spring Boot 流式响应接口核心组件解析
java·spring boot·后端
cur1es2 小时前
【TCP 协议的相关特性】
java·网络·网络协议·tcp/ip·tcp·滑动窗口·连接管理
山岚的运维笔记2 小时前
SQL Server笔记 -- 第80章:分页
java·数据库·笔记·sql·microsoft·sqlserver
Drifter_yh2 小时前
「JVM」 从字节码看多态原理与语法糖本质
jvm
开开心心就好2 小时前
文字转语音无字数限,对接微软接口比付费爽
java·linux·开发语言·人工智能·pdf·语音识别
海兰2 小时前
Elasticsearch 9.x Java 异步客户端
java·elasticsearch·jenkins
马猴烧酒.2 小时前
【JAVA算法|hot100】哈希类型题目详解笔记
java·笔记
毕设源码-邱学长2 小时前
【开题答辩全过程】以 果蔬销售管理系统为例,包含答辩的问题和答案
java
Drifter_yh2 小时前
「JVM」 Java 类加载机制与双亲委派模型深度解析
java·开发语言·jvm