Java Interned String 机制详解:字符串常量池、intern() 与 ==/equals 比较

原创2026-10-01 17:11:511,392 阅读
文章标签:教程知识库

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) 的内部逻辑:

  1. 首先检查传入的实参是否为 null;
  2. 如果非 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)来统一管理字符串。

整个流程可以概括为:

  1. 字面量声明字符串时,JVM 先去常量池中查找内容相同的字符串;
  2. 若常量池中已存在该字符串,直接返回已有的那个对象,不创建新对象;
  3. 若常量池中不存在,则新建对象,将其注册进常量池,再返回。

正是"先查池、命中即复用"这个流程,保证了同一 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() 的行为与字面量的驻留流程完全一致:

  1. 若常量池中已存在与当前字符串内容相等的字符串,直接返回池中对象;
  2. 若不存在,则把当前字符串加入常量池并返回(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 手动驻留

三条底层规则,记住它们即可覆盖全部场景:

  1. == 比身份(引用地址),equals() 比内容(值);字符串比较永远用 equals();
  2. 字面量与 String.valueOf()(透传字面量)走常量池,同值即同对象;
  3. 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 字符串驻留机制的四个关键事实:

  1. 不可变对象的方法调用也可能返回自身(如 toUpperCase() 在无需转换时返回 this);
  2. new String() 与字面量声明是不同的对象,前者在堆上新建,后者在常量池中复用;
  3. String.valueOf() 通过"非 null → toString() → 返回 this"透传驻留对象;
  4. 常量池"先查、命中复用、未命中注册"的流程,配合 String.intern() 可以手动控制驻留,但需警惕池膨胀风险。

掌握 == 与 equals() 的本质区别、字符串常量池的内存位置,以及 intern() 的适用边界,既是面试的得分点,也是写出高效、无隐蔽 Bug 的字符串代码的基础功。

登录后查看全文
tech-interview-for-developer