Skip to content

03.6 - 运行时常量池

定位: Class 文件常量池的运行时版本——从静态符号表到动态可扩展的常量仓库 面试高频度: ⭐⭐⭐ 考查方式: 两级常量池区别、符号引用→直接引用、String.intern() 原理

一、这是什么?为什么需要它?

Class 文件常量池是编译时生成的静态模板——它记录了类中用到的所有常量信息(类名、方法名、字段名、字符串字面量等),全部以符号引用的形式存在(字符串描述,不是内存地址)。

运行时常量池是类加载后在方法区创建的动态实例——它把 Class 文件常量池的内容加载进来,并且在运行过程中:

  1. 逐步将符号引用解析为直接引用(内存地址)
  2. 允许通过 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_infoCONSTANT_Methodref_infoCONSTANT_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)——符号引用不一定要在类加载阶段全部解析完成,而是在字节码指令第一次引用该符号时再解析。

java
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() 是运行时常量池动态扩展的核心机制。

java
// JDK 7+ 的实现逻辑 (伪代码)
public native String intern();

// 等价逻辑:
// 1. 检查运行时常量池中是否已存在 equals 相等的字符串
// 2. 存在 → 返回常量池中的引用
// 3. 不存在 → 在常量池中创建/加入该字符串, 返回新引用
java
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 及以前PermGenintern 字符串在永久代,不回收,PermGen 满则 OOM
7+intern 字符串在堆,GC 可回收不再引用的字符串
8+堆 (同上)元空间代替 PermGen,字符串池仍在堆

典型用途: 字符串去重。比如从数据库读大量重复字符串(如状态码 "SUCCESS"/"FAIL"),手动 intern 可以减少内存占用:

java
// 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() 行为对比

java
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 (同常量池引用)
    }
}
bash
javac InternDemo.java && java InternDemo
# 全部输出 true (除了 a==c 和 h==i)

验证 2: 常量池的运行时解析

通过 javap -v 观察字节码中的常量池引用和运行时差异:

bash
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+)移入堆管理
  • ← 返回 **运行时数据区索引**

Knowledge4J — Java 知识库