深入理解 Java 虚拟机(八)运行时栈帧结构

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u011330638/article/details/82728922

虚拟机是一个相对于“物理机”的概念,这两种机器都有代码执行能力,区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行不被硬件直接支持的指令集格式。

在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器生成本地代码执行)两种选择,也可能两者兼备。但从外观上看,所有 Java 虚拟机的执行引擎都是一直的:输入字节码文件,解析字节码,输出执行结果。

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里从入栈到出栈的过程。

在编译代码的时候,栈帧需要多大的局部变量表、多深的操作数栈都已经完成确定了,并且写入到了方法表的 Code 属性中,因此一个栈帧需要分配多少内存,不会受到运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能很长,很多方法都同时处于执行状态,对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。

局部变量表

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

方法的 Code 属性的 max_locals 代表该方法所需要分配的最大容量,以 Slot 为单位。一个 Slot 可以存放 32 位以内的数据,对应的类型有 boolean、byte、char、short、int、float、reference 和 returnAddress 等(可以按照 Java 语言对应的数据类型理解,但其实 Java 虚拟机中的基本数据类型和 Java 语言有较大的区别)。

reference 可能是 32 位,也可能是 64 位的,它至少起到两点作用:一是从此引用中直接或间接地查找对象在 Java 堆中的数据存放的真实地址索引;二是从此引用中直接或间接地查找对象所属的数据类型在方法区中存储的类型信息。

long 和 double 需要使用两个 Slot 存放,因此 long 和 double 是非原子性的。但由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的 Slot 是否为原子操作,都不会引起数据安全问题。而且,对于两个相邻的共同存放一个 64 位数据的两个 Slot,不允许采用任何方法单独地访问其中一个,Java 虚拟机规范中明确规定了如果遇到这种操作,虚拟机应该在类加载的校验阶段抛出异常。

如果执行的是类的非 static 成员方法,则局部变量表第 0 项数据默认是 “this”。

为了节省栈帧空间,Slot 是可以重用的,即如果当前字节码 PC 计数器的值超出了某个变量的作用域,那这个变量对应的 Slot 就可以交给其它变量使用。但这可能会带来一些问题:

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

运行结果:

[Full GC (System.gc()) [PSYoungGen: 897K->0K(38400K)] [ParOldGen: 65544K->66363K(87552K)] 66441K->66363K(125952K)

可以看到,即使离开了 placeHolder 的作用区域,gc 之后却没有回收 placeHolder 的 64M 内存。

但如果:

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

运行结果:

[Full GC (System.gc()) [PSYoungGen: 929K->0K(38400K)] [ParOldGen: 65544K->827K(87552K)] 66473K->827K(125952K)

这时候发现,placeHolder 被回收了!

这是为什么呢?其实 placeHolder 是否被回收的根本依据是局部变量表中的 Slot 是否还存有关于 placeHolder 数组对象的引用。在前一份代码中,虽然代码已经离开了 placeHolder 的作用域,但在此之后,没有任何对局部变量表的读写操作,placeHolder 所占用的 Slot 还没有被其它变量复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联,因此 gc 后也不会被回收。因此在一个方法中,如果后面的代码有一些很耗时的操作,前面又有占用了大量内存但实际上不会再用到的变量,手动将其设为 null 值便不见得是一个绝对无意义的操作。但在通常情况下,不需要关心这些,因为经过 JIT 编译后,手动设 null 值的操作会在编译优化后被消除掉,而 gc 也能正确回收内存。

另外,局部变量不存在准备阶段,因此必须手动赋初始值才可以使用。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,它的最大深度在编译的时候写入到 Code 属性的 max_stacks 数据项中。

当一个方法开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和读取内容。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,比如 iadd 指令只能用于 int 型,不能用于 long 型。

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的,但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法:一是执行引擎遇到任意一个方法返回的字节码指令,这种方式称为正常退出出口;另一种是在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,这种方式不会产生返回值,称为异常完成出口。

无论采用哪种退出方式,方法退出之后都需要返回到方法被调用的位置,程序才能正常执行。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址;而方法异常退出时,返回地址要通过异常处理器表来确定。

附加信息

虚拟机允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试相关的信息,这部分信息称为附加信息。

猜你喜欢

转载自blog.csdn.net/u011330638/article/details/82728922