5. 字节码执行引擎
运行时栈帧结构
“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。
一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,在编译期就已经确定了,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
对于执行引擎来说,在活动线程中,只有位于栈顶的方法才是在运行的,被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。
-
局部变量表
局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽
Variable Slot
为最小单位。Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。当调用方法是非static 方法时,局部变量表中第0位索引的 Slot 默认是用于传递方法所属对象实例的引用,即“this”关键字指向的对象。当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。
为了节省栈帧空间,局部变量表中的 Slot 是可以重用的。当离开了某些变量的作用域之后,这些变量对应的 Slot 就可以交给其他变量使用。
-
操作数栈
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。通过标准栈操作--压栈和出栈来访问。(什么是操作数,指令嘛?)操作数栈主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。方法执行中进行算术运算或者是调用其他的方法进行参数传递的时候是通过操作数栈进行的。
大多数虚拟机的实现都会进行优化,令两个栈帧出现一部分重叠。令下面的部分操作数栈与上面的局部变量表重叠在一块,这样在方法调用的时候可以共用一部分数据,无需进行额外的参数复制传递。
-
动态链接
每个栈帧包含一个指向运行时常量池中该栈帧所属方法的调用,持有这个引用是为了动态连接。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另外一部分将在每一次运行期间都转化为直接引用,这部分就称为动态连接。
-
返回类型
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是方法正常执行,在遇到返回的字节码指令时,根据需要返回相应的返回值给上层的方法的调用者,称为**“正常调用完成”;另外一种退出方式是在方法执行的过程中遇到了异常,而且没有在方法体内对异常进行妥善处理,就会导致方法退出,这种退出方法的方式称为“异常调用完成“**。
一般来说,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
方法调用
方法调用阶段的任务是确定被调用方法的版本,即是哪一个方法。
-
解析
调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来,且这个方法的调用版本在运行期是不可改变的。这类方法的调用被称为解析。
Java虚拟机支持以下5条方法调用字节码指令:
· invokestaic // 调用静态方法. · invokespecial // 调用实例构造器<init>()方法、私有方法和父类中的方法。 · invokevirtual // 调用所有的虚方法. · invokeinterface // 调用接口方法,会在运行时再确定一个实现该接口的对象。 · invokedynamic // 在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
只要能被
invokestatic
和invokespcial
调用的方法都可以在解析阶段唯一确定版本。符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final 修饰的方法,这些方法被称为 ”非虚方法“,反之其他方法被称为 ”虚方法“。解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。
-
分派
-
静态分派
/** * 静态分派 */ public class StaticDispatch { static abstract class Human { } static class Man extends Human { } static class Woman extends Human { } public void sayHello(Human guy) { System.out.println("hello,guy!"); } public void sayHello(Man guy) { System.out.println("hello,gentleman!"); } public void sayHello(Woman guy) { System.out.println("hello,lady!"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch sr = new StaticDispatch(); sr.sayHello(man); sr.sayHello(woman); } }
运行结果: hello,guys! hello,guys!
上述代码中”Human“称为”静态类型“,后面的”Man“则被称为“实际类型”或者“运行时类型”。虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。
-
动态分派
动态分派与Java的另一个特性相关联——重写。动态分派中涉及到的字节码指令为
invokevirtual
。invokevirtual
指令的运行时解析过程可以分为下面几步:- 找到操作数栈顶的第一个元素指向的对象的类型实例,记为C
- 如果在C中可以找到与常量中的描述符和简单名称都相符的方法,则进行权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则抛出异常
- 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,抛出异常。
invokevirtual
并不是把常量池中方法的符号引用解析到直接引用上就结束,而是根据接收者的实际类型来选择方法的版本,这个过程在运行期进行,是Java方法重写的本质。 -
动态分派的实现
动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,虚拟机采用了为类型在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。
如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。
具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引符号,当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按照索引转换出所需的入口地址。
-