执行引擎是Java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的。
在Java虚拟机规范中指定了虚拟机字节码执行引擎的概念模型,这个概念模型成为各种虚拟机执行引擎的统一外观。在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能包含几个不同级别的编译器执行引擎。但从外观上看起来,所有的Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
运行时栈帧结构
栈帧(Stack Frame)是用于支撑虚拟机进行方法调用和方法执行的数据结构,是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接的方法返回地址等信息,因为在编译程序代码的时候,这些信息都已经完全确定并写入到方法表的Code属性中,因此栈帧内存大小的分配不受程序运行期变量数据的影响。每一个方法从调用开始到执行完成的过程,就对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来讲,活动线程中,只有站定的栈帧是有效的,成为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引擎所运行的所有字节码执行都只针对当前栈帧进行操作。栈帧的概念结构如图:
局部变量表
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的最大局部变量表的容量。
操作数栈
操作数栈也常被称为操作栈,是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks数据项之中。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令向操作数栈中写入和提取内容,也就是入栈出栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期间转换为直接引用,这部分称为动态连接。
方法返回地址
一个方法被执行后,有两种方式退出:
- 正常完成出口(Normal Method Invocation Completion):执行引擎遇到任意一个方法返回的字节码指令。
- 异常完成出口(Abrupt Method Invocation Completion):方法执行过程中遇到了异常,并且此异常未在方法体内得到处理。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复其上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定,栈帧中一般不会保存这部分信息。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息。
方法调用
方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。
Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得相对复杂起来,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用。
解析(Resolution)
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析。
在Java语言中,符合“编译期可知,运行期不可变”这个要求的方法主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
与之相对应,在Java虚拟机里面提供了四条方法调用字节码指令,分别是:
- invokestatic:调用静态方法。
- invokespecial:调用实例构造器
方法、私有方法和父类方法。 - invokevirtual:调用所有的虚方法。
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器和父类方法四类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以成为非虚方法,与之相反,其他方法就成为虚方法(除去final方法)。
Java中的非虚方法除了使用invokestatic和invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在Java语言规范中明确说明的final方法是一种非虚方法。
分派(Dispatch)
解析调用一定是一个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派依据宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派四种分派情况。
静态分派
如下代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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, man!"); } public void sayHello(Woman guy) { System.out.println("hello, woman!"); } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); StaticDispatch sd = new StaticDispatch(); sd.sayHello(man); sd.sayHello(woman); } } |
运行结果:
1 2 |
hello, guy! hello, guy! |
上面代码中的“Human”称为变量的静态类型(Static Type)或者外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sd”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个镜头类型相同、实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译器可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加适合的”版本。
动态分派
动态分派与多态性的另一个重要体现“重写(Override)有着很密切的关联。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class DynamicDispatch { static abstract class Human { protected abstract void sayHello(); } static class Man extends Human { @Override protected void sayHello() { System.out.println("man say hello"); } } static class Woman extends Human { @Override protected void sayHello() { System.out.println("woman say hello"); } } public static void main(String[] args) { Human man = new Man(); Human woman = new Woman(); man.sayHello(); woman.sayHello(); man = new Woman(); man.sayHello(); } } |
运行结果:
1 2 3 |
man say hello woman say hello woman say hello |
虚拟机如何知道要调用哪个方法的?显然这里不可能根据静态类型来决定,因为静态类型都是Human的两个变量man和woman在调用sayHello()方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同。
方法调用的字节码均为 invokevirtual DynamicDispatch$Human.sayHello:()V,从字节码的角度,无论是指令(都是invokevirtual)还是参数都完全一样,但是这两条指令最终执行的目标方法并不相同,其原因需要从invokevirtual指令的多态查找过程开始说起。invokevirtual指令的运行时解析过程大致分为以下步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。
- 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的过程称为动态分派。
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个的宗量对目标方法进行选择。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
public class Dispatch { static class Obj1 {} static class Obj2 {} public static class Parent { public void method(Obj1 o) { System.out.println("Parent Obj1"); } public void method(Obj2 o) { System.out.println("Parent Obj2"); } } public static class Child extends Parent { public void method(Obj1 o) { System.out.println("Child Obj1"); } public void method(Obj2 o) { System.out.println("Child Obj2"); } } public static void main(String[] args) { Parent parent = new Parent(); Parent child = new Child(); parent.method(new Obj1()); child.method(new Obj2()); } } |
运行结果:
1 2 |
Parent Obj1 Child Obj2 |
编译阶段编译器的选择过程,即静态分派过程,选择目标方法的依据有两点:一是静态类型是Parent还是Child,而是方法参数是Obj1还是Obj2。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
运行阶段的虚拟机选择,即动态分派过程,在执行invokevirtual指令时,由于编译器已经确定目标方法的签名,这时候参数的静态类型、实际类型都不会对方法的选择构成任何影响,唯一可以影响虚拟机选择的因素只有此方法接收者的实际类型。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
基于栈的字节码解释执行引擎
许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。
解释执行
大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下面几个步骤:
基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上(部分字节码指令会带有参数)是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流里面的大部分都是零地址指令,它们依赖操作数栈进行工作。