欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

Java虚拟机(十一)------运行时栈帧结构

程序员文章站 2022-05-12 14:02:17
...

运行时栈帧结构

栈帧(Stack Frame) 是用于虚拟机执行时方法调用和方法执行时的数据结构,它是虚拟栈数据区的组成元素。每一个方法从调用到方法返回都对应着一个栈帧入栈出栈的过程。

每一个栈帧在编译程序代码的时候所需要多大的局部变量表,多深的操作数栈都已经决定了,并且写入到方发表的 Code 属性之中,一次一个栈帧需要多少内存,不会受到程序运行期变量数据的影响,仅仅取决于具体的虚拟机实现。

一个线程中方法调用可能很长,很多方法都处于执行状态。对于执行引擎来说,只有处于栈顶的栈帧才是有效的,称为当前栈帧,与之相关联的方法称为当前方法

在概念模型上,典型的栈帧主要由局部变量表、操作数栈、动态链接、返回地址组成,如下图所示:

Java虚拟机(十一)------运行时栈帧结构

接下来我们分别讲解栈帧中这四部分的具体结构。

 

1.  局部变量表

局部标量表 是一组变量值的存储空间,用于存放 方法参数 和 局部变量。在Class 文件的方法表的 Code 属性的 max_locals 指定了该方法所需局部变量表的最大容量。

变量槽 (Variable Slot)是局部变量表的最小单位,没有强制规定大小为 32 位,虽然32位足够存放大部分类型的数据。一个 Slot 可以存放 boolean、byte、char、short、int、float、reference 和 returnAddress 8种类型。其中 reference 表示对一个对象实例的引用,通过它可以得到对象在Java 堆中存放的起始地址的索引和该数据所属数据类型在方法区的类型信息。returnAddress 则指向了一条字节码指令的地址。 对于64位的 long 和 double 变量而言,虚拟机会为其分配两个连续的 Slot 空间。

虚拟机通过索引定位的方式使用局部变量表。之前我们知道,局部变量表存放的是方法参数和局部变量。当调用方法是非static 方法时,局部变量表中第0位索引的 Slot 默认是用于传递方法所属对象实例的引用,即 “this” 关键字指向的对象。分配完方法参数后,便会依次分配方法内部定义的局部变量。

为了节省栈帧空间,局部变量表中的 Slot 是可以重用的。当离开了某些变量的作用域之后,这些变量对应的 Slot 就可以交给其他变量使用。这种机制有时候会影响垃圾回收行为。

考虑下面两段代码(运行时加上 -verbose:gc参数):

代码一:

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

运行结果:

[GC 602K->378K(15872K), 0.0603803 secs]
[Full GC 378K->378K(15872K), 0.0323107 secs]
[Full GC 66093K->65914K(81476K), 0.0074124 secs]

代码二:

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

运行结果:

[GC 602K->378K(15872K), 0.0018270 secs]
[Full GC 378K->378K(15872K), 0.0057871 secs]
[Full GC 66093K->378K(81476K), 0.0054067 secs]

代码一和代码二内的placeholder变量在 System.gc() 执行后理应都被回收,可是结果却是只有代码二被回收了?

  • 因为代码一中 placeholder 虽然离开了作用域,但之后没有任何局部变量对其进行读写,也就是说其占用的 Slot 没有被复用,也就是说 placeholder 占用的内存仍然有引用指向它,因而它没有被回收。而代码二中的变量a由于复用了placeholder 的Slot ,导致 placeholder 引用被删除,因此占用的内存空间被回收。《Practical Java》一书中把”不使用的对象应手动赋值为 null “作为一条推荐的编码规则,这并不是一个完全没有意义的操作。但是不应该对 赋 null 值有过多的依赖,主要有两点原因:
  1. 从编码的角度来讲,用恰当的变量作用域来控制变量的回收才是最优雅的解决方法。
  2. 从执行角度将,使用赋值 null 的操作优化内存回收是建立在对字节码执行引擎概念模型基础上的,但是概念模型与实际执行模型可能完全不同。在使用解释器执行时,通常离概念模型还比较接近,但是一旦经过JIT 编译为本地代码才是虚拟机执行代码的主要方式,赋 null 值在JIT编译优化之后会被完全消除,这时候赋 null 值是完全没有意义的。(其实,上面代码一在 JIT 编译为本地代码后,gc() 之后内存也会被自动回收)

局部变量不像前面介绍的类变量那样存在“准备阶段”。 我们知道类变量有两次赋初始值的过程:

  • 准备阶段,赋予系统初始值;
  • 在初始化阶段,赋予程序员定义的初始值

因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。 但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,Java中不是任何情况下都存在诸如整型变量默认为0,布尔型变量默认为false等这样的默认值。 如下代码所示,这段代码其实并不能运行,还好编译器能在编译期间就检查到并提示这一点,即便编译能通过或者手动生成字节码的方式制造出下面代码的效果,字节码校验的时候也会被虚拟机发现而导致类加载失败

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

 

2.  操作数栈

操作数栈(Operand Stack)也常称为操作栈,是一个后入先出栈。在Class 文件的Code 属性的 max_stacks 指定了执行过程中最大的栈深度。Java 虚拟机的解释执行引擎称为”基于栈的执行引擎“,这里的栈就是指操作数栈。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。 例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

在概念模型中,两个栈帧是相互独立的。但是大多数虚拟机的实现都会进行优化,令两个栈帧出现一部分重叠。令下面的部分操作数栈与上面的局部变量表重叠在一块,这样在方法调用的时候可以共用一部分数据,无需进行额外的参数复制传递。

Java虚拟机(十一)------运行时栈帧结构

 

3.  动态连接

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

Class 文件中存放了大量的符号引用,字节码中的方法调用指令就是以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或第一次使用时转化为直接引用,这种转化称为静态解析。另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接

 

4.  方法返回地址

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

  • 当执行遇到返回指令,会将返回值传递给上层的方法调用者,这种退出的方式称为正常完成出口(Normal Method Invocation Completion),一般来说,调用者的PC计数器可以作为返回地址。
  • 当执行遇到异常,并且当前方法体内没有得到处理,就会导致方法退出,此时是没有返回值的,称为异常完成出口(Abrupt Method Invocation Completion),返回地址要通过异常处理器表来确定。

当方法返回时,可能进行3个操作:

  • 恢复上层方法的局部变量表和操作数栈
  • 把返回值压入调用者调用者栈帧的操作数栈
  • 调整 PC 计数器的值以指向方法调用指令后面的一条指令

 

5.  附加信息

虚拟机规范并没有规定具体虚拟机实现包含什么附加信息,这部分的内容完全取决于具体实现。在实际开发中,一般会把动态连接,方法返回地址和附加信息全部归为一类,称为栈帧信息