很多 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") 这一行实际上干了两件事:

  1. 先执行 ldc "abc":在堆上创建对象 A,登记进 StringTable
  2. 再执行 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);

逐行分析:

  1. s1 = new String("b") + new String("c")

    • new String("b") 触发 ldc "b""b" 进入 StringTable
    • new String("c") 触发 ldc "c""c" 进入 StringTable
    • 拼接生成内容为 "bc" 的新堆对象,赋给 s1
    • 此时 StringTable 中没有 "bc"
  2. s1.intern()

    • 查 StringTable,没有 "bc"
    • 把 s1 的引用登记进 StringTable
    • 现在池中的 "bc" 就是 s1 这个对象
  3. 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);

逐行分析:

  1. s1 = new String("b") + new String("c")

    • 同上,s1 是堆上的拼接对象,池中没有 "bc"
  2. String s2 = "bc"

    • ldc "bc" 查 StringTable,没有
    • 创建一个新的 String 对象登记进池,s2 指向这个新对象
    • s2 先占了坑
  3. s1.intern()

    • 查 StringTable,发现已有 "bc"s2 那个)
    • 返回 s2 的引用,但返回值被丢弃
    • s1 仍然指向拼接出来的那个对象

结果: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

false

intern() 在永久代复制了一份新对象入池,池中对象不是 s1,后面 s2 拿到的是那份副本

JDK 7+

true

intern() 把 s1 的引用直接登记进池,后面 s2 拿到的就是 s1

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.Versionjava.lang.VersionProps 等),这些类的代码中包含 "java" 这个字面量。当这些类被加载并执行到相关 ldc 指令时,"java" 就已经进入 StringTable 了——这一切发生在你的 main 方法执行之前。

所以真正的执行过程是:

  1. JVM 启动阶段:核心类加载,"java" 已经在 StringTable 中(指向一个你看不到的对象 X)

  2. s1 = new String("ja") + new String("va")

    • 拼接生成堆上的 "java" 对象,赋给 s1
  3. s1.intern()

    • 查 StringTable,发现 "java" 已经存在(对象 X)
    • 返回对象 X 的引用,但返回值被丢弃
    • s1 没能占到坑
  4. String s2 = "java"

    • ldc "java" 从 StringTable 拿到对象 X,s2 指向对象 X
  5. 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 中 ldc 指令首次执行时才创建对象并入池(懒加载)

new String("a") 几个对象

两个:ldc 创建的池中对象 + new 创建的新对象

"a" + "b" vs new String("a") + new String("b")

前者编译期优化为 "ab" 字面量,会入池;后者走 StringBuilder,结果不入池

intern() 改不改原变量

不改,必须 s = s.intern() 才能让 s 指向池中对象

JDK 6 vs 7+ 的区别

JDK 6 的 intern() 复制新对象入池;JDK 7+ 登记引用入池,不复制

顺序为什么重要

StringTable 先到先得,谁先执行谁占坑,后来的只能复用

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐