概述
执行引擎是Java虚拟机最核心的组成部分之一,虚拟机可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
执行引擎在执行代码时,可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至包含几个不同级别的编译器执行引擎。但从外观来看,所有Java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码的等效过程,输出的是执行结果。
运行时栈帧(Stack Frame)结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素,栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息,每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。在编译期,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧关联的方法称为当前方法,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表(Local Variable Table)
局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Class文件的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,一个Slot可以存放一个32位以内的数据类型,有boolean、byte、char、short、int、float、reference和returnAddress八种类型。reference类型表示引用,虚拟机规范没有指定它的长度和结构,但引用应该有以下两个作用:一是从此引用中直接或间接的查找到对象在Java堆中的数据存放的起始地址索引,二是此引用中直接或间接的查找到对象所属数据类型在方法区中的存储的类型信息。returnAddress已经很少见了,他是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,以前虚拟机用这几条指令实现异常处理,现在由异常表代替。
64位的数据类型由long和double,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表,索引值范围从0开始到局部变量表最大的Slot数量,如果访问32位变量,索引n代表第n个slot,如果是64位,会使用n和n+1两个slot。对于两个相邻的共同存放一个64位数据的两个Slot,不允许采用任何方式单独访问其中的某一个,否则在类加载阶段抛出异常。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static方法),局部变量表中第0位索引的Slot默认值是用于传递方法所属的对象实例的引用,在方法中可通过this访问隐含参数。其余参数按照参数表顺序排列,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。
Slot是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。不过,这种复用也会影响到垃圾收集的行为。
局部变量没有类变量的准备阶段,所以必须为局部变量赋初始值。
操作数栈(Operand Stack)
操作数栈的最大深度也是在编译的时候写入到Code属性的max_stacks数据项中,操作数栈中的每一个元素可以是任意的Java数据类型,包括long和double,32位数据占栈容量是1,64是2。在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈/出栈操作。在做算术运算是通过操作数栈来进行的,在调用其它方法时是通过操作数栈来进行参数传递的。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
动态连接(Dynamic Linking)
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件常量池中有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化成直接引用,这种转化称为动态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
- 第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者,是否有返回值和返回值的类型将根据遇到何种方法来返回指令来决定,这种退出方式称为正常完成出口;
- 另一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个技术值。而方法异常退出时,返回地址是通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就是当前栈帧出栈,退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。
方法调用
方法调用阶段唯一的任务是确定被调用方法的版本,暂时不涉及方法内部的具体运行过程。
解析
在类加载的解析阶段,会将一部分符号引用转化为直接引用,这种解析能成立的前提是方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。这些方法主要包括静态方法和私有方法。这两种方法不可能通过继承或别的方式重写其他版本,适合在类加载阶段进行解析。
与之相对应的,虚拟机有5条方法调用字节码指令:
- invokestatic:调用静态方法;
- invokespecial:调用实例构造器方法、私有方法和父类方法;
- invokevirtual:调用所有的虚方法;
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,该指令分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic和invokespecial调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,他们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法称为非虚方法,其他方法称为虚方法(除了final方法)。
public class StaticResolution {
public static void sayHello(){
System.out.println("hello");
}
}
public static void main(String[] args) {
sayHello();
}
在这段代码中的Class文件中,sayHello方法确实是通过invokestatic调用的。
final方法是使用invokevirtual调用的,但他无法被覆盖,没有其他版本,所以是一种非虚方法。
解析调用一定是一个静态的过程,在编译期间完全确定,在解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。
分派(Dispatch)
静态分派
public class StaticDiapatch {
static abstract class Human{}
static class Man extends Human{}
static class Woman extends Human{}
public void sayHello(Human human){
System.out.println("Hello, guy!");
}
public void sayHello(Man man){
System.out.println("Hello, gentleman!");
}
public void sayHello(Woman woman){
System.out.println("Hello, lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDiapatch sr = new StaticDiapatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
运行结果如下:
Hello, guy!
Hello, guy!
在“Human man = new Man()”这句代码中,Human称为变量的静态类型,Man称为变量的实际类型,静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的。实际类型变化的结果在运行期才可确定,编译器在编译时并不知道一个对象的实际类型是什么。
编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的,并且静态类型是编译期可知的,在编译阶段,Javac编译器会根据参数的静态类型决定使用那个重载版本,所以选择了Human作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派,静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个更加合适的版本。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。下面是一个重载的例子:
public class Overload {
public static void sayHello(Object arg){
System.out.println("hello Object");
}
public static void sayHello(int arg){
System.out.println("hello int");
}
public static void sayHello(long arg){
System.out.println("hello long");
}
public static void sayHello(Character arg){
System.out.println("hello Character");
}
public static void sayHello(char arg){
System.out.println("hello char");
}
public static void sayHello(char... arg){
System.out.println("hello char ...");
}
public static void sayHello(Serializable arg){
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}
}
因为‘a’是一个char,所以输出“hello char”,但他也可以是数字97,注释掉sayHello(char arg)方法输出变为“hello int”。注释掉sayHello(int arg)方法,输出变为“hello long”。
在上面的过程中,发生了两次类型转换,由char转为int再转为long,实际上自动转型还能发生多次,它们的顺序为:char->int->long->float->double,但不会转型到byte和short,因为char转为byte和short是不安全的。
注释掉sayHello(long arg)之后,输出变为“hello Character”。这时发生了一次自动装箱,继续注释掉Character后,输出变为“hello Serializable”,这是因为Serializable是Character的一个接口,自动装箱后找不到装箱类,但找到了装箱类实现的接口类型,所以又发生一次自动转型。Character还实现了Comparable接口,如果两个接口同时出现,编译器无法确定自动转型为哪个类型,拒绝编译,需要显式指定才能编译通过。注释掉Serializable后,输出变为“hello Object”,这是因为char自动装箱后转型为父类,如果有多个父类,越接近上层的父类优先级越低。把sayHello(Object arg)注释掉,结果变为“hello char …”。由此可见,可变长参数的重载优先级是最低的,这时’a’被当做一个数组元素。
动态分派
动态分派和重写有很密切的关联。
package classloadtest;
/**
* @author Zhang
* @date 2018/8/21
* @Description
*/
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();
}
}
输出结果为:
man say hello
woman say hello
woman say hello
从结果看来,是Java虚拟机根据实际类型来分派方法执行版本的。下面是main()方法的字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class classloadtest/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method classloadtest/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class classloadtest/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method classloadtest/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method classloadtest/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method classloadtest/DynamicDispatch$Human.sayHello:()V
24: new #4 // class classloadtest/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method classloadtest/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method classloadtest/DynamicDispatch$Human.sayHello:()V
36: return
LineNumberTable:
line 29: 0
line 30: 8
line 31: 16
line 32: 20
line 33: 24
line 34: 32
line 35: 36
LocalVariableTable:
Start Length Slot Name Signature
0 37 0 args [Ljava/lang/String;
8 29 1 man Lclassloadtest/DynamicDispatch$Human;
16 21 2 woman Lclassloadtest/DynamicDispatch$Human;
}
第16和20句是将两个对象引用压入栈顶,这两个对象称为接收者。17和21是方法调用指令,注释虽然显示执行的是Human的方法,但这两句指令最终执行的目标并不相同。原因是invokevirtual的动态查找,invokevirtual运行时解析分为以下几步:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C;
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束,如果不通过,返回IllegalAccessError异常;
- 否则,按照继承关系从下往上依次对C的父类进行第二步的搜索和验证过程;
- 如果找不到合适的方法,抛出AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用都把类方法符号引用解析到了不同的直接引用上,这个过程是Java方法重写的本质。这种运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
public class Dispatch {
static class QQ{}
static class _360{}
public static class Father{
public void hardChoice(QQ arg){
System.out.println("father choose QQ");
}
public void hardChoice(_360 arg){
System.out.println("father choose 360");
}
}
public static class Son extends Father{
@Override
public void hardChoice(QQ arg) {
System.out.println("son choose QQ");
}
@Override
public void hardChoice(_360 arg) {
System.out.println("son choose 360");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}
输出结果:
father choose 360
son choose QQ
在上面的代码中,包含了静态分派和动态分派的过程。
- 静态分派过程中选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360.这次选择的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)和Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所有Java静态分派属于多分派类型;
- 运行期,在执行son.hardChoice时,invokevirtual指令不关心参数类型,唯一可以选择的此方法的实际接收者,只有一个宗量作为依据,属于单分派类型。
Java语言是一门静态多分派、动态单分派的语言。
虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中机遇性能的考虑,大部分的实现都不会真正的进行如此频繁的搜索。最常用的稳定优化手段是为类在方法区建立一个虚方法表,在invokeinterface执行时也会用到接口方法表,使用虚方法表所引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在子类、父类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
除方法表外,还可以使用内联缓存和基于类型继承关系分析技术的守护内联两种非稳定的激进优化来获得更高的性能。
动态类型语言支持
JDK7增加了指令invokedynamic,是实现动态类型支持的改进之一,也为lambda表达式做技术准备。
动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,包括Groovy、JavaScript、PHP、Python、Ruby等。在编译期就进行类型检查的静态类型语言有C++、Java等。
Java语言在编译期会将方法完整的符号引用生成出来,作为方法调用指令的参数存储到Class文件中,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。而在动态类型语言中,编译时最多只能确定方法名称、参数和返回值,不会确定方法接收者。
静态语言可以提供严谨的类型检查,动态语言可以提供更大的灵活性,提高开发效率。
java.lang.invoke包
JDK1.7实现了JSR-292,invoke包就是JSR-292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。类似于C语言中的函数指针。以前Java的做法是在函数值传递一个接口,以实现了该接口的类的对象作为参数
public class MethodHandleTest {
static class ClassA{
public void println(String s){
System.out.println(s);
}
}
public static void main(String[] args) throws Throwable{
Object obj = new ClassA();
//obj.println(); //这句类型检查不合法,Object里就没有这个方法
getPrintlnMH(obj).invokeExact("lalala"); //这句可以调用到
}
public static MethodHandle getPrintlnMH(Object receiver)throws Throwable{
MethodType mt = MethodType.methodType(void.class, String.class); //方法类型,第一个参数是返回值,第二个和以后的是具体参数
return lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver); //第一个参数是接收者,第二个是方法名,第三个是方法类型
}
}
在上面的代码中,通过MethodHandle正确调用了println方法。实际上,getPrintlnMH方法中模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个具体方法来实现。这个方法本身的返回值,可以视为对最终调用方法的一个引用。这样,可以在函数中把MethodHandel作为参数。
MethodHandle与反射的区别
- 从本质上讲,反射与MethodHandle都是在模拟方法调用,但反射在模拟Java代码的层次调用,而MethodHandle在模拟字节码层次调用。在MethodHandle.lookup中的三个方法——findStatic()、findVirtual()、findSpecial()正是为了对应invokestatic、invokevirtual&invokeinterface、invokespecial这几条字节码指令的权限校验行为,而这些底层细节在使用Reflection API时是不需要关心的。
- 反射中的Method对象远比MethodHandle对象中包含的信息多,前者是方法在Java一端的全面映像,后者仅包含与执行该方法相关的信息。
- MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化,在MethodHandle上也应当可以采用类似思路去支持,而通过反射去调用方法则不行;
- 反射只服务于Java语言,而MethodHandle可服务于运行于虚拟机上的所有语言。
invokedynamic指令
在某种程度上,invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有invoke指令方法分派规则固化在虚拟机中的问题,把如何查找目标决定权从虚拟机转嫁到具体用户代码之中,让用户有更高的自由度。
每一处含有invokedynamic指令的位置都称作“动态调用点”(Dynamic Call Site),这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到三项信息:引导方法(Bootstrap Method)、方法类型(MethodType)和名称。引导方法是固有的参数,返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。
public class InvokeDynamicTest {
public static void main(String[] args) {
Runnable r = ()->{
};
}
}
在上面的代码中,用lambda表达式生成一条invokedynamic。在Class文件中,对应常量池CONSTANT_InvokeDynamic_info类型常量。
掌控方法分派规则
下面是一个调用祖父类方法的例子:
public class GrandFatherClassTest {
class GrandFather{
void thinking(){
System.out.println("I'm grandfather");
}
}
class Father extends GrandFather{
void thinking(){
System.out.println("I'm father");
}
}
class Son extends Father{
@Override
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
MethodHandle mh = lookup().findSpecial(GrandFather.class, "thinking",mt, getClass());
mh.invoke(this);
}catch (Throwable e){}
}
}
public static void main(String[] args) {
(new GrandFatherClassTest().new Son()).thinking();
}
}
如上代码,用MethodHandle找到指定的类型来调用它的方法。
基于栈的字节码解释执行引擎
许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行和编译执行两种选择。
解释执行
不管是解释还是编译,程序代码到物理机目标代码(编译执行)或虚拟机能执行的指令集(解释执行)步骤如下:
Java语言就是把抽象语法树之前的步骤实现为一个半独立的编译器。
Java语言中,Javac编译器完成了词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分是在虚拟机之外进行的,而解释器在虚拟机内部,所以Java程序编译是半独立的实现。
基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Architecture,ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,比如x86二进制指令集。
基于栈的指令集主要的优点是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的要受到硬件约束,使用栈架构的指令集,可以由虚拟机实现来自行决定把一些访问最频繁的数据放到寄存器中以获取尽量好的性能。栈架构的指令集还有一些其他优点,如代码相对更加紧凑、编译器实现更加简单。但也有缺点,执行速度会慢一些。