运行时栈帧结构
概述:
- 栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区中的虚拟机栈的栈元素,栈帧存储了方法的局部变量,操作数栈,动态连接和方法返回值等信息,每个方法从调用开始到执行完成的过程都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
- 一个线程中的方法调用链会很长,只有位于栈顶的栈帧才有效,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行所有字节码指令都只针对当前栈帧进行操作。
局部变量表
- 局部变量表是一组变量存储空间,用于存放方法参数和方法内部定义的局部变量。
- 在Java程序编译为class文件时就在方法的code属性的max_locals数据项中确定该方法所需要分配的局部变量表的最大容量。局部变量表的容量以变量槽为最小单位。
- 虚拟机通过索引定位的方式使用局部变量表,索引值的范围从0开始至局部变量表最大Slot数量。
- 在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果是实例方法(非static的方法),那么局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
- 局部变量表中的Slot是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省栈空间,在某些情况下Slot的复用会直接影响到系统的垃圾收集行为。
- 局部变量没有默认值,需要初始化,否则这段代码在java中不能运行。
1 | /** |
运行结果:
1 | [GC (System.gc()) 68864K->66256K(125952K), 0.0020403 secs] |
操作数栈
- 也称为操作栈,他是一个后入先出栈的栈,同局部变量一样,操作数栈的最大深度也在编译的时候写入到了code属性的max_stacks数据中,在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
- 当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈出栈操作。
- Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
动态连接
- 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
- 字节码中的方法调用指令就以常量池中指向方法的符号引用为参数,这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
- 第一种退出方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。
- 另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
- 方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。
- 方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息
- 虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息。
- 一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用那一个方法),暂时还未涉及方法内部的具体运行过程。
在程序运行时,进行方法调用是最普遍、最频繁的操作之一。
解析调用
- 解析就是将方法的符号引用转化成直接引用,解析的前提是方法须在方法运行前就确定一个可调用的版本,并且这个版本在运行阶段是不可改变的(编译期可知,运行期不可变)。
- 只有用
invokestatic
和invokespecial
指令调用的方法,都可以在解析阶段确定调用版本,符合此条件的有静态方法,私有方法,实例构造器和父类方法四类,再加上被final
修饰的方法(尽管它使用invokevirtual
指令调用)。它们在类加载时即把符号引用解析为该方法的直接引用,这些方法可以称为非虚方法,其他防备被称为虚方法。 - 解析调用是一个静态过程,编译期间就可以确定,分派调用可能是静态的也可能是动态的,是实现多态性的体现。
静态分派
为了解释静态分派和和重载(Overload),请看以下代码:
1 | public class StaticDispatch { |
运行结果:
1 | hello guy |
对代码
Human man = new Man()
来说,“Human”称为变量的静态类型,后面的“Man”称为变量的实际类型。虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。
静态分派的最典型应用表现就是方法重载。
静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
解析与分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。例如,前面说过,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的
编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本,看以下代码:
1 | public class Overload { |
运行结果:
1 | hello char |
动态分派
它与Java语言多态性的另一个重要体现–重写(Override)有着很密切的关联。用一个例子来讲解:
1 | public class DynamicDispatch { |
运行结果:
1 | man say hello |
动态分派的关键是 invokevirtual指令, 它的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
单分派和多分派
- 方法的接收者与方法的参数统称为方法的宗量
- 根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
用一个例子来讲解:
1 | public class Dispatch { |
运行结果:
1 | father choose 360 |
我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行“son.hardChoice(new QQ())”这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
虚拟机动态分派的实现
- 由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Inteface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。
- 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
基于栈的字节码解释执行引擎
请看P329的例子