Skip to content

虚拟机栈:栈帧结构与操作数栈

递归,是程序员绕不开的话题。

你有没有遇到过这种情况:写了一个递归方法,结果运行时抛出 StackOverflowError?这个错误就是虚拟机栈溢出的典型表现。

今天,我们来深入了解一下虚拟机栈的内部结构。


一、虚拟机栈是什么?

虚拟机栈(VM Stack)是线程私有的,生命周期与线程相同。

它的核心作用是存储方法调用——每当一个方法被调用,就会在栈上创建一个栈帧(Stack Frame);方法执行完毕后,栈帧被弹出。

线程执行流程
┌─────────────────────────────────────────────────────────┐
│  虚拟机栈(VM Stack)                                    │
│                                                          │
│  ┌──────────┐                                            │
│  │ 栈帧 3   │  ← 当前正在执行的方法                      │
│  │ methodC  │                                            │
│  ├──────────┤                                            │
│  │ 栈帧 2   │    methodC() 调用 methodB()               │
│  │ methodB  │                                            │
│  ├──────────┤                                            │
│  │ 栈帧 1   │    methodB() 调用 methodA()               │
│  │ methodA  │                                            │
│  └──────────┘                                            │
│                                                          │
│  栈底 ──────────────────────────────────────→  栈顶       │
└─────────────────────────────────────────────────────────┘

为什么叫"栈"?因为它是后进先出(LIFO)的数据结构——最后进来的栈帧,最先出去。


二、栈帧的内部结构

每个栈帧包含四个部分:

2.1 局部变量表(Local Variables)

存储方法的参数和局部变量

局部变量表的大小在编译时就确定了,存储的是:

  • 方法的参数
  • 方法内部的局部变量
  • long 和 double 类型占用 2 个 Slot(槽),其他类型占用 1 个
java
public int method(int a, int b) {
    int c = 10;
    int d = a + b + c;
    return d;
}

编译后,这个方法的局部变量表大概是这样分配的:

Slot内容
0this(实例方法的隐式参数)
1参数 a
2参数 b
3局部变量 c
4局部变量 d

注意:static 方法没有 this,所以 Slot 0 存储的是第一个参数。

2.2 操作数栈(Operand Stack)

一个后进先出的栈,用于指令执行过程中的临时数据存储和计算

为什么需要操作数栈?因为字节码指令需要操作数。

java
public int add() {
    int a = 1;
    int b = 2;
    int c = a + b;
    return c;
}

对应的字节码执行过程:

iload_0      // 从局部变量表 Slot 0 加载 1,压入操作数栈
iload_1      // 从局部变量表 Slot 1 加载 2,压入操作数栈
iadd         // 弹出栈顶两个元素,相加,结果压回栈顶
istore_2     // 弹出栈顶元素,存入局部变量表 Slot 2
iload_2      // 加载结果,准备返回
ireturn      // 返回

操作数栈的深度也是在编译时确定的,可以通过 javap -v 查看。

2.3 动态链接(Dynamic Linking)

每个栈帧都包含一个指向运行时常量池的引用,用于符号引用到直接引用的转换

java
public class DynamicLinkDemo {

    public void method() {
        System.out.println("Hello");
    }
}

System.out.println() 在编译时只是一个符号引用(一个字符串)。在运行过程中,动态链接会将这个符号引用解析为实际的直接引用(指向 PrintStream.println() 方法的内存地址)。

2.4 返回地址(Return Address)

记录方法返回后应该继续执行的位置

方法返回有两种方式:

  1. 正常返回:通过 ireturnareturnreturn 等指令
  2. 异常退出:通过抛出异常,且没有在方法内捕获

无论哪种方式,都需要知道返回到哪里去。


三、栈帧结构图

┌─────────────────────────────────────────┐
│              栈帧结构                     │
├─────────────────────────────────────────┤
│                                         │
│  ┌─────────────────────────────────┐    │
│  │        返回地址                   │    │
│  │  (Return Address)                │    │
│  ├─────────────────────────────────┤    │
│  │        动态链接                   │    │
│  │  (Dynamic Linking)               │    │
│  ├─────────────────────────────────┤    │
│  │        局部变量表                  │    │
│  │  (Local Variables)               │    │
│  │  [slot 0] [slot 1] ...          │    │
│  ├─────────────────────────────────┤    │
│  │        操作数栈                   │    │
│  │  (Operand Stack)                 │    │
│  │  [     ] [     ] [     ]        │    │
│  └─────────────────────────────────┘    │
│                                         │
└─────────────────────────────────────────┘

四、常见问题

4.1 栈溢出(StackOverflowError)

递归调用没有正确退出条件时,会导致栈深度不断增加,最终溢出:

java
public class StackOverflowDemo {

    // 没有退出条件的递归
    public void recursive() {
        recursive();  // StackOverflowError
    }
}

4.2 虚拟机栈配置

bash
# 单个线程栈大小(JDK 11+ Linux x64 默认 1MB)
-Xss1m
-Xss512k
-Xss1024k

# 查看默认大小
java -XX:+PrintFlagsFinal -version | grep ThreadStackSize

4.3 栈大小的影响

栈大小适用场景
256KB递归深度不深的场景,节省内存
1MB默认值,大多数场景够用
2MB+深度递归、复杂框架(如大数据处理)

五、面试高频问题

问题 1:局部变量表中的 Slot 是什么?

Slot(槽)是局部变量表的最小单位,每个 Slot 占用 32 位(4 字节)。long 和 double 占用 2 个 Slot,其他类型占用 1 个。

问题 2:操作数栈和局部变量表有什么区别?

局部变量表是数组,通过索引访问(iload_0, iload_1);操作数栈是,只能 push/pop。操作数栈用于指令执行过程中的临时计算,局部变量表用于存储方法参数和局部变量。

问题 3:为什么递归容易导致栈溢出?

因为每次递归调用都会创建新的栈帧,栈深度不断增加。如果递归没有正确的退出条件,或者递归深度超过了栈容量,就会溢出。


留给你的问题

我们讲了栈帧的四个组成部分:局部变量表、操作数栈、动态链接、返回地址。

你有没有想过:为什么 HotSpot 虚拟机把虚拟机栈和本地方法栈合二为一实现,而不是分开?

实际上,早期的 HotSpot 是分开实现的。但后来发现,Native 方法调用和 Java 方法调用在底层是互通的——它们都需要栈来保存调用信息,只是语言不同。所以后来就合并实现了。

但这也带来一个问题:如果 Native 方法执行时间很长,会不会导致 Java 线程的虚拟机栈溢出?

这个问题值得深入思考。

基于 VitePress 构建