第八章 虚拟机字节码执行引擎 | part 1

35 阅读4分钟

运行时栈帧结构

Java 虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual MachineStack)的栈元素。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。从第六章内容中,我们知道局部变量表的大小和操作数栈的深度是 Javac 编译时就能确认的,且记录在方法表的 Code 属性中。

局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量,详见第六章。

从第二章 part 1 的内容中我们知道,局部变量表的变量槽是可以重用的,而这种重用会影响到系统的垃圾收集行为。如下代码:

public class LocalTest {
    public static void main(String[] args) {
        {
            byte[] pd = new byte[64 * 1024 * 1024];
        }
        System.gc();
    }
}

当我们查看垃圾收集过程时,会发现虽然 pd 已经不在作用域了,但它占用的空间却没有被回收:

image.png

如果我们添加一行代码,如下所示:

public class LocalTest {
    public static void main(String[] args) {
        {
            byte[] pd = new byte[64 * 1024 * 1024];
        }
        int a = 0;
        System.gc();
    }
}

则会发现空间被回收掉了:

image.png

事实上,pb 空间能否被回收的根本原因是:局部变量表中的变量槽是否还存在关于 pb 数组对象的引用。在代码一中,虽然已经离开 pb 的作用域,但在此之后没有发生对局部变量表的读写,pb 占用的变量槽还没有被别的变量复用,因此作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。而 int a = 0; 这个语句就相当于将 pb 从局部变量槽情况,从而得到正确的回收结果(手动赋值为 null 也是类似的效果)。

不过由于即时编译才是 JVM 执行代码的主要方式,而赋 null 值语句一定会被编译器优化掉,因此这个行为是毫无意义的。实际上当字节码被编译为本地代码后,对 GC Roots 的枚举与解释时期有显著差别,而上面的例子无需添加语句也可以正常执行。

另外提一点,局部变量表不存在 “准备阶段”,因此定义了但没有赋初值的局部变量是无法使用的,例如下面的代码就无法通过编译:

public static void main() {
    int a;
    System.out.println(a);
}

操作数栈

操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出栈。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。举个例子,例如整数加法的字节码指令 iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行这个指令时,会把这两个 int 值出栈并相加,然后将相加的结果重新入栈。

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接(Dynamic Linking)。

Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态链接

方法返回地址

一般来说,方法正常退出时,主调方法的 PC 计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈、把返回值(如果有的话)压入调用者栈帧的操作数栈中、调整 PC 计数器的值以指向方法调用指令后面的一条指令等。