0%

虚拟机栈

JVM 虚拟机栈:方法执行的内存模型

虚拟机栈(Java Virtual Machine Stack)是 JVM 运行时数据区的核心组件之一,它专门用于描述 Java 方法的执行过程。作为线程私有的内存区域,虚拟机栈的生命周期与线程一致,内部通过 “栈帧” 记录方法调用的状态,是理解方法执行、局部变量存储、操作数计算的关键。

虚拟机栈的基本特性

1. 线程私有

每个线程在创建时会同步创建一个虚拟机栈,各线程的虚拟机栈相互隔离,确保方法执行的线程安全性。例如,线程 A 调用 methodA() 和线程 B 调用 methodA() 会在各自的虚拟机栈中生成独立的栈帧,互不干扰。

2. 栈帧为核心单位

虚拟机栈的内部存储单元是栈帧(Stack Frame),每个方法的调用对应一个栈帧的 “入栈”,方法执行完成(正常返回或抛出异常)对应栈帧的 “出栈”。因此,虚拟机栈的操作极其简单:只有入栈(push)和出栈(pop)两种操作

3. 无垃圾回收

虚拟机栈的内存随方法调用动态分配,随方法结束自动释放(栈帧出栈),无需 GC 介入,内存管理高效且确定。

4. 访问速度快

虚拟机栈的内存分配和释放基于连续的栈空间,访问速度仅次于程序计数器,远快于堆内存。

栈帧:方法执行的状态载体

栈帧是虚拟机栈的基本组成单元,每个栈帧对应一个方法的执行状态,内部包含局部变量表、操作数栈、动态链接、方法返回地址附加信息五部分。

1. 局部变量表:存储方法参数与局部变量

局部变量表(Local Variable Table)是栈帧中用于存储方法参数和局部变量的内存区域,其结构为数字数组,容量在编译期已确定(记录在方法的 Code 属性的 locals 字段中),运行时不可动态调整。

(1)存储单元:变量槽(Slot)

局部变量表以 “变量槽(Slot)” 为基本存储单元,每个 Slot 占用 4 字节(32 位):

  • 32 位类型byteshortcharintfloat、对象引用、boolean)占用 1 个 Slot;
  • 64 位类型longdouble)占用 2 个连续的 Slot(遵循 “对齐” 原则,不允许跨方法拆分)。
(2)存储内容
  • 方法参数:按参数声明顺序存储,实例方法的第一个 Slot 固定存储 this 引用(静态方法无 this);
  • 局部变量:方法内定义的变量,按定义顺序依次分配 Slot(已分配的 Slot 可被复用,如作用域结束的变量 Slot 可被后续变量使用)。
(3)实例解析

通过字节码查看局部变量表结构(使用 javap -v 类名 命令):

1
2
3
4
// 示例方法
public void test(int a, String b) {
int c = 10;
}

编译后的字节码片段(局部变量表相关部分):

1
2
3
4
5
6
7
8
9
10
11
12
public void test(int, java.lang.String);
descriptor: (ILjava/lang/String;)V
flags: ACC_PUBLIC
Code:
stack=1, locals=4, args_size=3 // locals=4 表示局部变量表有4个Slot
// 字节码指令...
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/example/Test; // 实例方法的this引用(Slot 0)
0 5 1 a I // 参数a(int,Slot 1)
0 5 2 b Ljava/lang/String; // 参数b(引用,Slot 2)
3 2 3 c I // 局部变量c(int,Slot 3)
  • 实例方法的 this 固定占用 Slot 0;
  • 参数 a(int)和 b(引用)分别占用 Slot 1 和 2;
  • 局部变量 c(int)占用 Slot 3,总容量为 4 个 Slot(locals=4)。

2. 操作数栈:方法执行的计算引擎

操作数栈(Operand Stack)是栈帧中用于临时存储计算中间结果、传递方法参数的内存区域,本质是一个后进先出(LIFO)的栈结构

(1)栈深度与类型

操作数栈的最大深度(栈容量)在编译期确定(记录在方法 Code 属性的 stack 字段中):

  • 32 位类型占用 1 个栈单位深度;
  • 64 位类型占用 2 个栈单位深度。
(2)核心作用
  • 中间结果存储:算术运算(如 i + j)时,先将 ij 入栈,执行加法指令后弹出结果并压栈;
  • 方法参数传递:调用方法时,将参数依次压入操作数栈,供被调用方法读取;
  • 临时变量空间:替代寄存器暂存数据,减少对物理寄存器的依赖。
(3)实例解析

通过字节码查看操作数栈的工作过程:

1
2
3
4
// 示例方法:计算 a + b
public int add(int a, int b) {
return a + b;
}

编译后的字节码片段:

1
2
3
4
5
6
7
8
9
public int add(int, int);
descriptor: (II)I
flags: ACC_PUBLIC
Code:
stack=2, locals=3, args_size=3 // stack=2 表示操作数栈最大深度为2
0: iload_1 // 将 Slot 1 的 a 入栈(操作数栈:[a])
1: iload_2 // 将 Slot 2 的 b 入栈(操作数栈:[a, b])
2: iadd // 弹出 a 和 b,计算 a+b,结果入栈(操作数栈:[a+b])
3: ireturn // 弹出结果并返回

过程解析:

  1. iload_1:从局部变量表 Slot 1 加载 a 到操作数栈;
  2. iload_2:从局部变量表 Slot 2 加载 b 到操作数栈;
  3. iadd:弹出 ab,计算和后将结果压栈;
  4. ireturn:弹出结果作为返回值,栈帧出栈。

3. 动态链接:符号引用到直接引用的转换

动态链接(Dynamic Linking)是栈帧中用于支持方法调用的关键机制,其核心是将常量池中的 “符号引用” 转换为内存中的 “直接引用”(如方法的内存地址)。

(1)符号引用与直接引用
  • 符号引用:编译期生成的字符串标识(如类名、方法名),存储在 class 文件的常量池中(如 #3#8 等),与内存地址无关;
  • 直接引用:直接指向目标的内存地址(如指针、偏移量),可直接访问目标。
(2)转换时机
  • 静态解析:类加载阶段(解析阶段)将部分符号引用(如静态方法、私有方法,无法被重写)转换为直接引用,运行期不再改变;
  • 动态链接:运行期每次调用方法时转换符号引用(如接口方法、重写的子类方法),支持 “动态绑定”(晚期绑定)。
(3)实例解析

字节码中方法调用的符号引用:

1
2
3
4
// 调用 System.out.println("hello") 的字节码
0: getstatic #3 // 符号引用:Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #7 // 符号引用:String "hello"
5: invokevirtual #8 // 符号引用:Method java/io/PrintStream.println:(Ljava/lang/String;)V
  • #3#7#8 是常量池中的符号引用,运行时通过动态链接转换为 System.out 字段的内存地址和 println 方法的入口地址。

4. 方法返回地址:恢复调用者执行状态

方法返回地址用于记录调用当前方法的上层方法的执行位置(即调用者的程序计数器值),确保方法执行完成后,调用者能从正确的位置继续执行。

(1)两种返回方式
  • 正常返回(return):方法执行 return 指令时,将返回值压入调用者的操作数栈,然后将返回地址设置为调用者的程序计数器值,当前栈帧出栈;
  • 异常返回:方法执行过程中抛出未捕获的异常,此时无返回值,通过异常表找到调用者的异常处理入口,当前栈帧直接出栈。

5. 附加信息

栈帧中还可能包含与虚拟机实现相关的附加信息(如调试信息),这部分不是 JVM 规范强制要求的,不同虚拟机(如 HotSpot、J9)可能有不同实现。

虚拟机栈的异常

虚拟机栈的内存大小可通过参数配置(固定大小或动态扩展),可能出现两种异常:

1. StackOverflowError

当线程请求的栈深度超过虚拟机允许的最大深度时抛出,典型场景是递归调用无终止条件。例如:

1
2
3
public void recursive() {
recursive(); // 无限递归,栈帧持续入栈,最终触发 StackOverflowError
}

可通过 -Xss 参数调整栈大小(如 -Xss1m 表示栈大小为 1MB),但栈大小过大会导致可创建的线程数减少(总内存固定时)。

2. OutOfMemoryError

  • 若虚拟机栈支持动态扩展,当扩展时无法申请到足够内存时抛出;
  • 若虚拟机栈为固定大小,当创建线程时无法为其分配初始栈内存时抛出(如系统内存不足,无法创建新线程)

欢迎关注我的其它发布渠道

表情 | 预览
快来做第一个评论的人吧~
Powered By Valine
v1.3.10