栈帧与方法调用
Java 方法调用不是凭空发生的。每次方法调用,JVM 都会在线程的虚拟机栈中创建一个栈帧。方法执行完毕后,栈帧出栈。
# 1. 栈帧是什么
栈帧是方法运行时的基本数据结构。
一个栈帧通常包含:
- 局部变量表。
- 操作数栈。
- 动态链接。
- 方法返回地址。
- 一些附加信息。
线程栈
├─ main方法栈帧
├─ service方法栈帧
└─ dao方法栈帧
当前正在执行的方法对应栈顶栈帧。
# 2. 局部变量表
局部变量表存放方法参数和局部变量。
例如:
public int add(int a, int b) {
int c = a + b;
return c;
}
局部变量表中会保存:
this引用。- 参数
a。 - 参数
b。 - 局部变量
c。
静态方法没有 this。
# 3. 操作数栈
操作数栈用于字节码指令计算。
例如:
return a + b;
可能对应:
iload_1
iload_2
iadd
ireturn
执行过程:
- 把
a压入操作数栈。 - 把
b压入操作数栈。 iadd弹出两个值相加。- 把结果压回栈。
- 返回结果。
# 4. 动态链接
Class 文件中保存的是符号引用。方法执行过程中,JVM 需要把符号引用解析为实际可调用的目标。
动态链接与方法调用、多态、运行时绑定密切相关。
例如:
List<String> list = new ArrayList<>();
list.add("a");
调用哪个 add 方法,需要在运行期结合对象实际类型确定。
# 5. 方法返回地址
方法执行完成后,JVM 要知道回到调用者的哪个位置继续执行。栈帧中会保存相关返回信息。
返回方式包括:
- 正常返回。
- 抛出异常。
如果方法抛出异常且没有被当前方法捕获,当前栈帧会出栈,异常继续向调用链上层传播。
# 6. StackOverflowError
递归过深会导致虚拟机栈空间不足:
public void recursion() {
recursion();
}
最终可能抛出:
java.lang.StackOverflowError
常见原因:
- 无限递归。
- 递归深度过大。
- 方法局部变量过多。
- 线程栈配置过小。
# 7. 阶段小结
栈帧是理解方法调用的关键。局部变量表保存局部状态,操作数栈执行字节码计算,动态链接支持方法解析和多态,返回地址保证方法执行后能回到调用点。线程栈问题通常表现为 StackOverflowError 或无法创建新线程。
# 8. 栈帧结构图
Java 虚拟机栈(线程私有)
┌──────────────────────────┐
│ methodC 栈帧(当前栈顶) │
│ ├─ 局部变量表 │
│ ├─ 操作数栈 │
│ ├─ 动态链接 │
│ └─ 返回地址 │
├──────────────────────────┤
│ methodB 栈帧 │
├──────────────────────────┤
│ methodA 栈帧 │
└──────────────────────────┘
方法调用越深,栈帧越多。递归不终止时,线程栈不断增长,最终触发 StackOverflowError。
# 9. 方法调用流程
以下代码:
public int a() {
return b() + 1;
}
private int b() {
return c() + 1;
}
private int c() {
return 1;
}
执行流程:
调用 a()
├─ 创建 a 栈帧
│ └─ 调用 b()
│ ├─ 创建 b 栈帧
│ │ └─ 调用 c()
│ │ ├─ 创建 c 栈帧
│ │ ├─ c 返回 1,c 栈帧出栈
│ │ ├─ b 返回 2,b 栈帧出栈
│ ├─ a 返回 3,a 栈帧出栈
异常传播也是类似过程,只是返回方式变成“异常退出”。如果当前方法没有捕获异常,栈帧会出栈,异常继续向上层传播。
# 10. 局部变量表 Slot
局部变量表以 Slot 为单位。
常见规则:
boolean、byte、char、short、int、float、引用类型通常占 1 个 Slot。long、double占 2 个 Slot。- 实例方法的 Slot 0 通常是
this。 - 静态方法没有
this。
示例:
public long add(int a, long b) {
long c = a + b;
return c;
}
局部变量表可能类似:
slot 0: this
slot 1: a
slot 2-3: b
slot 4-5: c
# 11. 开发中常见问题
# 11.1 递归为什么容易栈溢出
每次递归都会创建新的栈帧。如果递归没有出口,或者递归深度超过线程栈容量,就会栈溢出。
解决方式:
- 增加递归终止条件。
- 改成迭代。
- 控制输入规模。
- 必要时调整
-Xss,但这不是根本方案。
# 11.2 为什么创建太多线程也会 OOM
每个线程都需要栈空间。线程池配置不合理时,大量线程会消耗本地内存,可能导致 unable to create native thread。
# 专家速查表
| 栈帧区域 | 存放内容 | 排查价值 |
|---|---|---|
| 局部变量表 | 方法参数、局部变量、this | 分析变量槽位和 GC Roots |
| 操作数栈 | 字节码计算的临时操作数 | 理解指令执行过程 |
| 动态链接 | 指向运行时常量池引用 | 理解方法解析和分派 |
| 方法返回地址 | 方法完成后的返回位置 | 理解调用链恢复 |
| 附加信息 | JVM 实现相关调试信息 | 与具体虚拟机实现有关 |
栈帧问题通常表现为调用链过深、递归失控、局部变量持有大对象导致回收不及时。分析时要把线程栈、字节码和业务调用路径放在一起看。
# 12. Tips 快问快答
Q:栈帧会被 GC 回收吗?
A:栈帧随方法调用创建和销毁,不由 GC 像堆对象那样回收。
Q:局部变量表里的引用会影响对象回收吗?
A:会。只要栈帧中的引用还指向对象,该对象通常就是可达的。
Q:为什么异常栈能看到调用链?
A:异常栈记录了异常传播时经过的方法栈帧信息,因此能看到调用路径。
Q:-Xss 越大越好吗?
A:不是。单线程栈更大可以支持更深调用,但会减少同样内存下可创建线程数。