运行时常量池和字符串常量池的关系是什么?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 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/
面试考察点
- JVM 运行时数据区域的划分:面试官想确认你是否清楚方法区(元空间)、堆这些内存区域各自存放什么,特别是 JDK 7 前后的变化。
- 类加载机制的理解:是否了解在类加载的解析阶段,常量池中的符号引用是如何被解析为直接引用的,尤其是字符串字面量的处理过程。
- 字符串常量池的特性:是否知道字符串常量池的作用(缓存字符串对象、避免重复创建),以及
intern()方法的底层逻辑。 - 概念辨析能力:能否准确区分运行时常量池和字符串常量池这两个容易混淆的概念,并说清它们之间的引用关系,而不是简单地说 “字符串常量池是运行时常量池的一部分”。
核心答案
运行时常量池(Runtime Constant Pool)是方法区(JDK 8 以后是元空间)的一部分,每个类或接口在加载后都会生成一个对应的运行时常量池,里面存放了编译期生成的各种字面量和符号引用。而字符串常量池(String Constant Pool)是一个全局的、专门用于缓存字符串对象的区域,从 JDK 7 开始它被移到了 Java 堆中。
它们的关系可以这样理解:当 JVM 加载一个类时,会把 class 文件中的常量池(静态常量池)解析到方法区,形成运行时常量池。对于其中的字符串字面量,JVM 会在字符串常量池中查找或创建对应的字符串对象,然后在运行时常量池中存储一个指向该对象的引用。所以,字符串常量池是运行时常量池中字符串字面量常量的实际存储归宿。
深度解析
原理/机制
-
运行时常量池:每个类或接口对应一个。它包含了类文件常量池中的各种信息,比如:
- 字面量:文本字符串、final 常量值等。
- 符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
在类加载的**解析(Resolution)**阶段,这些符号引用会被替换为直接引用(比如内存地址)。运行时常量池具有动态性,除了预定义的常量,也可以在运行时通过某些方法(如
String.intern())将新的常量放入池中。
-
字符串常量池:它是一个全局的哈希表(实现细节可能因 JVM 版本而异),用于存储字符串对象的引用(或对象本身)。当代码中出现字符串字面量(例如
String s = "abc";)时,JVM 会按照以下步骤处理:- 检查字符串常量池中是否已经有一个内容为
"abc"的字符串对象。 - 如果池中存在,则直接返回该对象的引用。
- 如果池中不存在,则在堆中创建一个新的字符串对象(内容为
"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()的那个对象)的引用加入池中,并返回该引用(此时调用对象本身就成了池中对象,引用相同)。
- 正解:如果池中已有相同内容的字符串,返回池中引用;如果没有,它会将当前对象(调用
总结
运行时常量池是每个类的 “常量清单”,存放于方法区;字符串常量池是全局的 “字符串仓库”,位于堆中。二者通过类加载时对字符串字面量的解析过程产生关联:运行时常量池中的字符串条目最终指向字符串常量池中的实际对象。