JVM理论:(三/3)运行时栈帧结构、基于栈的字节码解释执行过程

一、栈帧结构

  讲栈帧结构有必要回顾一下前文Class文件中的Code属性结构,如下图。

  

  栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机栈的栈元素。每一个方法从调用开始到执行完成,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。每一个栈帧都包括了局部变量表,操作数栈,动态连接,方法返回地址和一些额外的附加信息

  在编译阶段,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响。

  一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态,对于执行引擎来讲,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法(例如一个方法调用另一个方法,但只有当前方法是有效的)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。栈帧结构如下图。

  

1、局部变量表

  局部变量表用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的局部变量表的最大容量。

  局部变量表的容量以Slot为最小单位,一个Slot 可以存放一个32位以内的数据类型,Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference 和 returnAddress这8种,64位的数据类型只有long和double两种,对于64位的数据类型会被以高位对齐的方式分配两个连续的Slot空间。

  (其中第7种reference类型表示对一个对象实例的引用,虚拟机实现要能通过这个引用做到两点,一是能从此引用中查找到对象在Java堆中的数据存放的起始地址索引,二是能查找到对象所属数据类型在方法区中的存储的类型信息。即如果局部变量是引用类型,要能通过这个引用不仅要能找到对象在堆中的位置,还要能找到对象所属类的信息。

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

  在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

  为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,代码虽然已经离开了某变量的作用域,但在此之后,没有任何对局部变量表的读写操作,变量原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联,这种关联没有被及时打断,在绝大部分情况下影响都很轻微,但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为null值便不见得是一个绝对无意义的操作。

  局部变量必须要赋初始值。局部变量不像类变量那样存在“准备阶段”。类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,字节码校验的时候会被虚拟机发现而导致类加载失败。

2、操作数栈

  回顾一下,在前面说明加载和存储的字节码指令时,知道加载和存储指令是用于将数据在栈帧中的局部变量表和操作数栈之间来回传输。操作数栈很像是某个方法里局部变量表的变量进行运算的场所,每个栈帧里都有一个操作数栈。

  操作数栈也常称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

   当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。且操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,例如还是使用iadd时,最接近栈顶的两个元素的数据类型必须为int型,不能出现一个long和一个float使用iadd命令相加的情况。在类校验阶段的数据流分析中还要再次验证这一点。

  另外,在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。

  Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

   

 3、动态连接

   每个栈帧都包含一个指向运行时常量池该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。通过动态连接可以知道该栈帧所属方法的引用,选取具体版本的方法进行调用参考JVM理论:(二/5)方法调用

 4、方法返回地址

  当一个方法开始执行后,只有两种方式可以退出这个方法。

  第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口。

  另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

  无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。

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

 5、附加信息

  虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧中。

二、基于栈的字节码解释执行引擎

  虚拟机是如何执行方法中的字节码指令的?  

  Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,不存在显式的参数,它们依赖操作数栈进行工作

  下面通过一段代码样例来描述这段代码在虚拟机中实际是如何执行的。

public int calc(){
    int a=100int b=200int c=300return(a+b)*c;
}

使用javap命令得到的字节码指令如下
public int calc();
Code:
Stack=2,Locals=4,Args_size=1
0:bipush 100
2:istore_1
3:sipush 200
6:istore_2
7:sipush 300
10:istore_3
11:iload_1
12:iload_2
13:iadd
14:iload_3
15:imul
16:ireturn

  javap提示这段需要栈深度为2的操作数栈(Stack=2)和4个Slot的局部变量空间(Locals=4)。

(1)首先执行偏移地址为0的指令,bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,跟随有一个参数,指明推送的常量值,这里是100。

  

(2)执行偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量Slot中。后面4条指令(直到偏移为11的指令为止)都是做同样的事情,也就是在对应代码中把变量a、b、c赋值为100、200、300。

   

(3)执行偏移地址为11的指令,iload_1指令的作用是将局部变量第1个Slot中的整型值复制到操作数栈顶。

   

(4)执行偏移地址12的指令,iload_2指令的执行过程与iload_1类似,把第2个Slot的整型值入栈。

   

(5)执行偏移地址为13的指令,iadd指令的作用是将操作数栈中前两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200出栈,它们相加后的和300重新入栈。

   

(6)执行偏移地址为14的指令,iload_3指令把存放在第3个局部变量Slot中的300入栈到操作数栈中。这时操作数栈为两个整数300。下一条偏移地址为15的指令imul是将操作数栈中前两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,与iadd指令执行过程完全类似。

   

(7)偏移地址为16的指令,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给此方法的调用者。到此为止,该方法执行结束。

   

 参考链接:

  https://www.cnblogs.com/wade-luffy/p/6058067.html

  http://chenzhou123520.iteye.com/blog/1607270

猜你喜欢

转载自www.cnblogs.com/zjxiang/p/9218156.html