栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区域中的虚拟机栈(Virtual Machine Stack)的栈元素。
栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址信息等。
一、概述
每一个方法从调用开始到执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并写入到方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行时变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对于当前帧进行操所。

二、局部变量表
局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译成Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,每个Slot都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress 类型的数据,与明确指出占用32位长度的内存空间不同,它允许Slot可以随着处理器、操作系统的不同而发生变化(64位虚拟机中使用64位物理内存空间实现一个Slot,需要使用对齐和补白让Slot看起来与32位机中一致)。
对于64位数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。由于局部变量表建立在线程的栈上,是线程私有的数据,所以无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引的方式使用局部变量表,索引范围是0~变量表最大Slot数。如果访问32位变量,索引n就代表第n个Slot;如果访问64位变量,则说明会同时使用n,n+1两个Slot,不允许单独访问其中一个,如果遇到这种操作,虚拟机将在类加载的验证阶段抛出异常。
为了尽可能节省空间,局部变量表的Slot是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码的PC计数器的值已经超过了某个变量的作用域,那这个变量的Slot就可以被重用。但这种设计伴随着一些副作用,有时会影响到GC。
我们在运行代码之前,先添加虚拟机的-verbose:gc运行参数,以便查看GC过程。(笔者使用的工具是IDEA)

1)Slot对GC的影响1
//case1
public class Test {
public static void main(String[] args) {
byte[] placeholder = new byte[64 * 1024 * 1024];
System.gc();
}
}

从运行结果中可以看到,系统执行了GC,但是内存并没有被回收。因为在执行System.gc()时,变量placeholder还处于作用域之中,所以没有回收placeholder所占内存。
2)Slot对GC的影响2
//case2
public class Test {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
}
System.gc();
}
}

这次,我们将变量限定在作用域中,在离开作用域后再次执行GC,但是内存仍然没有被回收。原因在于:局部变量表中的placeholder原本被占用的Slot还没有被其他变量复用。
3)Slot对GC的影响2 3
//case3
public class Test {
public static void main(String[] args) {
{
byte[] placeholder = new byte[64 * 1024 * 1024];
//placeholder=null;
}
int i = 0;
System.gc();
}
}

修改之后,我们再次运行,发现内存真的被回收了。因为变量placeholder所占用的Slot被重新复用,此时内存被回收。
至此,我们可以得出如下结论:在定义了一个占用大内存但之后不会再用的变量时,可以考虑将其手动赋值为null。但从编码角度,设置恰当的变量作用域控制变量回收时间是更好的选择,毕竟上述情况并不多见。
不使用的对象应手动赋值为null
《Prictical Java》
4)局部变量的初始化
局部变量并非和静态变量一样在初始化阶段就会被赋予初始值,因此没有被赋值的局部变量是不能运行的,即便编译器通过,字节码校验时也会被虚拟机发现而导致类加载失败。

三、操作数栈
操作数栈(Operand Stack)也常称为操作栈,它遵循栈的后入先出(Last In First Out,LIFO)规则。同局部变量表一样,操作数栈的最大深度在编译时就写入了Code属性的max_stack数据项。操作数栈的每一个元素可以是任意类型的。在方法执行的任何时候,操作数栈深度都不会超过max_stack数据项中设定的最大值。
当一个方法刚开始时,操作数栈是空的,方法执行过程中,执行出/入栈操作。例如,整数加法的字节码指令iadd在运行时,操作数栈中最接近栈顶的两个元素已经存入了int型数据,执行指令时,两个int值出栈并相加,然后将相加的值入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译时,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证。例如上述的iadd指令执行时,最接近栈顶的两个元素不能出现一个int,一个float相加的情况。
概念模型中,两个栈帧是操作数栈中完全独立的两个元素。但大多数虚拟机的实现里都会做一部分优化,另两个栈帧出现一部分重叠(共享)区域。让下面栈帧的部分操作数栈和上面栈帧的局部变量表重叠,这样进行方法调用时就可以公用一部分数据,无需进行额外的参数复制传递。
四、动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用视为了支持方法调用中的动态连接(Dynamic Linking)。常量池中有大量的符号引用,在每次运行期间转化为直接引用的部分,被称为动态连接。
五、方法返回值
一个方法执行后,只有两种退出方式:
- 执行引擎遇到任意一个方法返回的字节码指令,称为正常完成出口(Nomal Method Invocation Completion),有可能有返回值
- 执行过程中遇到异常,称为异常完成出口(Abrup Method Invocation Completion),不会有返回值
一般来说,正常退出时,调用者PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器的值。异常退出时,返回地址要通过异常处理器来确定,栈帧中一般不会保存这部分信息。
六、附加信息
不同的Java虚拟机实现中会增加一些规范里没有描述的信息到栈帧之中,如调试相关信息。实际开发中,一般会把动态连接、方法返回地址与其他附加信息统称为“栈帧信息”。