栈帧是用于虚拟机进行方法调用和方法执行的数据结构,是虚拟机栈的栈元素。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧需要多大的局部变量表,多深的操作数栈都已经完全确定,并且写入到方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
一、局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量。
变量槽 (Variable Slot)是局部变量表的容量的最小单位,虚拟机规范没有明确规定其占用的内存空间大小,一个 Slot 可以存放一个32位以内的数据类型: boolean、byte、char、short、int、float、reference 和 returnAddress 8种类型。其中 reference 表示对一个对象实例的引用,通过它可以得到对象在Java 堆中存放的起始地址的索引和该数据所属数据类型在方法区的存储的类型信息。returnAddress 则指向了一条字节码指令的地址。 对于64位的 long 和 double 变量而言,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间(由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题)。
虚拟机通过索引定位的方式使用局部变量表。索引值的范围是0到局部变量表最大的Slot数量。如果访问32位数据类型的变量,索引n就代表了使用第n个Slot。如果是64位数据类型的变量,则说明会同时使用n和n+1两个Slot,对于这两个Slot,不允许采用任何方式单独访问其中的某一个。
在方法执行时,如果执行的是实例方法(非static方法),那局部变量表的第0位索引的Slot默认是用于传递方法所属对象实例的引用,即this关键字指代的对象的引用。然后第1个索引Slot开始用于方法的参数表,再接着是根据方法体内部定义的变量顺序和作用域分配其余的Slot。
为可能节省栈帧空间,局部变量表中的Slot是可以复用的,方法体中定义的变量的作用于不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过某个变量的作用于,那这个变量对应的Slot就可以被其他变量复用。Slot复用可能会影响到垃圾回收。如下所示(在虚拟机运行参数加上-verbose:gc)
代码一:
结果一:
----------------------------------------------------------------分割线--------------------------------------------------------------------------
代码二:
结果二:
从两个代码来看,照理来说两个placeholder在离开他的作用域之后,应该会成为一个"死对象"而被回收掉,但是根据结果来,代码一没被回收二代码二被回收了。
所以,placeholder对象能否被回收的根本原因是:局部变量表中的Slot是否还存在关于placeholder数据对象的引用。代码一中虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量复用,所以作为GC Roots一部分的局部变量表还保持着对placeholder的关联。而代码二中的a复用了placeholder的Slot,使得placeholder跟局部变量表的关联被打断,所以也就被回收。
可以用手动赋值不使用的变量为null,用来代替那句int a = 0,把变量对应的局部变量表Slot清空(《Pratice Java》中把“不适用的对象手动赋值为null”作为一条推荐的编码规则)。但是不应该对 赋 null 值有过多的依赖,主要有两点原因:
1、从编码的角度来讲,用恰当的变量作用域来控制变量的回收才是最优雅的解决方法。
2、从执行角度讲,使用赋值 null 的操作优化内存回收是建立在对字节码执行引擎概念模型基础上的,但是概念模型与实际执行模型可能完全不同。在使用解释器执行时,通常离概念模型还比较接近,但经过JIT 编译后,才是虚拟机执行代码的主要方式,赋 null 值在JIT编译优化之后会被完全消除,这时候赋 null 值是完全没有意义的。(其实,上面代码一在 JIT 编译后,System.gc() 执行时就可以正确地回收掉内存,无需写成代码二的样子)
另外,局部变量不像类变量一样存在“准备阶段”,所以一个局部变量定义了但没有赋初始值是不能使用的。
二、操作数栈
操作数栈和普通的栈(后入先出)一样,只不过他是用来存放操作数以及操作结果的。Java 虚拟机的解释执行引擎称为”基于栈的执行引擎“,这里的栈就是指操作数栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中,32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2,在方法执行的任何时候,操作数栈的深度,都不会超过在max_stacks数据项中设定的最大值。
在做算术运算又或者是调用其他的方法进行参数传递的时候是通过操作数栈进行的。
在概念模型中,两个栈帧作为虚拟机栈的元素,是相互独立的。但是大多数虚拟机的实现都会进行优化,令两个栈帧出现一部分重叠。令下面的部分操作数栈与上面的局部变量表重叠在一块,这样在方法调用的时候可以共用一部分数据,无须进行额外的参数复制传递。如图
三、动态连接
每个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
四、方法返回地址
当一个方法开始执行以后,只有两种方法可以退出这个方法:
1、当执行遇到任意一个方法返回的字节码指令,可能会将返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来定,这种退出的方式称为正常完成出口,一般来说,调用者的PC计数器可以作为返回地址。
2、当执行遇到异常,并且这个异常在方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口,返回地址要通过异常处理器表来确定。
当方法退出时,可能进行3个操作:
1、恢复上层方法的局部变量表和操作数栈。
2、把返回值压入调用者调用者栈帧的操作数栈。
3、调整 PC 计数器的值以指向方法调用指令后面的一条指令。
五、附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中(比如与调试相关的信息),这部分的信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接,方法返回地址和其他附加信息全部归为一类,称为栈帧信息。