JVM 内存空间与常量池详解
常量池
字面量和符号引用
在 JVM(Java 虚拟机)中,字面量(Literal)和符号引用(Symbolic Reference)是 Class 文件中常量池(Constant Pool)里存放的两大类数据。
简单来说:字面量是数据的"值",而符号引用是数据的"定位描述"。
下面详细解释这两者的区别和作用:
1. 字面量 (Literal)
字面量比较接近于我们在 Java 语言层面理解的"常量"概念。它们是确定的、静态的数据值。
主要包括以下两类:
文本字符串:比如 String str = "Hello World"; 中的 "Hello World"。
被声明为 final 的常量值:包括基本数据类型(int, float, long, double)的常量值。例如 final int MAX = 100; 中的 100。
通俗理解:
就像你写在纸上的具体内容,比如写了一个数字 "100" 或者写了一句话 "你好",这些具体的内容就是字面量。
2. 符号引用 (Symbolic Reference)
符号引用属于编译原理方面的概念。因为 Java 在编译代码时(即 .java 编译成 .class 时),编译器并不知道引用的类、方法或字段在内存中的实际地址(也就是不知道它们运行起来后在内存的哪里)。
所以,编译器会用一组符号(字符串描述)来代表这些目标。
主要包括下面三类常量:
类和接口的全限定名 (Fully Qualified Name):例如 java/lang/String。
字段的名称和描述符 (Field Name and Descriptor):例如类中定义了一个变量 int age,这里包含字段名 age 和描述它是 int 类型的描述符 I。
方法的名称和描述符 (Method Name and Descriptor):例如 substring 方法,以及包含参数类型和返回值类型的描述字符串 (II)Ljava/lang/String;。
通俗理解:
这就好比你要去找一个人,但你不知道他具体的住址(内存地址)。
符号引用就像是一张名片,上面写着:"张三,XX公司经理,电话123"。这是一个描述信息。
你通过这个描述信息,在程序运行的时候,去查找这个人到底住在哪里。
方法区
方法区的实现形式
- jdk8以前:永久代
- jdk8以后:元空间
常量池
1. JDK 8 还有运行时常量池吗?
有的,绝对有。
只要 Java 还有 .class 文件和类加载机制,就一定需要运行时常量池。
JDK 8 的情况:
运行时常量池 (Runtime Constant Pool):依然存在,位于 元空间 (Metaspace) 中(即本地内存)。它主要存储类加载后的符号引用和字面量。
字符串常量池 (String Table):依然存在,位于 Java 堆 (Heap) 中。它专门用来存储字符串对象的引用,实现字符串的复用。
2. 为什么运行时常量池和字符串常量池要分开?(核心原因)
它们虽然都叫"常量池",但服务的对象和生命周期完全不同。
A. 运行时常量池 (属于"类")
来源:它直接对应 .class 文件中的 Constant Pool。
归属:它是属于每一个类的。每个类加载到内存后,都会有一个属于自己的运行时常量池。
内容:
字面量:整数、浮点数、字符串字面量(这里存的是通过索引指向字符串常量池的引用)。
符号引用:类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。
作用:它是类加载和动态链接的核心。JVM 执行字节码时(比如 invokevirtual),需要通过这里的符号引用找到真正的内存地址(解析)。
B. 字符串常量池 (属于"JVM 全局")
来源:JVM 运行时动态生成或加载字符串时维护的。
归属:它是全局共享的,整个 JVM 只有一个字符串常量池。
结构:它本质上是一个 HashTable(哈希表)。
内容:存储的是 字符串对象的引用(实际的 String 对象实例在堆中)。
作用:为了省内存。程序中通过 String s = "abc"; 这种方式创建字符串非常频繁,如果每次都 new 一个新对象,内存会爆炸。字符串常量池保证了内容相同的字符串字面量在内存中只有一份。
3. 分开的具体理由
- 复用粒度不同:
字符串常量池是为了复用具体的字符串数据。比如类 A 用了 "hello",类 B 也用了 "hello",它们应该共享同一个 String 对象。所以它必须是全局的。
运行时常量池是为了复用类的结构信息。类 A 里的 method1 和类 B 里的 method1 可能毫无关系,它们需要在各自的类上下文中解析。
- 生命周期与 GC:
字符串常量池里的字符串如果没有被任何地方引用,是可以被 GC 回收的(虽然回收条件比较苛刻,但在堆中方便管理)。
运行时常量池跟随类的生命周期。只要类没被卸载(Unload),它的运行时常量池就必须一直存在。将它放在元空间(JDK 8)可以避免因为加载太多类而挤爆堆内存。
- 性能优化:
如果把所有类的常量全混在一个大池子里,查找效率会变低。
运行时常量池让类在解析自己的指令时,只需要查自己的小本子,速度更快。
当真正需要"字符串对象"这个实体时,才去查全局的字符串常量池。
运行时常量池里的 "abc" 只是一个符号/索引。
它们最终都指向了字符串常量池里管理的那个唯一的 String 对象。
为什么静态变量不放在运行时常量池
这个问题触及到了 JVM 内存布局设计的核心权衡。静态变量(Static Variables)之所以不放在运行时常量池(Runtime Constant Pool)中,主要有以下三个关键原因:
1. 可变性 (Mutability)
常量池的本质是"只读":顾名思义,运行时常量池主要存放的是常量(Constant),即那些在编译期确定、运行期不可变的信息(如字面量、类名、方法签名的符号引用)。这些数据一旦解析完成,通常是不会被修改的。
静态变量是"可变"的:
public static int count = 0;
// 运行过程中可以随意修改
count = 5;如果在运行时常量池里频繁进行写操作,会破坏常量池"只读共享"的设计初衷,也会增加内存管理的复杂性(常量池通常设计为紧凑的只读结构)。
2. 生命周期与访问模式
- 静态变量属于"类对象"本身:
在 Java 语言规范中,静态变量(类变量)是随着类的初始化而创建的。从逻辑上讲,它们是 java.lang.Class 对象的一部分属性。
- JDK 7/8 的改动:
在 JDK 7 及以后,HotSpot JVM 将静态变量从 PermGen(永久代)移到了 Java 堆 (Heap) 中,具体是存放在对应的 java.lang.Class 对象的尾部。
- 这样做的好处是:静态变量可以像普通对象一样参与 Java 堆的 GC(垃圾回收)。如果是非常大的静态集合(比如 static List<Object> cache),放在堆里可以更好地利用堆的垃圾回收机制进行管理。如果放在元空间(Metaspace)或旧的 PermGen 的常量池里,回收起来会非常麻烦且低效。
3. 数据结构与查找效率
- 运行时常量池是"索引表":
它主要是一个数组结构,通过索引(Index)来访问符号引用。它的主要用途是解析(Resolution),即把 #12 这样的符号变成真正的内存地址。
- 静态变量需要"直接访问":
静态变量在类加载的准备阶段分配内存,在初始化阶段赋值。程序运行时,访问静态变量通常是直接读写内存地址,而不需要像常量池那样经过"查表 -> 解析"的过程。将它独立出来存储,可以优化访问效率。
总结
运行时常量池是用来存"描述信息"(类叫什么、方法叫什么、字面量是多少)的,就像是说明书。
静态变量是类的"状态数据"(当前计数是多少、缓存了什么对象),就像是记分牌。
说明书(常量池)印好了就不改了,放在元空间(Metaspace)。
记分牌(静态变量)随时在变,而且可能很大,所以放在堆(Heap)里,跟着 Class 对象一起管理。