03.6 - 运行时常量池
定位: Class 文件常量池的运行时版本——从静态符号表到动态可扩展的常量仓库 面试高频度: ⭐⭐⭐ 考查方式: 两级常量池区别、符号引用→直接引用、String.intern() 原理
一、这是什么?为什么需要它?
Class 文件常量池是编译时生成的静态模板——它记录了类中用到的所有常量信息(类名、方法名、字段名、字符串字面量等),全部以符号引用的形式存在(字符串描述,不是内存地址)。
运行时常量池是类加载后在方法区创建的动态实例——它把 Class 文件常量池的内容加载进来,并且在运行过程中:
- 逐步将符号引用解析为直接引用(内存地址)
- 允许通过
String.intern()动态添加新的常量
Class 文件常量池 (静态) 运行时常量池 (动态)
┌─────────────────────────┐ ┌─────────────────────────┐
│ #1 = Class │ 类加载 │ #1 → 指向方法区的 │
│ "java/lang/String"│ ─────→ │ String 类元数据 │
│ #2 = Methodref #1.#3 │ │ #2 → 指向 vtable 中的 │
│ #3 = NameAndType │ │ length() 方法地址 │
│ "length":"()I" │ │ #N = 通过 intern() │
│ #4 = String "hello" │ │ 动态加入的字符串 │
│ ... │ │ ... │
│ │ │ │
│ 只有符号引用, 无内存地址 │ │ 符号引用→直接引用(逐步) │
│ 编译期确定, 不可增长 │ │ 运行时也支持动态加入 │
└─────────────────────────┘ └─────────────────────────┘为什么需要两级? 因为"编译时不知道运行时的内存地址"——这是动态链接和多态的本质要求。如果编译时就硬编码了内存地址,那 List list = new ArrayList() 这样的多态代码就无法工作了(编译时不知道实际执行的是 ArrayList 还是 LinkedList)。
二、原理拆解
2.1 符号引用 → 直接引用
符号引用(Symbolic Reference) 是一组描述目标的字符串。Class 文件中的常量池条目以 CONSTANT_Class_info、CONSTANT_Methodref_info、CONSTANT_Fieldref_info 等形式存在。
CONSTANT_Methodref_info 的结构:
┌──────────────────────────────┐
│ tag = 10 (CONSTANT_Methodref) │
│ index = class_index (#1) │ → CONSTANT_Class_info → "java/lang/String"
│ index = nat_index (#2) │ → CONSTANT_NameAndType → "length":"()I"
└──────────────────────────────┘
翻译成人话: "有一个方法, 属于 java/lang/String 类,
方法名叫 length, 签名是 ()I (无参数返回 int)"直接引用(Direct Reference) 是内存地址或偏移量——指向方法区中类的元数据(对类引用),或指向 vtable 中方法入口地址(对方法引用)。
解析过程:
解析前 (运行时常量池) 解析后 (运行时常量池)
┌──────────────────────────┐ ┌──────────────────────────┐
│ #1 = Class │ │ #1 = Class 引用 │
│ "java/lang/String" │ 解析 │ 指向方法区 String │
│ │ ─────→ │ 类元数据的指针 │
│ #2 = Methodref │ │ │
│ java/lang/String. │ │ #2 = Methodref │
│ length:()I │ │ 指向 vtable 中 │
│ │ │ length() 方法地址 │
└──────────────────────────┘ └──────────────────────────┘
↑ ↑
编译期确定 (字符串形式) 第一次符号引用解析 (运行时)
可能延迟到字节码指令实际使用前为什么解析可以延迟?
JVM 规范允许迟到绑定(late resolution / lazy binding)——符号引用不一定要在类加载阶段全部解析完成,而是在字节码指令第一次引用该符号时再解析。
interface Processor {
void process();
}
class FastProcessor implements Processor {
public void process() { /* fast */ }
}
class SafeProcessor implements Processor {
public void process() { /* safe */ }
}
void execute(Processor p) {
p.process(); // 字节码: invokeinterface #N
// 编译时 #N 指向 Processor.process() 的符号引用
// 运行时: 根据 p 的实际类型, 解析到 Fast 或 Safe 的 process()
}编译时无法知道 p 的实际类型,因此无法解析到具体的方法地址。运行时首次执行 invokeinterface #N 时,JVM 根据 p 的动态类型查找 vtable 或 itable,解析出真正的方法入口。
2.2 String.intern() 原理
String.intern() 是运行时常量池动态扩展的核心机制。
// JDK 7+ 的实现逻辑 (伪代码)
public native String intern();
// 等价逻辑:
// 1. 检查运行时常量池中是否已存在 equals 相等的字符串
// 2. 存在 → 返回常量池中的引用
// 3. 不存在 → 在常量池中创建/加入该字符串, 返回新引用String s1 = "hello"; // 编译期常量, 直接进常量池
String s2 = new String("hello"); // 堆上新对象
String s3 = s2.intern(); // 检查常量池: "hello"已在, 返回常量池引用
System.out.println(s1 == s2); // false (常量池引用 != 堆对象)
System.out.println(s1 == s3); // true (都是常量池引用)
System.out.println(s2 == s3); // false (堆对象 != 常量池引用)intern() 的演化:
| JDK | 实现位置 | 效果 |
|---|---|---|
| 6 及以前 | PermGen | intern 字符串在永久代,不回收,PermGen 满则 OOM |
| 7+ | 堆 | intern 字符串在堆,GC 可回收不再引用的字符串 |
| 8+ | 堆 (同上) | 元空间代替 PermGen,字符串池仍在堆 |
典型用途: 字符串去重。比如从数据库读大量重复字符串(如状态码 "SUCCESS"/"FAIL"),手动 intern 可以减少内存占用:
// 100 万行数据的 status 字段, 只 3 种值
// 不 intern: 100 万个 String 对象在堆上
// intern 后: 常量池中 3 个 + 3 个指针指向它们
String status = resultSet.getString("status").intern();注意:intern 不是免费的——每次调用 intern 都要在常量池中查找(类哈希表结构),有性能开销。适合低频重复大量、有显著压缩收益的场景。
三、图解全景
┌─────────────────────────────────────────────────────────────────────┐
│ 符号引用 → 直接引用解析流程 │
│ │
│ 字节码指令执行到 invokevirtual #2 │
│ │ │
│ ▼ │
│ 检查运行时常量池中 #2 是否已解析 │
│ │ │
│ ┌──────┴──────┐ │
│ ▼ ▼ │
│ 已解析 未解析 │
│ 直接使用 进入解析流程 │
│ 内存地址 │ │
│ ▼ │
│ 从 #2 取 class_index (#1) │
│ 和 nat_index (#3) │
│ │ │
│ ▼ │
│ 查找方法区中 String 类的元数据 │
│ │ │
│ ┌──────────────┴──────────────┐ │
│ ▼ ▼ │
│ 找到类 找不到 │
│ │ ↓ │
│ ▼ 抛出 NoClassDefFoundError │
│ 在 vtable 中查找 length() │
│ │ │
│ ▼ │
│ 替换 #2 的值为方法入口地址 (直接引用) │
│ │ │
│ ▼ │
│ 执行引擎从该地址开始执行 length() │
│ │
│ 下一次 invokevirtual #2 → 直接使用已解析的地址, 跳过解析过程 │
└─────────────────────────────────────────────────────────────────────┘四、实战验证
验证 1: String.intern() 行为对比
public class InternDemo {
public static void main(String[] args) {
// 场景 1: 字面量引用比较
String a = "hello";
String b = "hello";
System.out.println("a == b: " + (a == b)); // true (同是常量池引用)
// 场景 2: new String() 与 intern
String c = new String("hello");
System.out.println("a == c: " + (a == c)); // false (堆对象 != 常量池)
String d = c.intern();
System.out.println("a == d: " + (a == d)); // true (intern 返回常量池引用)
// 场景 3: 运行时动态拼接的字符串
String e = "he" + "llo"; // 编译期常量折叠 → 等价于 "hello"
System.out.println("a == e: " + (a == e)); // true (编译期优化)
// 场景 4: 运行时拼接 (非编译期常量)
String f = new String("he") + new String("llo");
String g = f.intern();
System.out.println("a == g: " + (a == g)); // true (intern 返回已有常量)
// 场景 5: intern 去重效果
String h = new String("hello");
String i = new String("hello");
System.out.println("(h == i): " + (h == i)); // false (不同堆对象)
System.out.println("(h.intern() == i.intern()): " +
(h.intern() == i.intern())); // true (同常量池引用)
}
}javac InternDemo.java && java InternDemo
# 全部输出 true (除了 a==c 和 h==i)验证 2: 常量池的运行时解析
通过 javap -v 观察字节码中的常量池引用和运行时差异:
javap -v InternDemo.class | grep -A 5 "Constant pool"可以看到 #2 = Methodref 这种符号引用——它们在编译时只是文字描述,运行时才会被替换为实际地址。
五、面试视角
| 追问 | 答案要点 |
|---|---|
| 运行时常量池和 Class 文件常量池的区别? | Class 文件常量池是编译时生成的静态模板(符号引用),运行时常量池是类加载后在方法区创建的动态版本(符号引用→直接引用逐步解析,支持 String.intern() 动态扩展) |
| String.intern() 的原理? | JDK 7+ 实现:检查运行时常量池中是否有 equals 相等的字符串,有→返回常量池引用,没有→在堆中创建并加入常量池,返回引用 |
| 符号引用什么时候变成直接引用? | 类加载的解析阶段,或在字节码指令第一次引用该符号时(迟到绑定)。解析时机由 JVM 实现决定,规范允许延迟到使用前 |
| 为什么不能所有符号引用都在编译时解析? | 多态和动态链接。编译时不知道变量的实际运行时类型,无法确定方法的具体实现地址 |
| 符号引用和直接引用的区别? | 符号引用是字符串描述("java/lang/String.length:()I"),编译时确定;直接引用是内存地址/偏移量,运行时确定 |
| intern() 在 JDK 6 和 JDK 8 的区别? | JDK 6 intern 字符串在 PermGen(空间小,不回收→OOM);JDK 7+ 字符串池移到堆(空间大,GC 自动回收) |
📚 相关链接
- **Class文件结构** — Class 文件常量池是运行时常量池的静态来源
- **类加载生命周期** — 类加载的解析阶段完成符号引用→直接引用转换
- **方法区与元空间** — 运行时常量池存储在方法区/元空间中
- **Java堆** — 字符串常量池(JDK 7+)移入堆管理
- ← 返回 **运行时数据区索引**