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

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

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

  • 新开坑项目: 《Spring AI 项目实战(问答机器人、RAG 增强检索、联网搜索)》 正在持续爆肝中,基于 Spring AI + Spring Boot3.x + JDK 21...点击查看;
  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot3.x + JDK 17...点击查看项目介绍; 演示链接: http://116.62.199.48:7070/;
  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接: http://116.62.199.48/

面试考察点

  1. JVM 运行时数据区域的划分:面试官想确认你是否清楚方法区(元空间)、堆这些内存区域各自存放什么,特别是 JDK 7 前后的变化。
  2. 类加载机制的理解:是否了解在类加载的解析阶段,常量池中的符号引用是如何被解析为直接引用的,尤其是字符串字面量的处理过程。
  3. 字符串常量池的特性:是否知道字符串常量池的作用(缓存字符串对象、避免重复创建),以及 intern() 方法的底层逻辑。
  4. 概念辨析能力:能否准确区分运行时常量池和字符串常量池这两个容易混淆的概念,并说清它们之间的引用关系,而不是简单地说 “字符串常量池是运行时常量池的一部分”。

核心答案

运行时常量池(Runtime Constant Pool)是方法区(JDK 8 以后是元空间)的一部分,每个类或接口在加载后都会生成一个对应的运行时常量池,里面存放了编译期生成的各种字面量和符号引用。而字符串常量池(String Constant Pool)是一个全局的、专门用于缓存字符串对象的区域,从 JDK 7 开始它被移到了 Java 堆中。

它们的关系可以这样理解:当 JVM 加载一个类时,会把 class 文件中的常量池(静态常量池)解析到方法区,形成运行时常量池。对于其中的字符串字面量,JVM 会在字符串常量池中查找或创建对应的字符串对象,然后在运行时常量池中存储一个指向该对象的引用。所以,字符串常量池是运行时常量池中字符串字面量常量的实际存储归宿

深度解析

原理/机制

  • 运行时常量池:每个类或接口对应一个。它包含了类文件常量池中的各种信息,比如:

    • 字面量:文本字符串、final 常量值等。
    • 符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。 在类加载的**解析(Resolution)**阶段,这些符号引用会被替换为直接引用(比如内存地址)。运行时常量池具有动态性,除了预定义的常量,也可以在运行时通过某些方法(如 String.intern())将新的常量放入池中。
  • 字符串常量池:它是一个全局的哈希表(实现细节可能因 JVM 版本而异),用于存储字符串对象的引用(或对象本身)。当代码中出现字符串字面量(例如 String s = "abc";)时,JVM 会按照以下步骤处理:

    1. 检查字符串常量池中是否已经有一个内容为 "abc" 的字符串对象。
    2. 如果池中存在,则直接返回该对象的引用。
    3. 如果池中不存在,则在堆中创建一个新的字符串对象(内容为 "abc"),并将其引用存入字符串常量池,然后返回这个引用。 这个机制保证了相同字面量的字符串在池中只有一份,从而节省内存。

关系梳理(重点)

当类加载器加载一个包含字符串字面量的类时,JVM 会解析 class 文件中的 CONSTANT_String_info 结构。这个过程大致如下:

  • 首先,在字符串常量池中查找该字符串字面量对应的对象。
  • 找到或创建成功后,JVM 会把这个字符串对象的引用,填入当前类的运行时常量池中,作为该字面量的解析结果。
  • 因此,运行时常量池里存放的并不是字符串对象本身,而是一个指向字符串常量池中实际对象的指针(或者说引用)。

可以用一个比喻来帮助理解:运行时常量池是每个类持有的 “货物清单”,清单上记录着 “货物名称”(字符串字面量);而字符串常量池则是存放实际货物的 “大仓库”。清单上的条目最终都指向仓库里具体的货物。

代码示例

public class StringPoolDemo {
    public static void main(String[] args) {
        // s1 和 s2 都是字符串字面量,会在类加载时被处理
        String s1 = "hello";
        String s2 = "hello";
        // 比较引用地址,因为指向字符串常量池中同一个对象,所以返回 true
        System.out.println(s1 == s2); // true

        // 通过 new 创建字符串,会在堆中(非池中)创建一个新对象
        String s3 = new String("hello");
        // s1 指向池中对象,s3 指向堆中新对象,地址不同
        System.out.println(s1 == s3); // false

        // intern() 方法:尝试将 s3 放入字符串常量池
        // 如果池中已有相同内容的字符串(s1 指向的那个),则返回池中对象的引用
        String s4 = s3.intern();
        System.out.println(s1 == s4); // true,s4 拿到了池中引用
    }
}

对比分析

维度运行时常量池字符串常量池
存储位置方法区(JDK 8+ 为元空间)Java 堆(JDK 7+)
生命周期随类的卸载而销毁全局共享,与 JVM 生命周期大致相同(其中的对象可被 GC 如果不再有引用)
存储内容各种类型的常量:数字字面量、字符串引用、类/方法/字段的符号引用等仅存储字符串对象(或引用)
动态性类加载时确定,但可通过动态代理等机制增加可通过 intern() 方法动态加入新的字符串对象

最佳实践

  • 利用字面量自动复用:直接使用字符串字面量(String s = "xxx";)即可享受常量池的复用好处,无需额外操作。
  • 谨慎使用 intern():如果需要大量重复的、动态生成的字符串(例如从数据库或文件中读取的重复数据),手动 intern() 可以显著减少内存占用。但要注意,intern() 操作本身有查找和可能创建对象的开销;如果字符串内容本身重复率不高,反而会增加池的负担,甚至因为池使用哈希表而影响性能。
  • 注意版本差异:在 JDK 6 及以前,字符串常量池位于永久代(方法区),永久代空间有限,滥用 intern() 容易导致 OutOfMemoryError: PermGen space。JDK 7+ 将池移到堆中,情况有所缓解,但池中的字符串对象仍然会占用堆内存,且不会被 GC 如果一直被强引用。

常见误区

  • 误区一:把字符串常量池当成运行时常量池的一部分。
    • 正解:它们是不同的内存区域,只是通过引用建立联系。运行时常量池在元空间,字符串常量池在堆。
  • 误区二:认为字符串字面量直接存在运行时常量池里。
    • 正解:运行时常量池里只存引用,真正的字符串对象在堆中的字符串常量池里。
  • 误区三:对 new String("abc") 创建的对象数量理解有误。
    • 正解:如果常量池中还没有 "abc",类加载时会先在池中创建一个对象;执行 new String("abc") 时,会在堆中(非池)再创建一个对象,总共两个。如果池中已有,则 new 只创建一个堆中对象,加上池中对象,仍然两个。但无论如何,new 本身只负责在堆中创建新对象。
  • 误区四:误以为 intern() 一定返回池中原有对象的引用。
    • 正解:如果池中已有相同内容的字符串,返回池中引用;如果没有,它会将当前对象(调用 intern() 的那个对象)的引用加入池中,并返回该引用(此时调用对象本身就成了池中对象,引用相同)。

总结

运行时常量池是每个类的 “常量清单”,存放于方法区;字符串常量池是全局的 “字符串仓库”,位于堆中。二者通过类加载时对字符串字面量的解析过程产生关联:运行时常量池中的字符串条目最终指向字符串常量池中的实际对象。