Java Interned String 机制详解:字符串常量池、intern() 与 ==/equals 比较
Java Interned String 机制详解:字符串常量池、intern() 与 ==/equals 比较
本篇指南以 tech-interview-for-developer 仓库中 Interned String 文档 为核心,系统梳理 Java 字符串的不可变性、字符串常量池(String Constant Pool)、String.intern() 驻留机制,以及 == 与 equals() 在字符串比较中的本质差异。读完本文,你将能准确回答面试中"两个内容相同的字符串是否一定是同一个对象""new String() 与字面量声明有什么区别""intern() 有什么用"等高频问题,并能在实际开发中正确选择字符串的创建与比较方式。
一、String 的不可变性:方法调用并不总是返回新对象
Java 的 String 是不可变(immutable)对象,这是理解本文全部内容的前提。调用 String 上的方法时,方法并不会直接修改当前对象,而是以返回值的形式给出结果——通常这个结果是另一个新对象。
但"通常"不等于"总是"。请看下面这个例子:
public void func() {
String haribo1st = new String("HARIBO");
String copiedHaribo1st = haribo1st.toUpperCase();
System.out.println(haribo1st == copiedHaribo1st);
}
按照"不可变对象调用方法会返回新对象"的直觉,toUpperCase() 应该返回一个与原对象不同的新对象,== 比较的结果应该是 false。但实际执行结果是 true——两个引用指向同一个对象。
原因藏在 toUpperCase() 的实现逻辑里:该方法会先检查字符串中是否存在小写字母(也就是"是否真的需要转换"),如果没有检测到任何需要转换的小写字符,就直接返回当前对象自身,而不会新造一个字符串。"HARIBO" 已经是全大写,所以 toUpperCase() 原样返回了 this。
由此可以得到一个易被忽略的结论:不可变对象的"返回新对象"不是绝对的。像
toUpperCase()、substring()(部分 JDK 版本对单字符子串)等方法都有"能复用就复用"的优化路径。这也提醒我们,用==判断字符串是否相等从来都不是可靠做法——正确做法始终是equals()。
二、new String() 与字符串字面量:值相同,但对象不同
既然 toUpperCase() 可能复用原对象,那么换个问题:用构造器 new String("HARIBO") 创建的对象,和用字面量 "HARIBO" 声明的对象,是同一个对象吗?
不是。 通过构造器创建字符串时,无论内容是否相同,都会在堆(heap)中新分配一块内存,产生一个全新的对象。
public void func() {
String haribo1st = new String("HARIBO");
String haribo3rd = "HARIBO";
System.out.println(haribo1st == haribo3rd); // false
System.out.println(haribo1st.equals(haribo3rd)); // true
}
两个输出结果合起来说明了最核心的事实:
==比较的是引用(对象身份):haribo1st是堆上新 new 出来的对象,haribo3rd来自常量池,二者是两个不同的对象,所以结果为false;equals()比较的是内容(值):两个字符串的内容都是"HARIBO",所以结果为true。
如果想进一步"眼见为实",可以比较两者的 hashCode()。由于 String 重写了 hashCode(),其取值由字符串内容决定,所以这两个对象的 hashCode() 必然相同——这符合 Java 中"equals() 相等则 hashCode() 相等"的契约;但 hashCode() 相同不代表对象相同,引用层面它们依然是两个实例。
三、String.valueOf() 与 toString():返回自身对象的路径
接下来看第三种创建路径——String.valueOf():
public void func() {
String haribo3rd = "HARIBO";
String haribo4th = String.valueOf("HARIBO");
System.out.println(haribo3rd == haribo4th); // true
System.out.println(haribo3rd.equals(haribo4th)); // true
}
这次 == 的结果变成了 true。要理解这一点,需要看 String.valueOf(Object) 的内部逻辑:
- 首先检查传入的实参是否为
null; - 如果非
null,则调用该实参的toString()并返回其结果。
而 String.toString() 的实现非常特殊——它直接返回 this(当前字符串对象本身)。因此对 String.valueOf("HARIBO") 而言,实参 "HARIBO" 本身就是一个驻留在常量池中的字面量,valueOf 经过"非 null → 调用 toString() → 返回 this"这条链路后,返回的仍然是那个驻留对象,与 haribo3rd 是同一个对象,== 自然为 true。
从这里可以提炼出两条可复用的判断准则:
- 凡是字面量声明(直接写
"...")的字符串,都会走常量池驻留路径;- 凡是能"原样透传"字面量对象的方法(如
String.valueOf()),其结果仍指向驻留对象;- 只有
new String(...)会强制在堆上另建新对象。
四、字符串常量池(String Constant Pool)的运作流程
为什么字面量声明的字符串总是同一个对象?答案在于:JVM 通过常量池(constant pool)来统一管理字符串。
整个流程可以概括为:
- 字面量声明字符串时,JVM 先去常量池中查找内容相同的字符串;
- 若常量池中已存在该字符串,直接返回已有的那个对象,不创建新对象;
- 若常量池中不存在,则新建对象,将其注册进常量池,再返回。
正是"先查池、命中即复用"这个流程,保证了同一 JVM 内所有相同内容的字面量字符串都指向同一个对象。
这一机制与 JVM 的内存布局紧密相关。仓库中的 JVM 运行时数据区说明.md#L68-L86) 指出:JVM 将内存划分为 PC 寄存器、JVM 栈、本地方法栈、堆、方法区五个区域,其中方法区保存"JVM 读取的每个类与接口的运行时常量池、字段与方法代码、静态变量、方法字节码等",而堆则是"运行时动态分配的数据(对象、数组)"的存放区域,也是垃圾回收的主要目标。
对照 面试清单 中对内存区域的整理,可以更清晰地定位字符串驻留的位置:
- 常量池(Constant Pool):保存字符常量、类型、字段、对象引用,属于方法区(JDK 7 及之后字符串常量池实际位于堆中,这一点在面试延伸中常被追问);
- 堆区(Heap Area):
new关键字创建的对象与数组存放于此,由 GC 负责回收。
结合上一节的例子:new String("HARIBO") 走堆区、每次新建;字面量 "HARIBO" 走常量池、全局复用。二者虽然内容相同,但身处不同的内存区域、是不同的对象。
需要注意一个精度问题:原始文档与很多入门资料将"字符串驻留"笼统描述为"常量池管理"。严格来说,JDK 7 之后字符串常量池被移动到了堆中(从永久代迁出),而类文件自身的"运行时常量池"仍在方法区。理解到这个粒度,在面试中回答"字符串常量池到底在哪"时会更从容。
五、String.intern():手动把字符串驻留进常量池
既然字面量会自动驻留,那 new String() 创建的对象能不能也"入池"?能,靠的就是 String.intern() 方法——原始文档在解释完常量池流程后,也正是用 String.intern() 作为印证入口。
intern() 的行为与字面量的驻留流程完全一致:
- 若常量池中已存在与当前字符串内容相等的字符串,直接返回池中对象;
- 若不存在,则把当前字符串加入常量池并返回(JDK 7 及之后返回池中的引用)。
典型用法是配合 new String(),例如:
String a = new String("HARIBO");
String b = "HARIBO";
String c = a.intern();
System.out.println(a == b); // false:new 出来的对象在堆上
System.out.println(b == c); // true:intern() 返回了常量池中的驻留对象
使用 intern() 的场景与注意点:
- 适用场景:在运行时动态拼接、读取(如从文件、网络、数据库)大量重复度极高的字符串时,
intern()可以消除重复对象、显著降低内存占用; - 风险提示:常量池驻留的字符串不会被常规 GC 回收,如果无节制地对海量、低重复度字符串调用
intern(),会造成池的膨胀与内存压力。因此实际工程中应当"按需、限量"使用,而不是对所有字符串一律intern(); - 判断参考:先想清楚"这个字符串在生命周期内是否会大量重复",再决定是否驻留,这是面试官最想听到的工程权衡。
六、==、equals() 与 hashCode 的完整对照
把前面三个例子的结论汇总成一张速查表,方便面试与开发时快速对照:
| 声明方式 | 对象位置 | 与字面量 "HARIBO" 用 == 比较 |
用 equals() 比较 |
说明 |
|---|---|---|---|---|
"HARIBO" 字面量 |
常量池(驻留) | true(同池对象) |
true |
自动驻留,全局复用 |
new String("HARIBO") |
堆(每次新建) | false |
true |
内容相同但对象不同 |
String.valueOf("HARIBO") |
常量池(透传字面量) | true |
true |
toString() 返回 this |
s.intern()(对 new 出的对象调用) |
常量池(驻留后返回池中对象) | true |
true |
手动驻留 |
三条底层规则,记住它们即可覆盖全部场景:
==比身份(引用地址),equals()比内容(值);字符串比较永远用equals();- 字面量与
String.valueOf()(透传字面量)走常量池,同值即同对象; new String(...)强制在堆上新建对象,即使内容相同也是不同实例;intern()是把它拉回常量池的唯一途径。
hashCode() 在其中的角色是"辅助佐证":因为两个 equals() 相等的字符串其 hashCode() 必然相同(String 基于内容计算),所以可以用 hashCode 验证"内容一致",但不能用它证明"是同一个对象"——对象身份只能由 == 或内存地址判断。
七、与 StringBuffer / StringBuilder 的关联与面试延伸
"Interned String"这一主题在面试中很少单独出现,更多是作为字符串体系的一部分被连环追问。仓库中的 String / StringBuffer / StringBuilder 对比文档 给出了完整的横向对比,可作为本主题的自然延伸:
| 维度 | String | StringBuffer | StringBuilder |
|---|---|---|---|
| 可变性 | Immutable(不可变) | Mutable(可变) | Mutable(可变) |
| 同步 | 无需考虑(天然线程安全) | Synchronized,线程安全 | 不同步,非线程安全 |
| 适用场景 | 字符串少改动、多查询 | 多线程下大量字符串拼接 | 单线程下大量字符串拼接 |
把两个主题串起来看,面试官真正想考察的其实是同一套底层认知:
- String 不可变 → 为什么每次拼接都产生新对象 → 为什么大量拼接要用
StringBuilder/StringBuffer; - String 常量池 → 为什么字面量同值同对象 → 为什么
new String()是反模式 → 什么时候才值得intern(); - JVM 内存 → 常量池、堆、方法区分别放什么 → GC 对驻留字符串的"网开一面"。
对应地,仓库 面试清单 中的经典问题"StringBuilder 与 StringBuffer 的区别?"与"Java 的内存区域有哪些?"正是这条追问链的另外两个端点:前者考察不可变/可变与线程安全,后者考察方法区(含常量池)与堆的职责划分。建议将三篇内容(本主题、JVM 内存.md)、字符串三兄弟)作为一组关联复习。
总结
围绕 tech-interview-for-developer 仓库中的 Interned String 文档,本文从三个可运行示例出发,完整还原了 Java 字符串驻留机制的四个关键事实:
- 不可变对象的方法调用也可能返回自身(如
toUpperCase()在无需转换时返回this); new String()与字面量声明是不同的对象,前者在堆上新建,后者在常量池中复用;String.valueOf()通过"非 null →toString()→ 返回this"透传驻留对象;- 常量池"先查、命中复用、未命中注册"的流程,配合
String.intern()可以手动控制驻留,但需警惕池膨胀风险。
掌握 == 与 equals() 的本质区别、字符串常量池的内存位置,以及 intern() 的适用边界,既是面试的得分点,也是写出高效、无隐蔽 Bug 的字符串代码的基础功。