栈存储结构与栈帧内部结构
栈帧:方法调用的最小单元
前面我们说了方法调用会产生栈帧。那么一个栈帧里面到底装了什么?
JVM 规范定义了栈帧的组成结构:
┌─────────────────────────────────────────────┐
│ 栈帧(Stack Frame) │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 局部变量表(Local Variables) │ │
│ │ slot 0 │ slot 1 │ slot 2 │ ... │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 操作数栈(Operand Stack) │ │
│ │ [ ] [ ] [ ] [ ] ←top │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 动态链接(Dynamic Linking) │ │
│ │ 指向运行时常量池的符号引用 │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 方法返回地址 │ │
│ │ 正常返回 / 异常退出 │ │
│ └─────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────┐ │
│ │ 附加信息(可选) │ │
│ │ 调试信息、对齐填充等 │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────────┘局部变量表
局部变量表存放方法参数和方法内部定义的局部变量。
Slot:变量存储的基本单位
JVM 用 Slot(槽) 作为局部变量表的最小单位。一个 Slot 可以存放:
int、short、byte、char、boolean、float—— 1 个 Slotlong、double—— 2 个连续的 Slot
java
public class LocalVariablesDemo {
public static void method(long l, double d, Object o, int i) {
// 局部变量表分配:
// slot 0: this 引用(非 static 方法才有)
// slot 1-2: long 类型的 l(占 2 个 Slot)
// slot 3-4: double 类型的 d(占 2 个 Slot)
// slot 5: Object 类型的 o
// slot 6: int 类型的 i
int x = 10; // slot 7
String s = "hello"; // slot 8
boolean flag = true; // slot 9
}
}Slot 的复用
局部变量表中的 Slot 不是每个变量独占的。当一个变量的作用域结束后,它的 Slot 可以被后续的变量复用:
java
public class SlotReuse {
public void method() {
{
int a = 1; // slot 1
// a 的作用域在这里结束
}
// slot 1 现在可以复用
int b = 2; // 复用 slot 1
}
}Slot 复用会影响 GC 的行为:如果一个局部变量表中还有对对象的引用,但该引用实际上已经「不在作用域内」了(但 Slot 还被复用前的内容占用),GC 可能认为这个对象仍然可达。
局部变量的初始化
局部变量表中的变量必须经过显式赋值才能使用,否则编译失败:
java
public class UninitializedError {
public static void main(String[] args) {
int x; // 未初始化
System.out.println(x); // 编译错误:variable x might not have been initialized
}
}这和类变量的默认值(零值)不同。类变量(static 变量)在准备阶段就有零值,而局部变量必须手动赋值。
操作数栈
操作数栈是一个后进先出的栈,用于执行字节码指令时的临时数据存储。
工作方式
JVM 的字节码指令大多是对操作数栈的操作:
java
public int compute() {
int a = 1;
int b = 2;
int c = a + b;
return c;
}对应的字节码:
java
// compute() 方法的字节码
iconst_1 // 常量 1 入栈 → 栈:[1]
istore_1 // 出栈存到 slot 1 → 栈:[]
iconst_2 // 常量 2 入栈 → 栈:[2]
istore_2 // 出栈存到 slot 2 → 栈:[]
iload_1 // slot 1 入栈 → 栈:[1]
iload_2 // slot 2 入栈 → 栈:[1, 2]
iadd // 出栈 2+1 入栈 → 栈:[3]
istore_3 // 出栈存到 slot 3 → 栈:[]
iload_3 // slot 3 入栈 → 栈:[3]
ireturn // 返回栈顶值 → 栈:[]操作数栈的大小
操作数栈的大小在编译时确定——编译器根据字节码的最大栈深度,为每个方法分配固定大小的操作数栈。
java
public class StackDepth {
// 这个方法的操作数栈深度为 2
public int add() {
// 最多同时有 2 个值在栈上
return 1 + 2;
}
}动态链接
每个栈帧都包含一个指向运行时常量池的引用,这就是动态链接。
符号引用与直接引用
字节码中引用其他类、字段、方法时,使用的是符号引用(Symbolic Reference)。在方法执行过程中,这些符号引用需要被解析成直接引用(Direct Reference)。
栈帧
│
└── 动态链接 → 指向运行时常量池中的符号引用
│
│ 解析
▼
直接引用
│
▼
实际的内存地址为什么叫「动态」
因为解析发生在运行时。对于虚方法调用,编译器在编译时不知道具体是哪个类的方法(可能是子类),只能在运行时通过动态分派确定。这需要运行时通过动态链接去查找。
方法返回地址
方法返回地址记录了方法执行完毕后应该回到哪里继续执行。
两种退出方式
| 退出方式 | 说明 |
|---|---|
| 正常返回 | 执行引擎遇到 ireturn/lreturn/areturn/return 等返回指令 |
| 异常退出 | 方法执行过程中抛出未捕获的异常 |
正常返回 vs 异常退出的区别
- 正常返回:返回地址由
invokexxx指令隐式保存,栈帧弹出后,PC 寄存器恢复到调用者的下一条指令 - 异常退出:返回地址由异常处理表决定,不通过返回地址恢复执行
栈帧的内存占用估算
一个栈帧的大小取决于局部变量表和操作数栈的大小:
栈帧大小 ≈ 局部变量表大小 + 操作数栈大小 + 其他固定开销java
public class StackFrameSize {
// 参数 a (int) → slot 1
// 参数 b (long) → slot 2-3(占2个)
// 参数 c (Object) → slot 4
// 局部变量 x (int) → slot 5
// 局部变量 y (String) → slot 6
// 局部变量 z (double) → slot 7-8
// 操作数栈最大深度:3
// 局部变量表:9 个 slot × 4 字节 ≈ 36 字节
// 操作数栈:3 × 4 字节(最大深度)≈ 12 字节
// 加上动态链接、返回地址等固定开销
// 总计大约 64~96 字节每个栈帧
public static void method(int a, long b, Object c) {
int x = 10;
String y = "hi";
double z = 3.14;
}
}本节小结
栈帧由五个部分组成:
| 组成部分 | 作用 | 关键点 |
|---|---|---|
| 局部变量表 | 存储方法参数和局部变量 | Slot 为单位,long/double 占 2 Slot |
| 操作数栈 | 临时计算数据的存放 | 最大深度编译时确定 |
| 动态链接 | 指向运行时常量池 | 符号引用 → 直接引用 |
| 方法返回地址 | 记录返回位置 | 正常返回 vs 异常退出 |
| 附加信息 | 调试信息等 | 可选 |
下一节,我们来看 局部变量表(slot/静态vs局部变量),深入理解局部变量表的细节。
