方法区内部结构(常量池/运行时常量池)
方法区内部结构一览
方法区(元空间)内部包含多个组成部分,其中运行时常量池是最核心的部分之一。
┌──────────────────────────────────────────────────┐
│ 方法区 / 元空间 │
│ │
│ ┌────────────────────────────────────────────┐ │
│ │ 类信息 │ │
│ │ - 类的修饰符、全限定名、父类名 │ │
│ │ - 实现的接口列表 │ │
│ │ - 字段表(修饰符、类型、名称) │ │
│ │ - 方法表(修饰符、返回类型、字节码) │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ 运行时常量池(Runtime Constant Pool) │ │
│ │ - 字面量(字符串、数值) │ │
│ │ - 符号引用(类、字段、方法的符号引用) │ │
│ │ - 方法句柄(MethodHandle) │ │
│ │ - 方法类型(MethodType) │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ 代码缓存(Code Cache) │ │
│ │ - JIT 编译后的本地机器码 │ │
│ │ - 即时编译器管理的数据结构 │ │
│ └────────────────────────────────────────────┘ │
│ ┌────────────────────────────────────────────┐ │
│ │ 其他信息:域引用、方法表、内部结构等 │ │
│ └────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────┘1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
常量池的两个阶段
常量池经历了两个阶段:Class 文件常量池 → 运行时常量池。
Class 文件常量池
编译阶段,.class 文件中包含一个静态的常量池。它是一个表结构,包含字面量和符号引用:
bash
# 用 javap 查看 Class 文件常量池
javap -verbose MyClass.class | grep -A 50 "Constant pool"1
2
2
java
Constant pool:
#1 = Methodref #4.#13 // java/lang/Object."<init>":()V
#2 = String #14 // hello
#3 = Integer 100
#4 = Class #15 // java/lang/Object
#5 = Fieldref #16.#17 // java/lang/System.out:Ljava/io/PrintStream;
#6 = Methodref #18.#19 // java/io/PrintStream.println:(Ljava/lang/String;)V1
2
3
4
5
6
7
2
3
4
5
6
7
运行时常量池
Class 文件常量池被加载到内存后,变成运行时常量池(存放在方法区/元空间中)。运行时常量池是方法区的一部分,它在类加载后存储真正的直接引用:
java
public class RuntimeConstantPool {
public static void main(String[] args) {
// "hello" 这个字符串,在运行时常量池中有对应的 entry
// 符号引用 #2 已经被解析成直接的内存地址
String s = "hello";
// Integer 100 的包装类型
Integer i = 100; // 自动装箱,使用运行时常量池中的常量
}
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
运行时常量池的动态性
运行时常量池与 Class 文件常量池最大的区别是具有动态性——运行时常量池可以在运行时添加新的常量。
String.intern():向常量池添加字符串
String.intern() 是向运行时常量池添加字符串的主要方式:
java
public class InternDemo {
public static void main(String[] args) {
// intern() 的作用:
// 1. 如果字符串常量池中已有该字符串,返回常量池中的引用
// 2. 如果没有,把该字符串加入常量池,返回常量池中的引用
String s1 = new String("hello");
String s2 = s1.intern();
// s1 是堆中 new 出来的对象
// s2 是常量池中的字符串引用
System.out.println(s1 == s2); // false(s1 在堆,s2 在常量池)
String s3 = "hello"; // 字面量在编译时就进入常量池
String s4 = "hello";
System.out.println(s3 == s4); // true(都是常量池中的同一个引用)
// intern() 的使用场景:节省内存
String[] arr = new String[1000000];
for (int i = 0; i < 1000000; i++) {
// 每个 new String() 都在堆上分配对象
// intern() 可以让多个引用共享常量池中的字符串
arr[i] = new String("hello").intern();
}
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
JDK 6 vs JDK 8 中 intern() 的区别
这是面试经典题,JDK 6 和 JDK 8 对 intern() 的实现有重大差异:
java
public class InternDifference {
public static void main(String[] args) {
String s1 = new StringBuilder("ja").append("va").toString();
// JDK 6:intern() 把字符串复制到 PermGen 的常量池
// s1 和 s2 是不同的对象
// JDK 7+:intern() 只在常量池中记录字符串的引用
// s1 和 s2 是相同的对象(堆中的同一个对象)
String s2 = s1.intern();
System.out.println(s1 == s2); // JDK 6: false, JDK 7+: true
}
}1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
核心原因:JDK 7 把字符串常量池移到了堆中。
常量池的 GC 回收
方法区的 GC 主要回收两部分:类的卸载和常量池的垃圾回收。
常量池中的常量回收
运行时常量池中的常量,如果没有任何地方引用,也会被 GC 回收:
java
public class ConstantGC {
public static void main(String[] args) {
// 大量创建字符串,这些字符串可能被 GC 回收
for (int i = 0; i < 100000; i++) {
String s = "generated_" + i;
// 如果这些生成的字符串没有被任何地方长期持有
// 它们可能会被常量池 GC 回收
}
}
}1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
本节小结
方法区内部结构的核心要点:
| 组成部分 | 说明 |
|---|---|
| 类信息 | 类的元数据、字段表、方法表 |
| 运行时常量池 | 字面量、符号引用、动态添加的常量 |
| 代码缓存 | JIT 编译后的机器码 |
| 运行时常量池的动态性 | 可以通过 String.intern() 动态添加 |
运行时常量池是理解字符串池化、intern() 机制、符号引用解析的关键。
下一节,我们来看 StringTable 位置调整与验证。
