专栏原创出处:github-源笔记文件 (opens new window) ,github-源码 (opens new window),欢迎 Star,转载请附上原文出处链接和本声明。
Java JVM-虚拟机专栏系列笔记,系统性学习可访问个人复盘笔记-技术博客 Java JVM-虚拟机 (opens new window)
# 一、前言
Java 虚拟机以方法作为最基本的执行单元,「栈帧」则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。
栈帧存储了方法的局部变量表、操作栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
大白话:
代码里面的每个方法都是一个栈帧,我们可以把一个方法链看着是一个个栈帧堆积在那,每调用一个方法就少一个栈帧。
- 方法里面的变量与运算操作通过 局部变量表、操作栈 完成。
- 因为 Java 支持多态,所以运行时实际的调用通过 动态连接 完成。
- 方法完成后可能会有返回值,返回值在哪通过 方法返回地址 指定。
# 二、运行时栈帧结构概念图
# 三、 局部变量表
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
1) 变量槽
局部变量表的容量以变量槽为最小单位,boolean、byte、char、short、int、float、reference 或 returnAddress 类型的数据都可以使用 32 位或更小的物理内存来存储。 64 位的数据类型只有 long 和 double 两种。
Java 虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的变量槽数量。
- 如果访问的是 32 位数据类型的变量,索引 N 就代表了使用第 N 个变量槽
- 如果访问的是 64 位数据类型的变量,则说明会同时使用第 N 和 N+1 两个变量槽
2) 方法调用时参数的传递
当一个方法被调用时,Java 虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。
如果执行的是实例方法(没有被 static 修饰的方法),那局部变量表中第 0 位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字「this」来访问到这个隐含的参数(字节码 aload_0)。
其余参数则按照参数表顺序排列,占用从 1 开始的局部变量槽,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的变量槽。
3) 变量槽是可以重用的
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。
缺点:影响到系统的垃圾收集行为。比如:大方法占用较多的 Slot,执行完该方法的作用域后没有对 Slot 赋值或者清空设置 null 值,垃圾回收器便不能及时的回收该内存。
4) 局部变量定义后必须赋值
局部变量定义后必须赋值。因为它不像类变量一样存在「准备阶段初始化赋值」「初始化阶段程序赋值」。
# 四、 操作栈
操作栈也常被称为操作数栈,它是一个后入先出(LIFO)栈。
1) 操作栈容量
操作栈的每一个元素都可以是包括 long 和 double 在内的任意 Java 数据类型。Javac 编译器的数据流分析工作保证了在方法执行的任何时候,操作栈的深度都不会超过在 max_stacks 数据项中设定的最大值。
- 32 位数据类型所占的栈容量为 1
- 64 位数据类型所占的栈容量为 2
2) 操作栈的操作过程
当一个方法刚刚开始执行的时候,这个方法的操作栈是空的,在方法的执行过程中,会有各种字节码指令往操作栈中写入和提取内容,也就是出栈和入栈操作。
- 比如在做算术运算的时候,是通过将运算涉及的操作栈压入栈顶后调用运算指令来进行的
- 比如在调用其他方法的时候,是通过操作栈来进行方法参数的传递
例如:整数加法的字节码指令 iadd,这条指令在运行的时候要求操作栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行这个指令时,会把这两个 int 值出栈并相加,然后将相加的结果重新入栈。
3) 栈帧的局部优化
在虚拟机概念模型中,栈帧之间是独立的。但是实际虚拟机实现时会做一些优化处理,让两个栈帧的「部分操作栈」、「部分局部变量表出现重叠」,这样可以节省一部分空间,方法调用时直接可以共享一部分数据,无须进行额外的参数复制。
# 五、 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用:
- 一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为「静态解析」
- 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为「动态连接」
# 六、 方法返回地址
无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息。正常调用退出时有返回值,异常调用提出时一般没有返回值。
1) 方法退出方式-正常调用完成
执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定。
主调方法的 PC 计数器的值就可以作为「返回地址」,栈帧中很可能会保存这个计数器值。
2) 方法退出方式-异常调用完成
在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。 无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出。
返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。
3) 方法退出的过程
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:
- 恢复上层方法的局部变量表和操作栈
- 把返回值(如果有的话)压入调用者栈帧的操作栈中
- 调整 PC 计数器的值以指向方法调用指令后面的一条指令等
这里写的「可能」是由于这是基于概念模型的讨论,只有具体到某一款 Java 虚拟机实现,会执行哪些操作才能确定下来。
# 七、字节码分析实战
写一个简单的程序运算,calculation 方法使用一个运算结果与类变量 threshold 进行比较。其中对分母做了不为 0 校验。
编译后查看字节码进行分析,进一步加强对「栈帧」的理解。
public class StackFrameExample {
private int threshold;
public StackFrameExample(int threshold) {
this.threshold = threshold;
}
public boolean calculation(int a, int b) {
int c = 300;
int sum = a + b;
if (sum == 0) {
throw new IllegalArgumentException("zero ...");
}
return (c / sum) > threshold;
}
}
执行 javap -c -p -v StackFrameExample.class
查看字节码如下:
内容较多建议分屏与源代码对应查看,参考翻译-字节码指令集 (opens new window)
public class StackFrameExample
minor version: 0
major version: 55
flags: ACC_PUBLIC, ACC_SUPER
Constant pool: => 常量池
#1 = Methodref #7.#26 // java/lang/Object."<init>":()V
#2 = Fieldref #6.#27 // io/gourd/java/jvm/bytecode/StackFrameExample.threshold:I
#3 = Class #28 // java/lang/IllegalArgumentException
#4 = String #29 // zero ...
#5 = Methodref #3.#30 // java/lang/IllegalArgumentException."<init>":(Ljava/lang/String;)V
#6 = Class #31 // io/gourd/java/jvm/bytecode/StackFrameExample
#7 = Class #32 // java/lang/Object
#8 = Utf8 threshold
#9 = Utf8 I
#10 = Utf8 <init>
#11 = Utf8 (I)V
#12 = Utf8 Code
#13 = Utf8 LineNumberTable
#14 = Utf8 LocalVariableTable
#15 = Utf8 this
#16 = Utf8 Lio/gourd/java/jvm/bytecode/StackFrameExample;
#17 = Utf8 calculation
#18 = Utf8 (II)Z
#19 = Utf8 a
#20 = Utf8 b
#21 = Utf8 c
#22 = Utf8 sum
#23 = Utf8 StackMapTable
#24 = Utf8 SourceFile
#25 = Utf8 StackFrameExample.java
#26 = NameAndType #10:#33 // "<init>":()V
#27 = NameAndType #8:#9 // threshold:I
#28 = Utf8 java/lang/IllegalArgumentException
#29 = Utf8 zero ...
#30 = NameAndType #10:#34 // "<init>":(Ljava/lang/String;)V
#31 = Utf8 io/gourd/java/jvm/bytecode/StackFrameExample
#32 = Utf8 java/lang/Object
#33 = Utf8 ()V
#34 = Utf8 (Ljava/lang/String;)V
{
private int threshold;
descriptor: I
flags: ACC_PRIVATE
public io.gourd.java.jvm.bytecode.StackFrameExample(int);
descriptor: (I)V
flags: ACC_PUBLIC
Code:
stack=2, locals=2, args_size=2
0: aload_0 =>this 引用本地变量推送至栈顶
1: invokespecial #1 // Method java/lang/Object."<init>":()V 调用父类构造函数 this.super()
4: aload_0 =>this 引用本地变量推送至栈顶
5: iload_1 => 构造函数的 threshold 本地变量推送至栈顶
6: putfield #2 // Field threshold:I 为指定类的实例域 threshold 赋值
9: return => 从当前方法返回 void
public boolean calculation(int, int);
descriptor: (II)Z
flags: ACC_PUBLIC
Code:
stack=3, locals=5, args_size=3 =>
0: sipush 300 => 将一个短整型常量 300 推送至栈顶
3: istore_3 => 将栈顶 c = 300 数值存入第四个本地变量(this,a,b)已经占了 3 个槽位
4: iload_1 => 将第二个 int 型本地变量 a 推送至栈顶
5: iload_2 => 将第二个 int 型本地变量 b 推送至栈顶
6: iadd => 将栈顶两 int 型数值 a、b 相加并将结果压入栈顶
7: istore 4 => 将栈顶 int 型数值存入指定本地变量 sum (第五个本地变量,槽位为 4)
9: iload 4 => 将指定的 int 型本地变量(槽位为 4 的 sum)推送至栈顶
11: ifne 24 => 当栈顶 int 型数值 sum 不等于 0 时跳转到[24] 字节码
14: new #3 // class java/lang/IllegalArgumentException 开始创建异常对象
17: dup => 复制栈顶数值并将复制值压入栈顶
18: ldc #4 // String zero ... 将 String 型常量值(zero ...)从常量池中推送至栈顶
20: invokespecial #5 // 调用超类构建方法, 实例初始化方法
23: athrow => 将栈顶的异常抛出
24: iload_3 => 将第四个 int 型本地变量 c(槽位为 3)推送至栈顶
25: iload 4 => 将第五个 int 型本地变量 sum(槽位为 4)推送至栈顶
27: idiv => 将栈顶两 int 型数值相除(c/sum)并将结果压入栈顶
28: aload_0 =>this 引用本地变量推送至栈顶
29: getfield #2 // Field threshold:I 获取类的实例域 threshold
32: if_icmple 39 => 比较栈顶两 int 型数值大小((c / sum) > threshold), 当结果小于等于 0 时跳转[39] 字节码
35: iconst_1 => 将 int 型 1 推送至栈顶(true)
36: goto 40 => 无条件跳转
39: iconst_0 => 将 int 型 0 推送至栈顶(false)
40: ireturn => 从当前方法返回 int
LineNumberTable:=> 表存放方法的行号信息
line 17: 0
line 18: 4
line 19: 9
line 20: 14
line 22: 24
LocalVariableTable: => 存放方法的局部变量信息,与上面指令操作一一对应,重点是第一个槽位是 this 引用
Start Length Slot Name Signature
0 41 0 this Lio/gourd/java/jvm/bytecode/StackFrameExample;
0 41 1 a I
0 41 2 b I
4 37 3 c I
9 32 4 sum I
StackMapTable: number_of_entries = 3 => 栈图
frame_type = 253
offset_delta = 24
locals = [ int, int ]
frame_type = 14
frame_type = 64
stack = [ int ]
}
疑问点一): Code 中 18: ldc #4
的#4 表示什么?
对应常量池的符号引用。上述中#4 表示常量池中#29 = Utf8 zero ...
字符串
疑问点二): aload_0 指令的意义?
对于需要对象才能操作的方法、类成员变量等,我们需要先确定是哪个对象,然后在确定这个对象要执行的操作。
- 如果使用局部变量,直接操作数据槽
- 如果使用类变量,先要拿到类的引用,再通过符号引用定位类成员
- 每个非静态方法隐式的第一个参数默认是 this 对象,只是对我们程序来说看不到。因此第 0 个槽位的数据就是 this,直接使用 aload_0 指令进行压栈操作
疑问点三): calculation 方法明明返回 boolean ,为什么最终使用 ireturn 返回 int 型?
为了减少指令集,使用 int 替代了 byte、short、char 和 boolean 的算数指令。这些类型可以向上转换不会丢失精度。
疑问点四): StackMapTable-栈图是什么?
感兴趣深入研究的可参考 虚拟机规范-StackMapTable (opens new window)
# 参考
- 《深入理解 Java 虚拟机:JVM 高级特性与最佳实践(第 3 版)》周志明 著
- 字节码翻译字典-字节码指令集 (opens new window)