深入解析Java虚拟机栈帧:理解方法调用与执行的关键数据结构
什么是栈帧?

大家都知道,Java 虚拟机的内存区域被划分成了不同的部分,包括程序计数器、虚拟机栈、本地方法栈、堆以及方法区。你还不知道呢,快去看看《Java 虚拟机内存结构及编码实战》。这次要介绍的栈帧,它是 Java 虚拟机中的虚拟机栈的基本元素,同时也用于支持 Java 虚拟机进行方法调用和方法执行,了解了它就能更好地理解 Java 虚拟机执行引擎是怎样运行的。
每一个方法的调用开始到执行结束这个整个过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。栈帧会存储方法的局部变量表等信息。在同一时刻以及同一条线程里,只有位于栈顶的方法才处于运行状态,只有位于栈顶的栈帧才是有效的。执行引擎所运行的所有字节码指令,都仅仅针对当前的栈帧来进行操作。虚拟机栈和栈帧的总体结构如下图:
接下来,分别介绍栈帧中的方法返回地址的作用和数据结构。
局部变量表(Local Variables Table)
局部变量表是用于存储一组变量值的内存空间。它可以用来存放方法参数以及方法内部定义的局部变量。在已经编译好的 Class 文件中,方法的 Code 属性的 max_locals 数据项能够确定该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽为最小单位。每个变量槽可以存放一个 32 位数据类型。这些 32 位数据类型包括 boolean、byte、char、short、int、float 和 reference 这几种类型。前 6 种类型同学们都了解,所以不必多介绍。reference 类型是对一个对象实例的引用。通过这个引用可以做到两件事:一是根据引用直接或间接地查找到实例在 Java 堆中的数据存放的起始地;二是根据引用直接或间接地查找到在方法区中的存储的类信息。对于 64 位数据类型,像 double 这种类型,也是以高位对齐的方式分配变量槽空间。
使用局部变量表时,通过索引来定位对应数据的位置。索引值的范围是从 0 开始,一直到局部变量表最大的变量槽数量。如果访问的是 32 位数据类型的变量,那么索引 N 就表示使用第 N 个变量槽。如果访问的是 64 位数据类型的变量,就意味着会同时使用第 N 个和第 N+1 个变量槽。两个相邻的变量槽共同存放一个 64 位数据。虚拟机不允许以任何方式单独访问这两个相邻变量槽中的某一个。如果遇到进行这种单独访问操作的字节码,Java 虚拟机就会在类加载的校验阶段抛出异常。
当调用一个方法时,会借助局部变量表来进行参数值向参数变量列表的传递。如果执行的是未被 static 修饰的对象实例的成员方法,那么在局部变量表中,第 0 位索引的变量槽通常就是该对象实例的引用,在方法里能够通过关键字 this 来访问到这个隐含的参数。其余参数按照参数表顺序进行排列。参数表分配完后,依据方法体内部定义的局部变量顺序和作用域来分配其余的变量槽。为节省栈帧所耗内存空间,局部变量表中的变量槽可被重用。当方法体中定义的局部变量超出其作用域时,该局部变量对应的变量槽可交给其他变量来使用。
之前的《JVM 的类加载机制全面解析》介绍过,在类加载过程里,类变量存在两次赋予初始值的过程。一次是在准备阶段,会赋予系统初始值;还有一次是在初始化阶段,会赋予代码中定义的初始值。所以即便没有给类变量进行赋值,那也没有关系,类变量依然会有一个确定的初始值,不会出现歧义。局部变量不像类变量有那样的“准备阶段”。如果一个局部变量被定义了,但没有赋予初始值,那么它是完全不能被使用的。所以,不要认为在 Java 中任何情况下都存在诸如整型变量默认为 0、布尔型变量默认为 false 等这样的默认值规则。例如:
public class OneMoreStudy {
公共静态的 void 主方法(字符串数组 args)。
int i;
System.out.println(i);
}
}
因为局部变量i没有初始,在编译过程就会报错:
在代码中,可能存在变量 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 虚拟机的实现中,都会进行一些优化,即两个不同方法的栈帧会出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表相互重叠。这样做一方面节约了一些内存空间,另一方面在进行方法调用时能够直接共用一部分数据,无需进行额外的参数复制与传递,如下图所示:
动态连接(Dynamic Linking)
每个栈帧都包含一个引用,这个引用指向运行时常量池中该栈帧所属的方法。持有这个引用的目的是为了支持在方法调用过程中的动态连接。
之前的《Class 文件结构全面解析》介绍过,Class 文件的常量池中存有很多符号引用。这些符号引用,一部分在类加载阶段或者第一次使用时会被转化为直接引用,也就是实际运行时内存布局中的入口地址,这种转化被叫做静态解析。还有一部分会在每一次运行期间都转化为直接引用,这部分被称为动态连接。这两个转化过程的具体情况,在此暂不提及,后续的文章将会详细地进行介绍。
方法返回地址
方法返回时可能需在栈帧中保存某些信息,用于恢复调用者的执行状态,调用者即调用当前方法的方法。通常情况下,若方法正常退出,调用者的程序计数器的值可作为返回地址,栈帧很可能会保存此计数器值。而当方法异常退出时,返回地址需通过异常处理器表来确定,栈帧一般不会保存这部分信息。
方法返回的过程等同于把当前栈帧出栈。可能会执行以下操作:恢复调用者的局部变量表;恢复调用者的操作数栈;将返回值(若有)压入调用者栈帧的操作数栈中;调整程序计数器的值,使其指向方法调用指令后面的一条指令。
附加信息
Java 虚拟机规范允许在栈帧中增加规范未描述的信息,例如调试相关信息和性能收集相关信息等。这部分信息完全由具体的虚拟机实现决定。通常会将动态连接、方法返回地址以及其他附加信息归为一类,称作栈帧信息。
总结
栈帧是 Java 虚拟机中虚拟机栈的基本元素。每一个方法从开始调用到执行结束,整个过程都对应着一个栈帧在虚拟机栈中的操作。这个操作是从入栈到出栈的过程。栈帧存储了方法的局部变量表、操作数栈、动态连接以及方法返回地址等其他附加信息。局部变量表的作用是存放方法参数以及方法内部所定义的局部变量;在各种字节码指令执行的时候,会针对操作数栈进行出栈和入栈的操作;动态连接指向的是运行时常量池中该栈帧所属方法的引用;方法返回地址的作用是恢复调用当前方法的那个方法的执行状态。