JVM 内存区域与栈帧详解
内容涵盖:运行时数据区、虚拟机栈栈帧、符号引用/直接引用、常量池、操作数栈实例。
目录
一、JVM 运行时数据区
运行时数据区主要分为线程私有和线程共享两大类,共 5 个核心区域(外加一个常被提及的"直接内存")。
线程私有(随线程创建/销毁)
1. 程序计数器(Program Counter Register)
- 作用:记录当前线程正在执行的字节码指令地址(行号指示器)。
- 特点:每个线程一份;是唯一一个规范中不会抛出 OOM 的区域。
- 用于多线程切换后恢复到正确执行位置(分支、循环、跳转、异常处理、线程恢复都依赖它)。
2. Java 虚拟机栈(JVM Stack)
- 作用:描述 Java 方法的执行过程。每个方法调用会创建一个栈帧(Stack Frame)。
- 特点:方法调用入栈、返回出栈。
- 异常:栈深度超上限抛
StackOverflowError(如递归过深);无法分配内存抛OutOfMemoryError。
3. 本地方法栈(Native Method Stack)
- 作用:为 Native 方法(
native修饰、由 C/C++ 等实现的方法)服务。 - 特点:HotSpot 中与虚拟机栈合二为一。
- 异常:同样会抛
StackOverflowError和OutOfMemoryError。
线程共享(所有线程共享)
4. Java 堆(Heap)
- 作用:存放几乎所有对象实例和数组,是 GC 管理的主要区域。
- 细分:新生代(Eden + Survivor S0/S1)、老年代。
- 异常:堆中无内存完成实例分配且无法扩展时抛
OutOfMemoryError: Java heap space。
5. 方法区 / 元空间(Method Area / Metaspace)
- 作用:存储类信息(类的版本、字段、方法、接口)、常量、静态变量、即时编译器编译后的代码缓存等。
- 演进:
- JDK 7 及以前:HotSpot 用"永久代(PermGen)"实现,属于堆的一部分。
- JDK 8 及以后:用元空间(Metaspace)替代永久代,改用本地内存。
- 异常:永久代/元空间耗尽抛
OutOfMemoryError: Metaspace。 - 包含运行时常量池(Runtime Constant Pool):存放编译期生成的字面量、符号引用,以及运行期动态加入的常量(如
String.intern())。
额外:直接内存(Direct Memory)
- 不属于运行时数据区,也不是堆,而是直接分配的本地内存。
- 作用:NIO 的
ByteBuffer.allocateDirect()使用,避免在 Java 堆和 Native 堆间复制数据,提升 IO 性能。 - 异常:受本机物理内存限制,也会抛
OutOfMemoryError(如Direct buffer memory)。
小结表
| 区域 | 线程归属 | 存储内容 | 常见异常 |
|---|---|---|---|
| 程序计数器 | 私有 | 字节码指令地址 | 无 |
| 虚拟机栈 | 私有 | 栈帧(局部变量、操作数栈等) | SOF / OOM |
| 本地方法栈 | 私有 | Native 方法调用 | SOF / OOM |
| 堆 | 共享 | 对象实例、数组 | OOM: heap space |
| 方法区/元空间 | 共享 | 类信息、常量、静态变量 | OOM: Metaspace |
| 直接内存 | — | NIO 缓冲 | OOM: Direct buffer |
二、Java 虚拟机栈 · 栈帧详解
每个方法调用会创建一个栈帧,栈帧里装着该方法"运行所需的全部现场数据"的容器,包含 4 个部分:
1. 局部变量表(Local Variable Table)
存放方法的参数和内部定义的局部变量,以"槽位(Slot)"为单位,按声明顺序排布。
对 add(int a, int b)(实例方法)来说:
| 槽位索引 | 内容 |
|---|---|
| 0 | this(隐藏参数,实例方法才有) |
| 1 | 参数 a |
| 2 | 参数 b |
| 3 | 局部变量 c |
- 静态方法没有
this,槽位 0 就是第一个参数。 - 基本类型占 1 个槽;
long/double占 2 个槽;引用类型存的是指向堆中对象的引用(地址),不是对象本身。 - 大小在编译期确定,运行期不变。
2. 操作数栈(Operand Stack)
方法执行时的"工作台"。字节码指令在这里进行运算——先把操作数压入栈,执行指令时弹出、计算,再把结果压回。
以 int c = a + b 对应字节码:
0: iload_1 // 局部变量槽位1(a) 压入操作数栈
1: iload_2 // 槽位2(b) 压入操作数栈
2: iadd // 弹出栈顶两个值相加,结果压回栈顶
3: istore_3 // 弹出栈顶结果,存入局部变量槽位3(c)
iload_1 -> [a]
iload_2 -> [a, b]
iadd -> [a+b] // 弹出a、b,压入和
istore_3 -> [ ] // 弹出和,写回 c
3. 动态链接(Dynamic Linking)
栈帧里保存着一个指向运行时常量池中该栈帧所属方法的引用。方法里调用其他方法、访问字段时,通过它去常量池找到对应的符号引用,再解析成直接引用。
为什么叫"动态":一部分符号引用在编译期无法确定,需要运行期才能解析(比如多态、invokedynamic 的 Lambda)。
4. 方法返回地址(Return Address)
记录方法执行完后"回到哪里继续执行"——即调用该方法的下一条指令地址。
方法返回时:
- 把返回值放到调用者的操作数栈上;
- 弹出当前栈帧(销毁);
- 根据栈帧里保存的返回地址,让调用者继续从"调用该方法的下一条指令"往下执行。
栈帧纵览
┌─────────────────────────────┐
│ 栈帧(Stack Frame) │
├─────────────────────────────┤
│ 局部变量表 │ ← this、参数、局部变量(按槽位)
│ 操作数栈 │ ← 运算的临时工作台
│ 动态链接 │ ← 指向常量池中方法的引用
│ 方法返回地址 │ ← 调用者的下一条指令
│ (可能还有附加信息,如调试信息) │
└─────────────────────────────┘
一句话:方法被调用 → 新建栈帧压栈 → 参数进局部变量表 → 操作数栈做运算 → 动态链接找被调用的方法 → 返回时按返回地址回到调用者并弹出栈帧。
三、符号引用 vs 直接引用
理解两者关键在于区分 编译期 和 运行期。
符号引用(Symbolic Reference)
- 定义:一组字符串描述,用"名字 + 描述符"方式文字性指明目标方法/字段是谁。
- 阶段:编译期产物,存在于字节码(
.class文件)的常量池里。 - 它描述的是"方法叫什么、什么签名",而不是"在内存哪个位置"。
例如常量池项:
Methodref #5.#23 // 指向类 #5、名字和类型 #23
#5 = Class #6 // java/lang/String
#23 = NameAndType #24:#25
#24 = add // 方法名 add
#25 = (II)I // 描述符:两个int参数,返回int
直接引用(Direct Reference)
- 定义:能直接定位到目标的引用,是真实的内存地址、偏移量或句柄。
- 阶段:运行期,类加载/解析后得到。
- 例如方法在方法区(元空间)里的实际入口地址、字段在实例中的偏移量。
核心区别对比
| 维度 | 符号引用 | 直接引用 |
|---|---|---|
| 产生阶段 | 编译期(.class 常量池) |
运行期(类加载解析后) |
| 本质 | 字符串描述(名称+描述符) | 内存地址 / 偏移量 / 句柄 |
| 依赖 | 不依赖具体内存布局 | 依赖目标类已加载、地址已确定 |
| 是否可直接定位 | 否,需先解析 | 是,直接跳转/取值 |
| 举例 | add (II)I |
方法在元空间入口地址 0x00007f... |
一句话:符号引用是"门牌号上的名字",直接引用是"GPS 坐标"。
为什么分两个
.class 文件在编译时,目标类可能还没被 JVM 加载,内存布局未知,编译器没法填真实地址。符号引用实现"编译与运行解耦"——编译期只写字符串描述,运行期类加载器把类装进内存后再解析成地址。这支撑了 Java 的动态链接和延迟解析(用到才解析) 特性。
四、常量池:class 常量池 / 运行时常量池 / 字符串常量池
三个易混概念
| class 常量池 | 运行时常量池 | 字符串常量池 | |
|---|---|---|---|
| 又叫 | Constant Pool Table | Runtime Constant Pool | String Pool / String Table |
| 在哪 | .class 文件里(磁盘) |
方法区/元空间(JDK8+) | 堆里(JDK7 起移到堆) |
| 什么时候有 | 编译期 | 类加载时 | 运行期 |
| 存什么 | 字面量 + 全部符号引用 | class 常量池的运行时版本 | 只有 String 对象 |
| 本质 | 静态表 | 表 | 哈希表(类似 HashSet) |
class 文件常量池
javac 编译后写进 .class 文件里的一个表,存两类东西:
- 字面量:字符串、数值常量、
static final的常量值。 - 符号引用:类/接口全限定名、字段名和描述符、方法名和描述符。
它是字节码指令的数据来源——每条指令通过 #编号 去这个表里查。
常见项类型:Class、Methodref、Fieldref、InterfaceMethodref、String、Utf8、Integer、NameAndType、MethodHandle、MethodType、InvokeDynamic。
关键区别
- 内容不同:class/运行时常量池存所有符号引用和字面量;字符串常量池只存字符串对象。
- 位置不同:运行时常量池在元空间;字符串常量池在堆。
- 有交集,但不等同:代码里的字符串字面量先在 class 常量池里有
String项,运行期被intern进字符串常量池成为真正的String对象。
字符串常量池示例
String s1 = "成功"; // ① 编译期:class 常量池有 String 字面量 "成功"
// ② 运行期:字面量放进字符串常量池(堆里的 String 对象)
String s2 = "成功"; // ③ 直接从字符串常量池取同一对象,s1 == s2 为 true
String s3 = new String("成功"); // ④ new 出来的另开一块,不进池,s3 != s1
- ① 涉及 class 常量池 / 运行时常量池;
- ②③ 涉及字符串常量池;
- 两者是"原料"和"成品池"的关系。
五、操作数栈实例
以 compute(int x) 方法执行 return this.add(x, 10) 为例。
字节码
aload_0 // ① 把 this 压入操作数栈
iload_1 // ② 把 x(槽位1)压入操作数栈
bipush 10 // ③ 把常量 10 压入操作数栈
invokevirtual #N // ④ 调用 Calc.add:(II)I
ireturn // ⑤ 返回栈顶的 int 结果
compute 的局部变量表:
| 槽位 | 内容 |
|---|---|
| 0 | this(Calc 实例引用) |
| 1 | 参数 x |
操作数栈逐步变化(假设 x = 5)
初始 → 栈: [ ]
① aload_0 → 栈: [ this ]
② iload_1 → 栈: [ this, 5 ]
③ bipush 10 → 栈: [ this, 5, 10 ] ← 栈顶
④ invokevirtual → 弹出 this、5、10 交给 add
add 执行完返回结果 15
→ 栈: [ 15 ] ← 返回值压回 compute 的栈
⑤ ireturn → 弹出栈顶 15 作为返回值
→ 栈: [ ](compute 返回,栈帧销毁)
关键点
- 操作数栈是后进先出的:压栈顺序
this → x → 10,栈顶是10。 - 调用方法时参数按从左到右依次压栈,最左边的接收者
this在栈底,最后一个参数在栈顶。 add有自己独立的栈帧和操作数栈,不共用compute的栈;方法间通过"压参数、弹结果"传递数据。- 这就是每个方法都要有自己的操作数栈的原因。
浙公网安备 33010602011771号