深入解析 Java String.intern():从内存模型到实战优化

Java 中 String.intern() 方法的作用可以用一句话概括:将字符串对象加入到字符串常量池中,并返回该字符串在常量池中的引用。

为了真正理解它是干嘛用的,需要结合 字符串常量池 的机制来看。


Q1: java字符串的intern()是干嘛用的?

1. 核心机制:它是怎么工作的?

在 JVM 中,有一个专门的内存区域叫"字符串常量池"。

当你调用一个字符串的 intern() 方法时,JVM 会执行以下操作:

  1. 去池子里找:检查常量池中是否已经存在内容相同的字符串。
  2. 如果存在:直接返回常量池中那个已存在字符串的引用。
  3. 如果不存在:把当前字符串的内容复制(或记录引用,取决于JVM版本)到常量池中,然后返回常量池里的这个新引用。

2. 代码示例:直观感受

java 复制代码
public class InternDemo {
    public static void main(String[] args) {
        // 场景1:字面量创建,直接在常量池中
        String s1 = "hello"; 

        // 场景2:new 关键字创建,在堆内存中开辟了一个新对象
        // 注意:此时常量池里其实也有 "hello",因为 s1 已经把它放进去了
        String s2 = new String("hello"); 

        // 场景3:调用 intern()
        String s3 = s2.intern(); 

        // 比较地址(引用)
        System.out.println(s1 == s2);  // false (一个是常量池,一个是堆)
        System.out.println(s1 == s3);  // true  (s3 指向了常量池中已有的 "hello")
        System.out.println(s2 == s3);  // false (堆地址 != 常量池地址)
    }
}

3. 什么时候需要用到 intern()?(应用场景)

在日常业务代码中,我们很少主动去调 intern(),因为直接用双引号 "" 声明的字符串本来就在常量池里。但在以下两种场景,intern() 非常有用:

场景一:极大节省内存(应对海量重复字符串)

假设你从数据库或日志文件中读取了 1000 万条用户数据,其中有一个字段是"省份"。全国只有 34 个省级行政区,但如果不加处理,你会在堆内存中创建几百万个内容为"广东省"、"北京市"的 String 对象,极其浪费内存。

用法

java 复制代码
String province = rs.getString("province");
// 如果不 intern,每次都会在堆里 new 新对象
// 调用 intern 后,这 1000 万个引用最终都会指向常量池里的那 34 个字符串
province = province.intern(); 

场景二:提升字符串比较速度(用 == 替代 equals()

== 比较的是内存地址,速度极快;而 equals() 需要逐个字符比较,相对较慢。

如果确认两个字符串都经过了 intern() 处理,那么直接用 == 判断是否相等即可。

(注:现代 JVM 对 equals() 的优化已经很好了,为了代码可读性,通常不推荐仅仅为了用 == 而去调 intern(),但这个理论知识点面试常考)


4. 面试必知的"坑"与历史演变(重要)

关于 intern(),JDK 1.6 和 JDK 1.7+ 有一个巨大的分水岭:

  • JDK 1.6 及之前 :字符串常量池放在永久代 (方法区的一部分),和堆内存是物理隔离的。
    • 调用 intern() 时,如果池子里没有,会把字符串对象本身复制一份扔进常量池。
    • :永久代空间很小,如果动态生成大量不同的字符串并 intern(),极其容易导致 OutOfMemoryError: PermGen space
  • JDK 1.7 及之后 :字符串常量池被移到了堆内存 中。
    • 调用 intern() 时,如果池子里没有,不再复制对象 ,而是在常量池里记录一个指向堆中该字符串对象的引用
    • 好处:大大减少了内存拷贝,且受堆内存的垃圾回收(GC)管理,不容易 OOM 了。

5. 总结建议

  • 是什么:返回字符串在常量池中的引用。
  • 怎么用str.intern()
  • 该不该用
    • 平时写代码,不要滥用 (如 new String("abc").intern() 是纯纯的脱裤子放屁,因为 "abc" 本来就在池里)。
    • 适合用 的场景:在读取外部数据(数据库、网络、文件)时,如果明确知道会有海量且高度重复 的字符串,使用 intern() 可以把内存占用从 GB 级别降到 MB 级别。

Q2: 如果是静态代码写的时候,能写在代码里的字符串肯定都是在字符串常量池存在了的吧?

绝大多数情况下你的直觉是对的,但并非绝对。 关键在于你"怎么写"这个字符串。

我们可以把写在代码里的字符串分为以下几种情况来盘点:

1. 纯字面量声明(100% 在常量池)

如果你直接用双引号写一个字符串,它在编译期就会被确定,并直接放入字节码文件的常量池中,类加载时直接进入字符串常量池。

java 复制代码
String s1 = "hello"; // 绝对在常量池里
String s2 = "你好世界"; // 绝对在常量池里

2. "编译期常量"的拼接(100% 在常量池)

如果是由多个纯字面量,或者加了 final 的基本类型/字符串拼接而成,Java 编译器(javac)非常聪明,它会在编译阶段直接帮你把结果算出来,当成一个纯字面量处理。

java 复制代码
String s3 = "hel" + "lo"; // 编译器直接优化成 "hello",在常量池
final String prefix = "hel";
String s4 = prefix + "lo"; // 因为 prefix 是 final,编译器也会优化成 "hello",在常量池
final int num = 1;
String s5 = "数字" + num; // 优化成 "数字1",在常量池

3. 变量的拼接(不在常量池!------ 这是最大的陷阱)

如果参与拼接的变量没有 final 修饰 ,编译器就无法在编译期确定它的值。这种情况只能在运行期 通过 StringBuilder(或 JDK 9+ 的底层机制)动态拼接,结果会创建在堆内存中,而不会自动进入字符串常量池!

java 复制代码
String a = "hel";       // a 在常量池
String b = "lo";        // b 在常量池
String c = a + "lo";    // 【注意】c 是在堆内存中新建的对象,不在常量池!

// 证明:
System.out.println(c == "hello"); // 输出 false!

这时候就需要 intern() 了:

如果你想强行让 c 指向常量池,你就必须手动调用:

java 复制代码
String d = c.intern(); // 现在 d 指向常量池了
System.out.println(d == "hello"); // 输出 true

4. new String() 创建(不在常量池,但在堆里)

java 复制代码
String e = new String("hello"); 

这行代码实际上创建了两个东西:

  1. "hello" 这个字面量本身(在常量池)。
  2. new 出来的那个 String 对象(在堆内存)。
    变量 e 指向的是堆内存里的对象,而不是常量池里的对象。
java 复制代码
System.out.println(e == "hello"); // 输出 false

总结

你说的"写在代码里的字符串都在常量池",更准确的说法应该是:写在代码里的"字符串字面量"和"编译期可确定的常量表达式"肯定在常量池里。

但是,只要你的代码里包含了变量的动态拼接 或者 new** 关键字**,哪怕你把变量声明死在代码里(比如 String a = "a"; String b = a + "b";),它生成的结果也是在堆内存中,并不在常量池中。

所以,如果你在静态代码里通过变量拼接得到了一个字符串,又想用它去和别的字面量做 == 比较,或者想复用它的内存,这时候对它调一下 intern() 就是有意义的。

Q3: 为什么说:如 new String("abc").intern() 是纯纯的脱裤子放屁

new String("abc").intern() 是"脱裤子放屁"(纯纯的多此一举),是因为这行代码在底层白白在堆内存中创建了一个立刻就会被丢弃的垃圾对象

我们可以把这句代码拆解成三个阶段,看看 JVM 到底做了什么多余的事:

拆解执行过程:

第一阶段:类加载时(处理 "abc"

当你把代码写进 .java 文件并编译后,"abc" 作为一个字面量,在类加载的时候,就已经被放进字符串常量池 了。此时,池子里已经有了 "abc"

第二阶段:执行 new String("abc")(多余的操作开始了)

当程序运行到这一行时,JVM 会在堆内存 中强行开辟一块新的空间,创建一个新的 String 对象。这个对象里面包含的字符数据和常量池里的 "abc" 一模一样。

此时,内存里有两个 "abc":一个在常量池,一个在堆里。

第三阶段:执行 .intern()(把刚才的努力全废了)

intern() 方法会去常量池里找:"喂,池子里有没有 abc 啊?"

池子:"有啊,第一阶段就放进来了。"

intern():"好嘞,那我就直接返回池子里那个 abc 的地址吧。"

最终结果:

这行代码执行完,你拿到的是常量池里 那个 "abc" 的引用。

而第二步在堆内存 里辛辛苦苦 new 出来的那个 String 对象,因为没有任何引用指向它,瞬间变成了孤儿,只能等着被垃圾回收器(GC)清理掉。


形象的比喻:

这就好比:

  1. 公司档案室(常量池)本来就已经有一份原件《员工手册》。
  2. 你(new)花钱找复印机重新复印了一份,拿在手里。
  3. 然后你调用了 intern(),意思是:去档案室找找有没有这本手册?有啊,那把我手里这份复印件扔进垃圾桶,我还是用档案室的原件吧。

你花钱复印了,最后又扔了,这不就是脱裤子放屁吗?

正确的做法:

如果你本来就想要常量池里的 "abc",直接写:

java 复制代码
String s = "abc";

这就完了!一步到位,不创建任何多余的堆对象。


⚠️ 补充纠正:什么时候 new String(...).intern() 是有意义的?

必须强调的是,我上面骂它"多此一举",前提是括号里传的是一个"字面量"(比如 "abc"

如果括号里传的是运行时动态计算出来的变量 ,那 new String(...).intern() 就非常有意义了!

有意义的场景举例:

java 复制代码
// 从网络或文件读取了一堆字符
char[] chars = {'a', 'b', 'c'}; 

// 你必须用 new String 把字符数组转成字符串对象(此时在堆内存中)
String dynamicStr = new String(chars); 

// 此时调 intern() 就不叫脱裤子放屁了!
// 因为 "abc" 可能之前不在常量池里,这一步把它成功存入常量池,供全项目复用
String pooledStr = dynamicStr.intern(); 

总结:

  • new String("字面量").intern() = 纯纯的脱裤子放屁。
  • new String(动态变量/数组).intern() = 合理的优化手段。

Q4: 上面给的有意义场景举例中,String pooledStr = dynamicStr.intern(); 后,存入常量池为什么就是优化手段了?我不能继续引用dynamicStr吗?

这是一个非常敏锐的问题!

你当然可以继续使用 dynamicStr,它里面确实有 "abc" 这个数据,你拿它做任何业务逻辑(比如打印、截取、当参数传递)完全没问题,代码不会报错。

但为什么说 intern() 是优化手段?核心在于:这个优化的目的,不是为了方便你"用"这个字符串,而是为了"省内存"和"减轻垃圾回收(GC)的压力"。

为了让你彻底明白,我们来看一个高并发/大量重复的场景模拟。

场景模拟:解析 1000 万条日志

假设你写了一个方法,用来解析外部传来的字符数组,并提取出状态码。这个方法会被调用 1000 万次,且状态码高度重复(大部分都是 "abc")。

路径一:不使用 intern() (只用 dynamicStr

java 复制代码
public void parseLog(char[] chars) {
    // 每次调用,都会在堆内存中 new 一个全新的 String 对象
    String dynamicStr = new String(chars); 
    
    // 假设你把这个字符串存到了一个全局的 List 里
    globalList.add(dynamicStr); 
}

发生了什么?

这行代码执行 1000 万次,JVM 就会在堆内存里老老实实地创建 1000 万个内容都是 "abc" 的独立对象

即使它们内容一样,但在 JVM 眼里它们是 1000 万个不同的个体。

  • 后果 1: 内存爆炸。1000 万个 String 对象及其内部char数组,会吃掉大量堆内存。
  • 后果 2: GC 疯狂。当内存满了,垃圾回收器(GC)不得不疯狂工作去清理这些废对象,导致你的程序卡顿(STW)。

路径二:使用 intern()

java 复制代码
public void parseLog(char[] chars) {
    String dynamicStr = new String(chars); 
    
    // 核心优化:把引用指向常量池
    dynamicStr = dynamicStr.intern(); 
    
    // 存入全局 List
    globalList.add(dynamicStr); 
}

发生了什么?

  1. 第 1 次调用: 堆里 new 了一个 "abc"intern() 发现池子里没有,就把堆里这个对象的引用记录到池子里,并返回。此时 List 里存的是池子的引用。
  2. 第 2 次调用: 堆里 new 了一个 "abc"intern() 一看,池子里已经有了!于是直接抛弃刚 new 的那个堆对象,返回池子里那个引用。刚 new 的对象瞬间变成垃圾。
  3. 第 1000 万次调用: 依然如此。

最终结果:

你的 globalList 里确实存了 1000 万个元素,但这 1000 万个元素指向的是同一个内存地址(常量池里的那个 "abc"

中间那些被 new 出来又被抛弃的堆对象,因为没有任何人引用它们,会被极快地被轻量级 GC 清理掉。

  • 收益: 全局只需要维护 1 份 "abc" 的真实数据,内存占用断崖式下降。

回到你的疑问:"我不能继续引用 dynamicStr 吗?"

你可以引用它,但你要明白你引用的是谁

  • 如果你写 String dynamicStr = new String(chars);,你手里拿的是刚刚从复印机里出来的一份复印件
  • 如果你写 dynamicStr = dynamicStr.intern();,你手里的复印件被扔了,换成了档案室原件的借阅证

如果你只是在当前方法里用一下 dynamicStr,用完就扔了(局部变量),那确实没必要 intern(),因为方法结束它就被 GC 回收了,不占长期内存。

但只要这个字符串有以下两种情况之一, intern()** 就有巨大意义:**

  1. 被长期存活的对象引用 (比如存进了全局的 ListMapSet,或者存入了数据库缓存)。
  2. 被极高频率地创建(比如在一个千万级的 for 循环里)。

一句话总结

intern() 不是为了改变字符串里的内容,而是为了让成千上万个本该各自占内存的重复字符串,合并成对同一个内存地址的引用。这就叫空间优化。