什么是 Class 常量池,和运行时常量池区别是什么?
一则或许对你有用的小广告
欢迎 加入小哈的星球 ,你将获得: 专属的项目实战(已更新的所有项目都能学习) / 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/
面试考察点
面试官抛出这个问题,主要想考察你以下几个方面的掌握程度:
- 对 Java 类文件结构的理解:是否知道
.class文件中除了字节码指令,还包含了一个重要的资源池 —— Class 常量池(静态常量池)。 - 对 JVM 运行时数据区的掌握:是否清楚运行时常量池存在于哪个区域(方法区/元空间),以及它是在什么时机被创建和使用的。
- 对 “静态” 与 “动态” 概念的区分:能否清晰地阐述一个是在编译期生成、存储在文件中的静态信息,另一个是在运行时、存储在内存中的动态数据结构。
- 知识的延伸能力:能否联系到
String.intern()方法、常量池溢出(OutOfMemoryError)等更深入的话题。
核心答案
简单来说:
-
Class 常量池:可以理解为一张存放在
.class文件中的 “资源清单” 或 “符号表”。它是在编译期由 Java 编译器生成的,主要存储了类中定义的字面量(如文本字符串、final 常量值)和符号引用(如类和接口的全限定名、字段名和描述符、方法名和描述符)。 -
运行时常量池:是 JVM 在类加载完成之后,将 Class 常量池 中的内容加载到内存中的方法区(JDK 8 以后是元空间),并转换而来的版本。它不再是静态的符号,而是可以直接使用的运行时内存数据结构,并且在运行期间,新的常量(比如通过
String.intern()方法)也可以被动态地加入其中。
深度解析
为了帮你更好地理解,我们从 “出生” 到 “使用” 的过程来拆解一下。
原理与机制:从静态文件到运行时内存
可以把这个过程想象成拍电影:
-
剧本阶段 (Class 常量池):在电影开拍前(编译后),我们有一个剧本(
.class文件)。剧本里有一个 “道具清单”(Class 常量池),上面写着 “需要一个名为 ‘主角’ 的演员”,“需要一个写有 ‘Hello World’ 的道具牌”。这里的‘主角’就是一个符号引用,‘Hello World’ 就是一个字面量。它只是一个约定,还没有落实到具体的对象上。 -
拍摄阶段 (运行时常量池):当电影正式开拍(类加载)时,制片人(JVM)拿着剧本,开始把 “道具清单” 上的东西变成现实。它找到名叫 “主角” 的演员(解析符号引用为直接引用),制作了 “Hello World” 的道具牌(在内存中创建对象)。这个 “现实版的动态道具清单” 就存放在一个专门的地方 —— 运行时常量池。在拍摄过程中,如果临时需要加一个新道具,比如
String.intern()进来的字符串,也可以动态地加到这个清单里。
核心区别总结如下:
| 特性 | Class 常量池 | 运行时常量池 |
|---|---|---|
| 存在位置 | .class 文件中(静态存储) | JVM 方法区/元空间中(运行时内存) |
| 产生时间 | 编译期(javac) | 类加载后(JVM 运行期) |
| 内容性质 | 符号引用、字面量(静态约定) | 解析后的直接引用、运行时对象(动态可执行) |
| 内容可变性 | 不可变(文件不可变) | 动态可变(可以通过程序添加新常量) |
| 作用范围 | 仅属于该类文件 | 对应 JVM 中的一个类,但可以被该类的所有实例共享 |
代码示例与可视化
我们可以用 javap 命令来直观地看到 Class 常量池。写一个简单的类:
public class ConstantPoolDemo {
private final String str = "Hello";
private final int num = 10;
public void sayHello() {
System.out.println(str);
}
}
编译后,在命令行执行 javap -v ConstantPoolDemo.class,你会看到类似这样的输出(截取部分):
Constant pool:
#1 = Methodref #6.#20 // java/lang/Object."<init>":()V
#2 = String #21 // Hello <-- 字面量 "Hello"
#3 = Fieldref #5.#22 // ConstantPoolDemo.str:Ljava/lang/String;
#4 = Fieldref #5.#23 // ConstantPoolDemo.num:I
#5 = Class #24 // ConstantPoolDemo
#6 = Class #25 // java/lang/Object
#7 = Utf8 str
#8 = Utf8 Ljava/lang/String;
#9 = Utf8 num
#10 = Utf8 I
#11 = Utf8 ConstantValue
#12 = Integer 10 // <-- 字面量 10
#13 = Utf8 sayHello
#14 = Utf8 ()V
#15 = Utf8 Code
#16 = Utf8 LineNumberTable
#17 = Utf8 SourceFile
#18 = Utf8 ConstantPoolDemo.java
#19 = Utf8 <init>
#20 = NameAndType #19:#14 // "<init>":()V
#21 = Utf8 Hello // <-- Utf8 信息,被 #2 引用
#22 = NameAndType #7:#8 // str:Ljava/lang/String;
#23 = NameAndType #9:#10 // num:I
#24 = Utf8 ConstantPoolDemo
#25 = Utf8 java/lang/Object
这个 Constant pool 部分就是 Class 常量池。可以看到,里面的 #21 是一个 Utf8 类型的 "Hello",而 #2 是一个 String 类型的常量,它指向了 #21。这些在运行时都会被加载到方法区的运行时常量池中。
而 String.intern() 方法就是一个动态添加常量的典型例子:
String s1 = new String("hello") + new String("world");
s1.intern(); // 尝试将 "helloworld" 这个字符串对象放入运行时常量池
String s2 = "helloworld"; // 直接从运行时常量池取
System.out.println(s1 == s2); // 在 JDK 7+ 中,很可能为 true,因为 intern() 将对象引用放入了常量池
这个例子就说明了运行时常量池在运行时是可以变化的。
最佳实践与注意事项
-
理解符号引用的重要性:Class 常量池的存在,让 Java 的类可以独立编译、动态链接。在类加载的解析阶段,JVM 会把符号引用替换为直接引用(比如内存地址),这样大大增强了 Java 的灵活性。
-
注意常量池溢出:在 JDK 7 之前,运行时常量池位于永久代(PermGen),如果通过
String.intern()不停地往里面加字符串,可能会导致java.lang.OutOfMemoryError: PermGen space。从 JDK 7 开始,字符串常量池被移到了堆中,而运行时常量池的其他部分(如静态变量)在 JDK 8 被移到了元空间(直接内存),溢出情况有所变化,但理论上如果动态添加太多常量(比如大量使用动态代理生成的类),元空间仍然可能耗尽。 -
String.intern()的使用:这是一个经典的 “时空权衡” 案例。合理使用intern()可以节省内存(重复的字符串值只存一份),但错误使用(比如对大量显然不会重复的字符串调用)则会增加常量池的负担,甚至影响 GC 效率。在实际开发中,需要谨慎评估。
总结
一句话概括:Class 常量池是编译后的静态清单,而运行时常量池是 JVM 在运行时基于这个清单建立的动态内存数据结构。理解这个区别,是深入掌握 Java 类加载机制和 JVM 内存模型的关键一步。