虚拟机字节码执行引擎

读《深入理解Java虚拟机》记录
所谓的「虚拟机字节码执行引擎」其实就是 JVM 根据 Class 文件中给出的字节码指令,基于栈解释器的一种执行机制。通俗点来说,也就是 JVM 解析字节码指令,输出运行结果的一个过程。

运行时栈帧结构

详见:https://linxiaobaixcg.github.io/2020/10/29/Java%E8%BF%90%E8%A1%8C%E6%97%B6%E6%95%B0%E6%8D%AE%E5%8C%BA%E5%9F%9F/

方法调用

Class文件的编译过程并不包含传统编译的连接阶段,Class文件中方法都是以符号引用的形式存储的,而不是方法的入口地址。这个特性使得Java具有强大的动态扩展的能力,但同时也增加了Java方法调用过程的复杂性,因为方法需要在类加载期间甚至是运行时才能确定真正的入口地址,即将符号引用转换为直接引用。

这里所说的方法调用并不等同于方法执行,这个阶段的唯一目的就是确定被调用方法的版本,还不涉及方法内部的具体运行过程。对于方法的版本,需要解释的就是由于重载与多态的存在,一个符号引用可能对应多个真正的方法,这就是方法的版本。

解析

解析的过程就是在类加载过程中的解析阶段。在类加载过程中,我们知道解析阶段就是将符号引用转换为直接引用的过程,那个时候的解析阶段解析了类或接口、字段、类方法和接口方法。在这个阶段,会将Class文件中的一部分方法的符号引用解析为直接引用,这种解析能够成立的条件是,方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的电泳版本在运行期间是不变的。也就是说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。

在Java虚拟机中提供了5条方法调用的字节码指令,分别是:

  • invokestatic:调用静态方法;
  • invokespecial:调用实例构造器方法、私有方法和父类方法;
  • invokevirtual:调用所有的虚方法;
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象;
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑都是固化在Java虚拟机中的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

解析调用是一个静态的过程,在编译期间就已经完全确定,在类加载的解析阶段就会把涉及到的符号引用转化为直接引用,不会延迟到运行期再去完成。而分派调用则既可能是静态的也可能是动态的,根据分派的宗量数可以分为单分派和多分派,这两类分派方法的两两组合就构成了静态单分派、静态多分派、动态单分派和动态多分派四种。

分派

Java是一门面向对象的语言,它具备三个主要的面向对象特征:继承、封装和多态。正是由于多态的存在,使得在判断方法调用的版本的时候会存在选择的问题,这也正是分派阶段存在的原因。这一部分会在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
public class StaticDispatch {
static 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();
StaticDispatch sr=new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}

运行结果如下:

hello,guy!
hello,guy!

这是考察多态的经典问题。要想了解这个问题的本质,需要知道这两个概念:静态类型和实际类型。

什么是静态类型?静态类型可以理解为变量声明的类型,比如上面的man这个变量,它的静态类型就是Human。而实际类型就是创建这个对象的类型,man这个变量的实际类型就是Man。这两种类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会发生变化,并且最终的静态类型是编译期间可知的。而实际类型变化的结果在运行期才可以确定,编译器在编译程序时并不知道一个对象的实际类型是什么。

动态分派

在了解了静态分派后,再看看动态分派的过程,它和多态性的另一个重要的特性重写有关。下面用一个例子来介绍,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class DynamicDispatch {
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
public void sayHello(){
System.out.println("Hello gentleman");
}
}
static class Woman extends Human{
public void sayHello(){
System.out.println("Hello lady");
}
}
public static void main(String[] args) {
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
}
}

结果如下:

Hello gentleman
Hello lady
Hello lady

这个结果对于熟悉Java面向对象编程的人来说都不陌生。这里要说明的是,虚拟机是如何知道要调用哪个版本的。

显然这不是根据静态类型决定的,因为两个对象的静态类型都是Human。但是调用的结果却不同,这是因为这两个对象的实际类型不同。所以,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
public class Dispatch {
static class Pepsi{}
static class Coca{}

public static class Father{
public void like(Pepsi p){
System.out.println("Father likes pepsi");
}
public void like(Coca c){
System.out.println("Father likes coca");
}
}
public static class Son extends Father{
public void like(Pepsi p){
System.out.println("Son likes pepsi");
}
public void like(Coca c){
System.out.println("Son likes coca");
}
}
public static void main(String[] args) {
Father father=new Father();
Son son=new Son();
father.like(new Coca());
son.like(new Pepsi());
}
}

结果如下:

Father likes coca
Son likes pepsi

这个结果没有什么意外的地方,主要是看一下虚拟机是如何确定方法调用的版本的。

先看看静态分派过程,这个时候选择的依据有两个:静态类型是Father还是Son,方法参数是Pepsi还是Coca。这次选择产生了两个invokevirtual指令,两条指令的参数分别为常量池中指向Father.like(Coca)和Father.like(Pepsi)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

然后看看运行时虚拟机的选择,即动态分派过程。在执行son.like(new Pepsi())时,也就是说在执行invokevirtual指令时,由于编译期间已经决定目标方法的签名必须是like(Pepsi),虚拟机此时不会关心传递过来的参数是什么,因为这时参数的静态类型、实际类型都对方法的选择不会构成影响,唯一有影响的就是方法的接收者的实际类型是Father还是Son。因为只有一个宗量,所以Java的动态分派属于单分派。

虚拟机动态分派的实现

由于动态分派是非常频繁的操作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机会进行优化。常用的方法就是为类在方法区中建立一个虚方法表(Virtual Method Table,在invokeinterface执行时也会用到接口方法表,Interface Method Table),使用虚方法表索引来替代元数据查找以提升性能。下图就是前面代码的虚方法表结构:
20160606200822089

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了父类的方法,子类方法表中的地址会替换为指向子类实现版本的入口地址。在上图中,Son重写了Father的全部方法,所以Son的方法表替换了父类的地址。但是Son和Father都没有重写Object的方法,所以方法表都指向了Object的数据类型。

为了程序实现上的方便,具有相同签名的方法,在父类和子类的虚方法表中都应该具有一样的索引号,这样当类型变换时,仅仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。

方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。