JVM中的栈帧结构

栈帧(Stack Frame)是用于支持虚拟机进行方法调用方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应一个栈帧在虚拟机栈里面从入栈到出栈的过程。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,概念模型上,典型栈帧结构如图所示:
JVM中的栈帧结构_第1张图片
然后介绍一下栈帧中的局部变量表、操作数栈、动态连接和方法返回地址等各个部分的作用和数据结构。

1. 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量

局部变量表的容量以变量槽(Slot)为最小单位,虚拟机规范中未说明它该有多大,只说了每个Slot都应该能存放一个boolean、byte、char、shoert、int、float、reference或returnAddress类型的数据。当然它们在Java中均占了32位以内,前面6种我们见得比较多,现在说一下reference类型。reference类型表示对一个对象实例的引用,虚拟机对它的长度和结构没有说明,但虚拟机实现至少都应当能通过这个引用做到两点:

  1. 从此引用直接或间接地查找到对象在Java堆中的数据存放的起始地址索引。(对象实例数据
  2. 此引用中直接或间接地查找对象所属数据类型在方法去中的存储的类型信息。(类型数据

而第8种类型returnAddress已经很少见了。对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间,Java中明确的64位数据只有long和double两种。虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。即如果访问的是32位变量,则索引n就代表访问第n个Slot,64位的则是第n和n+1个Slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,如果遇到有这种操作的字节码序列,会在类加载的校验阶段抛出异常。

方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过这样的设计虽节省了空间,但也会有一定的副作用,例如在某些情况下,Slot的复用会直接影响到系统的垃圾收集行为。


下面看三段代码:


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

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

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

下面是运行结果,主要看一下标红色的地方。
JVM中的栈帧结构_第2张图片

  • 第一个代码中向内存填充了64MB的数据,然后通知虚拟机进行垃圾收集,但是并没有回收,而且分配空间的时候直接是在老年代分配的。当然能理解,因为在执行System.gc()时,变量placeholder还处于作用域内,虚拟机自然不敢回收placeholder的内存。
  • 第二个代码块中,花括号外面后placeholder的作用域就消失了,但是还是没能回收掉。
  • 第三个代码块中只是在花括号外面中加了一行int a = 0;就会回收,是为什么呢?

placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。第一次修改(第二个代码)中虽然已经离开了placeholder的作用域,但在此之后没有任何对局部变量表 的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联,因此不会释放。但第二次修改(第三个代码)中加了一个语句后,它便复用了placeholder引用的Slot空间,所以placeholder引用所占用的Slot就“消失”了,没了GC Roots也就自然会被回收了。

手动对一些引用赋值为null虽然会对GC 有利,但是在编码角度讲,更好的方法是以恰当的变量作用域来控制变量回收时间。还有一点关于局部变量表的就是我们知道类变量有两次赋初始值的过程,一次是在准备阶段赋予系统初始值,另外一次是在初始化阶段赋予程序员定义的初始值。因此即使初始化阶段程序员没有为类变量赋值也没关系,它仍会有一个初始值。但局部变量不一样,如果一个局部变量定义了但没有赋初始值是不能使用的。比如下面的代码就会报错,Java不是在任何情况下都会将一些变量赋予一个初值的。

    public static void main(String[] args) {
        int a;
        System.out.println(a);
    }

2. 操作数栈

操作数栈是一个后入先出的栈,同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double,32位数据类型所占的栈容量为1,64位的占2个。方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

一个方法刚开始执行时操作数栈是空的,方法执行过程中会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作。例如执行iadd指令时,就会将最接近栈顶的两个int元素取出并相加,然后将相加的结果再入栈。操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点。比如刚才的iadd指令,它取出的元素必须是int的,不能出现诸如long和float类型的变量。

虽然概念模型中不同栈帧之间是完全相互独立的,但大多虚拟机实现中会有一些优化处理:令两个栈帧出现一部分重叠,让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起重叠在一起,这样在进行方法调用时就可以公用一部分数据,无须进行额外的参数复制传递,如图所示:
JVM中的栈帧结构_第3张图片

3. 动态连接(这部分比较重要)

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。我们知道Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接,关于方法的解析与调用看这。所以要执行某个方法时,某个指令(例如invokevirtual)将常量池中的引用作为参数,而根据这个引用就可以找到真正的栈帧

4. 方法返回地址

一个方法开始执行后只有两种方式可以退出,

  • 正常完成出口:执行引擎遇到任意一个方法返回的字节码指令,这时候可能有返回值传递给上层的方法调用者,是否有返回值及返回值类型是由方法返回指令决定的。
  • 异常完成出口:方法在执行过程中遇到了异常,而且这个异常没有在方法体内得到处理,不管是何种异常,只要在本方法的异常表中没有搜索到相匹配的,就会导致方法退出。异常完成出口的方式退出方法,不会给上层调用者产生任何返回值。

方法退出时总是要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来恢复它的上层方法的执行状态。一般来说,方法正常退出时调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值;而方法异常退出时,返回地址是要通过异常处理表来确定,栈帧中一般不会保存这部分信息。方法退出过程实际相当于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等

你可能感兴趣的:(java虚拟机原理)