运行时常量池和字符串常量池的关系是什么?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 概念区分能力:面试官不仅仅是想知道你听过 "常量池" 这个词,更是想看你是否能区分 class 文件常量池、运行时常量池、字符串常量池这三者,而不是混为一谈。

  2. JVM 内存模型理解:考察你是否清楚这些常量池分别位于 JVM 内存中的哪个区域,以及 JDK 版本之间的位置变化。

  3. 实际行为理解:是否真正理解 String s = "hello"String s = new String("hello") 底层发生了什么,常量池在这其中扮演了什么角色。

核心答案

先把三者的关系一句话说清楚:

class 文件常量池是静态的(存在 .class 文件里),类加载时变成 运行时常量池(存在方法区中)。运行时常量池里的字符串字面量会被 字符串常量池 引用,字符串常量池是一个全局的、专门给 String 用的哈希表。

常量池类型 存在位置 内容 生命周期
class 文件常量池 .class 文件中 字面量、符号引用(类名、方法名、字段名) 静态,编译时生成
运行时常量池 方法区(JDK 8: 元空间) class 文件常量池的运行时表示 + 动态常量 随类加载而创建,随类卸载而回收
字符串常量池 堆(JDK 7+) 字符串对象的引用 全局唯一,JVM 进程生命周期

深度解析

一、三个常量池的演变链路

先把整个链路搞清楚,从 Java 源码到 JVM 运行,常量池经历了什么:

上图展示了常量池从编译到运行的三阶段演变。逐个阶段说:

  • 阶段一javac 编译后,.class 文件里就有一张常量池表(Constant Pool Table),里面存着所有字面量(字符串、数字)和符号引用(类名、方法名、字段名的全限定名)。这些是死的,存在磁盘上的。

  • 阶段二:JVM 加载类的时候,会把 class 文件常量池搬到内存中,变成 运行时常量池。同时,符号引用会被解析成直接引用(真正的内存地址)。每个类都有一个自己的运行时常量池。

  • 阶段三:运行时常量池中的字符串字面量,会被自动 "驻留"(intern)到 字符串常量池 中。字符串常量池是一个全局的哈希表(StringTable),所有线程共享。

二、运行时常量池 vs 字符串常量池——到底什么关系?

这是这道题的核心。两者的关系可以从三个维度来理解:

维度一:包含关系

运行时常量池的内容比字符串常量池 宽泛得多。运行时常量池里不光有字符串,还有数字常量、类引用、方法引用、字段引用等。字符串常量池只管 String 对象。

维度二:关联方式

运行时常量池中的字符串字面量(比如代码里写的 "hello"),在类加载时会查找字符串常量池:

  • 如果字符串常量池里已经有了 "hello" 的引用 → 运行时常量池直接指向它
  • 如果没有 → 在堆里创建一个 String 对象,把引用放入字符串常量池,运行时常量池再指向它

维度三:内存位置的变化

这个一定要记清楚,JDK 版本之间有变化:

JDK 7 为什么要挪?因为永久代(PermGen)的空间是有限的,默认只有 82MB,而字符串常量池的使用量通常很大,很容易撑爆永久代导致 java.lang.OutOfMemoryError: PermGen space。挪到堆里之后,堆的空间通常大得多(几个 GB),而且 GC 管理更灵活。

三、用代码理解常量池的行为

光说理论不够,来看几个经典例子:

public class StringPoolDemo {
    public static void main(String[] args) {

        // 情况 1:字面量创建(自动驻留到字符串常量池)
        String s1 = "hello";
        String s2 = "hello";
        System.out.println(s1 == s2);  // true
        // 编译期 "hello" 进入 class 文件常量池
        // 类加载时,运行时常量池引用字符串常量池中的同一个 String 对象
        // s1 和 s2 指向同一个对象

        // 情况 2:new 创建(不自动驻留)
        String s3 = new String("hello");
        System.out.println(s1 == s3);  // false
        // new String() 会在堆上创建一个新对象
        // 但 "hello" 这个字面量已经在字符串常量池中了
        // 所以 s3 指向堆中的新对象,s1 指向常量池引用的对象

        // 情况 3:intern() 手动驻留
        String s4 = s3.intern();
        System.out.println(s1 == s4);  // true
        // intern() 会检查字符串常量池
        // 如果已经存在 "hello" 的引用,直接返回该引用
        // 所以 s4 和 s1 指向同一个对象
    }
}

再来看一个更绕的:

String s1 = new String("he") + new String("llo");
// 这个过程发生了什么?
// 1. "he" 和 "llo" 是字面量,类加载时进入字符串常量池
// 2. new String("he") 在堆上创建一个对象
// 3. new String("llo") 在堆上再创建一个对象
// 4. + 号拼接底层用 StringBuilder,最终在堆上生成 "hello" 对象
// 5. 注意:"hello" 这个拼接结果不在字符串常量池中!

s1.intern();
// 把 "hello" 放入字符串常量池(JDK 7+ 放的是堆中对象的引用)

String s2 = "hello";
// 现在 "hello" 已经在常量池中了,s2 指向的是 s1.intern() 放入的那个引用

System.out.println(s1 == s2);  // JDK 7+: true
                               // JDK 6:   false(因为 intern 会复制对象到永久代)

这个例子能拿到 true 还是 false,取决于 JDK 版本。JDK 6 的 intern() 会在永久代中 复制一份 String 对象,所以地址不同。JDK 7+ 的 intern() 只是在字符串常量池中 记录堆中对象的引用,不复制,所以地址相同。这个区别非常关键,面试中经常考。

四、常见误区

有几个地方特别容易搞错:

误区 1:字符串常量池里存的是 String 对象本身

错。JDK 7+ 字符串常量池(StringTable)里存的是 引用(指向堆中的 String 对象),不是对象本身。JDK 6 确实是存对象(在永久代中),但 JDK 7 改了。

误区 2:运行时常量池和字符串常量池是同一个东西

不是。运行时常量池是每个类一份,跟随类加载,内容更广泛(各种常量);字符串常量池是全局唯一的 StringTable,只管字符串。

误区 3:String.intern() 每次都创建新对象

不是。intern() 先查 StringTable,如果已经有了就直接返回引用,不会重复创建。只有在常量池中没有时,才会在堆中创建(或记录引用)。

面试高频追问

  1. 追问一:String s = new String("abc") 创建了几个对象?

    最多 2 个

    • 如果 "abc" 还没在字符串常量池中 → 先在堆中创建一个(被常量池引用),再 new 一个 → 2 个
    • 如果 "abc" 已经在字符串常量池中了 → 只 new 一个 → 1 个
  2. 追问二:JDK 7 为什么要将字符串常量池从永久代移到堆中?

    永久代空间有限(默认 82MB),而字符串常量使用量大,容易导致 PermGen OOM。移到堆中后,堆空间更大且 GC 管理更灵活,降低了 OOM 的风险。同时,JDK 8 彻底移除了永久代,改用元空间(Metaspace),使用本地内存,空间更充裕。

  3. 追问三:intern() 方法在生产中有什么用?

    主要用于 字符串去重,减少内存占用。比如你从数据库或网络读取了大量重复字符串,用 intern() 可以让它们都指向同一个引用。但要注意:过度使用 intern() 会导致字符串常量池膨胀(尤其 JDK 6,常量池在永久代),反而引发 OOM。生产中一般用 Set 或自定义的字符串池来控制。

常见面试变体

  • "String s = new String("abc") 创建了几个对象?"
  • "JDK 6 和 JDK 7 的字符串常量池有什么区别?"
  • "String.intern() 方法的作用是什么?"
  • "什么是字符串驻留(String interning)?"

记忆口诀

三池演变:class 常量池(死的,在文件里)→ 运行时常量池(活的,在方法区)→ 字符串常量池(全局 StringTable,JDK 7+ 在堆里)。关键区别:运行时常量池每个类一份,字符串常量池全局唯一。

总结

运行时常量池和字符串常量池的关系,一句话概括:运行时常量池是 class 文件常量池的运行时化身(每个类一份),字符串常量池是 JVM 全局唯一的字符串去重哈希表。运行时常量池中的字符串字面量通过字符串常量池来实现复用。面试时把三者的演变链路讲清楚,再配合 new String()intern() 的代码示例,基本稳了。