一文彻底搞懂 Java 字符串常量池与 intern() 机制
很多 Java 初学者在面试中都会遇到这样的问题:String s1 = "abc" 和 String s1 = new String("abc") 有什么区别?intern() 方法有什么用?JDK 6 和 JDK 7 之后有什么不同?
这篇文章会用最通俗的方式,带你把这块知识彻底吃透。
一、先搞清楚:到底有几个"常量池"?
很多人学这块内容一上来就晕,根本原因是把两个"常量池"搞混了。
1.1 类文件常量池(.class 文件里的)
你写的每一个 .java 文件,经过编译后会生成一个 .class 文件。这个文件里有一块区域叫常量池,存放的是编译期就能确定的东西,比如字符串字面量 "abc" 的 UTF-8 编码数据、类名、方法名等。
重点:这里面存的只是原始数据,不是 Java 对象。
1.2 字符串常量池(StringTable,运行时的)
这是 JVM 在运行时维护的一个哈希表,你可以把它理解成一个"登记簿"。它的作用是:保证内容相同的字符串在内存中只需要存一份,大家共用。
重点:这个表里存的是引用(指针),指向真正的 String 对象。 不管是 JDK 6 还是 JDK 7+,StringTable 存的都是引用。区别在于:JDK 6 中引用指向的 String 对象在永久代(PermGen),JDK 7+ 中在普通堆上。
一句话总结:
.class里的常量池是"原材料",运行时的 StringTable 是"登记簿"。
二、字面量 "abc" 对应的 String 对象,到底什么时候创建?
很多人会说"字面量在类加载时就放入常量池了",这个说法不够准确。实际过程分三步:
第一步:编译期 编译器把 "abc" 这段文字写入 .class 文件的常量池中。此时只是 UTF-8 数据,不是对象。
第二步:类加载 JVM 加载 .class 文件,把常量池数据读入内存中的运行时常量池。但是!String 对象仍然没有被创建。
第三步:代码真正执行到这一行 当 JVM 执行到 String s = "abc"; 对应的字节码指令 ldc "abc" 时,才会:
- 去 StringTable 里查:有没有内容为
"abc"的记录? - 没有 → 在堆上创建一个
String("abc")对象(jdk7),把引用登记进 StringTable,返回这个引用 - 有 → 直接返回已有的引用
JVM 规范允许实现选择提前解析(eager)或延迟解析(lazy)。HotSpot(最主流的 JVM 实现)采用的是延迟解析,即
ldc首次执行时才创建对象并入池。简单理解:字面量对应的 String 对象是"懒加载"的——用到了才创建。 这是理解后面所有内容的关键前提。
三、String s = "abc" 和 new String("abc") 的区别
3.1 字面量赋值
String s1 = "abc";
String s2 = "abc";
System.out.println(s1 == s2); // true
s1 执行时,ldc "abc" 发现池里没有,创建对象入池,s1 指向它。 s2 执行时,ldc "abc" 发现池里有了,直接返回同一个对象。 所以 s1 和 s2 指向同一个对象,== 为 true。
3.2 new 创建
String s1 = new String("abc");
String s2 = "abc";
System.out.println(s1 == s2); // false
new String("abc") 这一行实际上干了两件事:
- 先执行
ldc "abc":在堆上创建对象 A,登记进 StringTable - 再执行
new String(对象A):在堆上又创建了一个全新的对象 B
s1 指向对象 B(new 出来的),s2 指向对象 A(池中登记的),它们是两个不同的对象,所以 == 为 false。
用一张图表示:
堆内存:
┌──────────────┐ ┌──────────────┐
│ 对象A │ │ 对象B │
│ String "abc"│ │ String "abc" │
│ (池中登记的) │ │ (new出来的) │
└──────────────┘ └──────────────┘
↑ ↑
StringTable 和 s2 s1
所以
new String("abc")会创建两个对象,这是面试高频考点。(如果 StringTable 中已有"abc",则ldc不会再创建新对象,此时只创建一个)
四、字符串拼接 + 发生了什么?
4.1 包含变量或 new 的拼接
String s1 = new String("a") + new String("b");
这行代码底层通过 StringBuilder 完成拼接,最终调用 toString() 在堆上生成一个内容为 "ab" 的新 String 对象。
关键点:这种拼接生成的字符串不会自动进入 StringTable。 也就是说,执行完这行代码后,StringTable 里有 "a" 和 "b" 的记录(因为 ldc 触发了),但没有 "ab" 的记录。
这一点非常重要,直接决定了后面 intern() 的行为。
4.2 常量表达式拼接(重要特例)
String s1 = "a" + "b";
String s2 = "ab";
System.out.println(s1 == s2); // true
如果拼接双方都是编译期常量(字面量、final 常量等能在编译期确定值的变量),Java 编译器会在编译阶段直接将其优化为结果字符串。也就是说 "a" + "b" 在 .class 文件中直接变成了 "ab",和你手写 "ab" 没有任何区别。
只有当拼接中包含变量或
new String()时,才会走StringBuilder,生成不在池中的新堆对象。
五、intern() 方法到底做了什么?
5.1 基本逻辑
intern() 的作用可以用一段伪代码概括:
public String intern() {
if (StringTable 中有内容相同的字符串) {
return StringTable 中那个对象的引用;
} else {
// JDK 7+ 的行为:
把当前对象的引用登记进 StringTable;
return 当前对象自己;
// JDK 6 的行为:
// 在永久代复制一份新对象放入 StringTable;
// return 那份新对象;
}
}
注意:"登记进 StringTable" = 在哈希表中插入一条 key-value 记录(key 是内容哈希,value 是指向已有堆对象的引用)。不涉及创建新对象。
5.2 两个容易踩的坑
坑一:intern() 不会改变调用者变量的指向
String s1 = new String("a");
s1.intern(); // 返回值被丢弃了,s1 没有任何变化
intern() 只是返回一个引用,就像 toUpperCase() 一样,不会修改原变量。你必须用返回值接住:
s1 = s1.intern(); // 这样 s1 才会指向池中对象
坑二:谁先执行谁占坑
StringTable 就像一个"先到先得"的登记簿。无论是 ldc 指令还是 intern() 方法,谁先把某个字符串放进去,后来的就只能用已有的。执行顺序决定一切。
六、JDK 6 和 JDK 7+ 的核心区别
6.1 内存布局不同
JDK 6:StringTable 引用的 String 对象分配在永久代(PermGen)
JDK 7+:StringTable 引用的 String 对象分配在 Java 堆中(JDK 8 彻底移除了永久代)
问题二:第三章 3.2 "两个对象"缺少前提条件
6.2 intern() 行为不同
当 StringTable 中没有对应字符串时:
- JDK 6:在永久代中复制一份全新的 String 对象(包括内部的 char 数组)放入池中,返回这份副本的引用。原堆对象和池中对象是两个独立的实例。这也是为什么 JDK 6 中频繁调用
intern()容易导致永久代 OOM。 - JDK 7+:不复制,直接把当前堆上对象的引用登记进池中,返回当前对象自己。
这个区别直接导致了同一段代码在不同版本上的运行结果不同。
七、经典面试题深度剖析
以下分析均基于 JDK 8 环境,假设之前没有其他代码触发过相关字面量。
7.1 先 intern 再字面量
String s1 = new String("b") + new String("c");
s1.intern();
String s2 = "bc";
System.out.println(s1 == s2);
逐行分析:
-
s1 = new String("b") + new String("c")new String("b")触发ldc "b","b"进入 StringTablenew String("c")触发ldc "c","c"进入 StringTable- 拼接生成内容为
"bc"的新堆对象,赋给s1 - 此时 StringTable 中没有
"bc"
-
s1.intern()- 查 StringTable,没有
"bc" - 把
s1的引用登记进 StringTable - 现在池中的
"bc"就是s1这个对象
- 查 StringTable,没有
-
String s2 = "bc"ldc "bc"查 StringTable,发现已有(就是s1)s2拿到的引用就是s1
结果:true。因为 s1 先占了坑,s2 复用了 s1。
7.2 先字面量再 intern
String s1 = new String("b") + new String("c");
String s2 = "bc";
s1.intern();
System.out.println(s1 == s2);
逐行分析:
-
s1 = new String("b") + new String("c")- 同上,
s1是堆上的拼接对象,池中没有"bc"
- 同上,
-
String s2 = "bc"ldc "bc"查 StringTable,没有- 创建一个新的 String 对象登记进池,
s2指向这个新对象 s2先占了坑
-
s1.intern()- 查 StringTable,发现已有
"bc"(s2那个) - 返回
s2的引用,但返回值被丢弃 s1仍然指向拼接出来的那个对象
- 查 StringTable,发现已有
结果:false。因为 s2 先占了坑,s1 和 s2 是两个不同的对象。
7.3 同一段代码,JDK 6 vs JDK 7+ 对比
String s1 = new String("b") + new String("c");
s1.intern();
String s2 = "bc";
System.out.println(s1 == s2);
| JDK 版本 | 结果 | 原因 |
|---|---|---|
|
JDK 6 |
|
|
|
JDK 7+ |
|
|
7.4 高级陷阱:把 "bc" 换成 "java" 会怎样?
学完前面三道题,你可能觉得已经万无一失了。来看这道:
String s1 = new String("ja") + new String("va");
s1.intern();
String s2 = "java";
System.out.println(s1 == s2);
按照 7.1 的思路推导:s1.intern() 时池中没有 "java",应该把 s1 登记进池,后面 s2 复用 s1,结果应该是 true。
但实际运行结果是 false。
原因:JVM 在启动阶段就已经把 "java" 放入了 StringTable。
JVM 启动时会加载大量核心类(如 sun.misc.Version、java.lang.VersionProps 等),这些类的代码中包含 "java" 这个字面量。当这些类被加载并执行到相关 ldc 指令时,"java" 就已经进入 StringTable 了——这一切发生在你的 main 方法执行之前。
所以真正的执行过程是:
-
JVM 启动阶段:核心类加载,
"java"已经在 StringTable 中(指向一个你看不到的对象 X) -
s1 = new String("ja") + new String("va")- 拼接生成堆上的
"java"对象,赋给s1
- 拼接生成堆上的
-
s1.intern()- 查 StringTable,发现
"java"已经存在(对象 X) - 返回对象 X 的引用,但返回值被丢弃
s1没能占到坑
- 查 StringTable,发现
-
String s2 = "java"ldc "java"从 StringTable 拿到对象 X,s2指向对象 X
-
s1(拼接的堆对象)!=s2(对象 X)→false
可以验证——换一个 JVM 启动时不会用到的字符串就正常了:
String s1 = new String("ja") + new String("vx");
s1.intern();
String s2 = "javx";
System.out.println(s1 == s2); // true — "javx" 不会被预加载
核心认知:不是只有你的代码才能往 StringTable 里放东西,JVM 自身在启动时也会放入一批字符串。 具体有哪些取决于 JDK 版本和实现,没有固定清单,但
"java"是最常被考到的一个。遇到intern()相关题目结果和预期不符时,首先考虑是否被 JVM 预加载了。
八、做题万能思路
遇到 String 相关的 == 判断题,按以下四步走:
第一步:确认 JDK 版本。 JDK 6 和 7+ 的 intern() 行为不同。
第二步:逐行执行代码。 跟踪两个信息:每个变量指向哪个对象、StringTable 中有哪些条目。
第三步:对每行代码识别类型。
"abc"→ldc指令,池中无则创建入池,有则复用new String("abc")→ldc处理字面量 +new创建新对象,变量指向 new 的那个"a" + "b"(全是常量)→ 编译器直接优化为"ab",等同于字面量new String("a") + new String("b")→StringBuilder拼接,结果不入池s.intern()→ 池中无则登记(JDK 7+ 登记引用 / JDK 6 复制),池中有则返回已有。注意返回值有没有被接住
第四步:判断两个变量是否指向同一个对象。
九、总结
| 知识点 | 要记住的核心结论 |
|---|---|
|
StringTable 存什么 |
存的始终是引用。JDK 6 中引用指向永久代中的 String 对象,JDK 7+ 中指向普通堆上的 String 对象 |
|
字面量何时入池 |
HotSpot 中 |
|
|
两个: |
|
|
前者编译期优化为 |
|
|
不改,必须 |
|
JDK 6 vs 7+ 的区别 |
JDK 6 的 |
|
顺序为什么重要 |
StringTable 先到先得,谁先执行谁占坑,后来的只能复用 |
更多推荐



所有评论(0)