字节码执行引擎是Java虚拟机核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,而虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件制约地定制指令集与执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,还可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。但从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
执行引擎所运行的所有字节码指令都只针对当前线程的当前栈帧(栈顶栈帧)进行操作。
执行引擎被称为“基于栈的执行引擎”,里面的“栈”就是操作数栈。
方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作之一。一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
解析
调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析。
静态方法与类型直接关联,私有方法在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
调用不同类型的方法,字节码指令集里设计了不同的指令:
- invokestatic:用于调用静态方法
- invokespecial:用于调用实例构造器()方法、私有方法和父类中的方法(通过super关键字调用)
- invokevirtual:用于调用所有的虚方法
- invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法(比如解析Lambda表达式)。
invokedynamic:每一处含有invokedynamic指令的位置都被称作“动态调用点”。这条指令的第一个参数是CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。以新建线程的Lambda语句为例:
new Thread(()->{});
通过invokedynamic获取到Thread的构造器参数为Runnable类型的实例。
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。
分派
静态分派(重载)
对于这样一段代码:
public class Main {
public void judge(List<?> list) {
System.out.println("这是一个 List");
}
public void judge(ArrayList<?> list) {
System.out.println("这是一个 ArrayList");
}
public void judge(LinkedList<?> list) {
System.out.println("这是一个 LinkedList");
}
public static void main(String[] args) {
Main m = new Main();
List<?> arrayList = new ArrayList<>();
List<?> linkedList = new LinkedList<>();
m.judge(arrayList);
m.judge(linkedList);
}
}
它的输出结果是什么?
我们把上面代码中的“List”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type),后面的“ArrayList”和“LinkedList”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类型”(Runtime Type)。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译期是可知的;而实际类型变化的结果在运行期才能确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
JVM在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了judge(List)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的(或者说是由javac编译器完成)。
javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一”的,往往只能确定一个“相对更合适的”版本。产生这种模糊结论的主要原因是字面量天生的模糊性,它不需要定义,所以字面量就没有显式的静态类型,它的静态类型只能通过语言、语法的规则去理解和推断。
比如说这段代码:
public class Main {
public static void sayHello(char arg) {System.out.println("hello char");}
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 main(String[] args) {sayHello('a');}
}
main()方法中的sayHello()会优先匹配参数类型为char的重载方法,如果注释掉这个方法,由于char类型可以向上转型为int类型并继续向上转型为long类型,因此sayHello()会依次去寻找int类型的重载方法和long类型的重载方法:
动态分派(重写)
对于这段代码:
public class Main {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
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、woman)的实际类型不同。那么HVM是如何根据实际类型来分派方法执行版本的呢?
invokevirtual指令的运行时解析过程大致分为以下几步:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常
- 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程
- 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常
来看这段代码的字节码:
0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中。
16行是负责将创建好的Human类的对象从局部变量表中压入栈顶,17行则是通过invokevirtual进行解析,先找到操作数栈顶的元素所指向对象的实际类型,这里为Man,而且找到了与常量池中描述符和简单名称都相符的方法sayHello()并且通过了权限校验,返回Man类中sayHello()方法的直接引用。
同理,20行、21行也算是通过invokvirtual解析最终找到了Woman类的sayHello()方法。
正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
基于栈的解释器执行过程
对于下面这段代码;
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
使用javap
编译后的部分代码:
public int calc();
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
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
LineNumberTable:
line 3: 0
line 4: 3
line 5: 7
line 6: 11
descriptor: ()I
表示返回值为int类型,(括号里面为参数列表的数据类型,这里没有)
flags: (0x0001) ACC_PUBLIC
表示这是一个public实例方法
stack=2, locals=4, args_size=1
这句话说明这段代码需要深度为2的操作数栈和4个变量槽的局部变量空间,所需形参参数的数量为1(实例方法的第一个形参为this,所以这里虽然括号里面没有形参,args_size仍然为1)
LineNumberTable
表示源码和这里的字节码指令的行号对应
接下来看字节码的执行流程:
- 首先,执行偏移地址为0的指令,bipush指令的作用是将单字节的整型常量值(-128~127)压入操作数栈顶,跟随有一个参数,指明推送的常量值,这里是100:
- 执行偏移地址为2的指令,istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量槽中。后续4条指令(3、6、7、10行)都是做一样的事情(sipush就是将short类型的整数压入栈顶),也就是在对应代码中把变量a、b、c赋值为100、200、300。这4条指令的图示略过:
- 执行偏移地址为11的指令,iload_1指令的作用是将局部变量表第1个变量槽中的整型值复制到操作数栈顶:
- 执行偏移地址为12的指令,iload_2指令的执行过程与iload_1类似,把第2个变量槽的整型值入栈:
- 执行偏移地址为13的指令,iadd指令的作用是将操作数栈中头两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd指令执行完毕后,栈中原有的100和200被出栈,它们的和300被重新入栈:
- 执行偏移地址为14的指令,iload_3指令把存放在第3个局部变量槽中的300入栈到操作数栈中。这时操作数栈为两个整数300:
下一条指令imul是将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果90000重新入栈,栈顶元素为90000。 - 执行偏移地址为16的指令,ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给该方法的调用者。到此为止,这段方法执行结束: