一篇文章快速搞懂Java虚拟机的栈帧结构

什么是栈帧?

正如大家所了解的,Java虚拟机的内存区域被划分为程序计数器、虚拟机栈、本地方法栈、堆和方法区。(什么?你还不知道,赶紧去看看《Java虚拟机内存结构及编码实战》)这次要介绍的栈帧(Stack Frame),就是Java虚拟机中的虚拟机栈(Virtual Machine Stack)的基本元素,它也是用于支持Java虚拟机进行方法调用和方法执行背后的数据结构,了解了它就可以更好地理解Java虚拟机执行引擎是如何运行的。

每一个方法从调用开始至执行结束的整个过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,在同一时刻、同一条线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。虚拟机栈和栈帧的总体结构如下图:

一篇文章快速搞懂Java虚拟机的栈帧结构

接下来,再分别介绍一下栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。

局部变量表(Local Variables Table)

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

局部变量表的容量以变量槽(Variable Slot)为最小单位,每个变量槽存放一个32位数据类型,如boolean、byte、char、short、int、float和reference这几种类型。前6种类型同学们应该都了解,就不必多介绍了,reference类型表示对一个对象实例的引用,通过这个引用做到两件事情:根据引用直接或间接地查找到实例在Java堆中的数据存放的起始地或索引;根据引用直接或间接地查找到在方法区中的存储的类信息。对于64位数据类型,如long和double这两种类型,是以高位对齐的方式为其分配两个连续的变量槽空间。

使用局部变量表时,通过索引定位对应数据的位置,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,如果遇到进行这种操作的字节码,Java虚拟机就会在类加载的校验阶段中抛出异常。

当一个方法被调用时,会使用局部变量表来完成参数值到参数变量列表的传递过程。如果执行的是对象实例的成员方法(没有被static修饰的方法),那么局部变量表中第0位索引的变量槽默认就是该对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数。其余参数则按照参数表顺序排列,参数表分配完毕后,再根据方法体内部定义的局部变量顺序和作用域分配其余的变量槽。为了尽可能节省栈帧所耗的内存空间,局部变量表中的变量槽是可以重用的,当方法体中定义的局部变量超出其作用域时,该局部变量对应的变量槽就可以交给其他变量来重用。

之前的《JVM的类加载机制全面解析》中介绍过,在类加载过程中,类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予代码中定义的初始值。因此即使没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,不会产生歧义。但是局部变量不像类变量有那样的“准备阶段”,如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的。所以不要认为Java中任何情况下都存在诸如整型变量默认为0、布尔型变量默认为false等这样的默认值规则。比如:

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

因为局部变量i没有初始,在编译过程就会报错:

Error:(4, 28) java: 可能尚未初始化变量i

操作数栈(Operand Stack)

操作数栈是一个后入先出(Last In First Out,LIFO)栈。和局部变量表一样,在已经编译好的Class文件中,方法的Code属性的max_stacks数据项中,就确定了该方法所需分配的操作数栈的最大深度。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。

当一个方法刚刚开始执行的时候,该方法的操作数栈是空的,在该方法的执行过程中,会有各种字节码指令对操作数栈进行出栈和入栈的操作。比如,整数加法的字节码指令iadd,在该指令执行前必须保证操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当该指令执行时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译代码时,编译器会严格保证这一点,在类加载的校验阶段也会再次验证这一点。在上面的iadd指令中,只能用于整型数的加法,它在执行时,最接近栈顶的两个元素的数据类型必须为int型,不能出现其他数据类型使用iadd命令相加的情况。

一个方法调用另外一个方法时,可以通过操作数栈来进行方法参数的传递。虽然在Java虚拟机规范中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多Java虚拟机的实现是,都会进行一些优化:两个不同方法的栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部*部变量表重叠在一起,这样做不仅节约了一些内存空间,更重要的是在进行方法调用时就可以直接共用一部分数据,不需要进行额外的参数复制和传递,如下图:

一篇文章快速搞懂Java虚拟机的栈帧结构

动态连接(Dynamic Linking)

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

之前的《Class文件结构全面解析》中介绍过,Class文件的常量池中存有大量的符号引用,这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用(实际运行时内存布局中的入口地址),这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。关于这两个转化过程的具体过程,这里先卖个关子,后续的文章会详细介绍。

方法返回地址

方法返回时可能需要在栈帧中保存一些信息,用来于恢复调用者(调用当前方法的方法)的执行状态。一般来说,方法正常退出时,调用者的程序计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

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

附加信息

在Java虚拟机规范中,允许Java虚拟机增加一些规范里没有描述的信息到栈帧之中,比如:调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现。一般会把动态连接、方法返回地址和其他附加信息全部归为一类,称为栈帧信息。

总结

栈帧是Java虚拟机中的虚拟机栈的基本元素,每一个方法从调用开始至执行结束的整个过程,都对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址和其他附加信息。局部变量表用于存放方法参数和方法内部定义的局部变量;各种字节码指令执行时,会对操作数栈进行出栈和入栈的操作;动态连接是指向运行时常量池中该栈帧所属方法的引用;方法返回地址用于恢复调用当前方法的方法的执行状态。

上一篇:五分钟后,你将真正理解MySQL事务隔离级别!


下一篇:面试真题:求100万内的质数