探秘 JVM:字节码执行引擎

在不同的虚拟机实现中,执行引擎在执行 java 代码时可能会有 解释执行 (通过解释器执行)和 编译执行 (通过 JIT 生成本地代码执行)两种选择,也可能是二者兼备,但不管采用哪种方式执行,当我们调用一个方法的时候,都需要确定目标方法的具体版本,因为在面向对象语言中存在封装、继承和多态的三大特性,一个方法可能因为重载或覆盖而存在多个版本。

方法调用阶段的主要工作是确定被调用方法的版本,而非执行具体的方法。字节码文件中存储的是方法的符号引用,只有将符号引用解析成直接引用(运行时方法的入口内存地址)才能确定具体调用的方法是谁,这个映射的过程有的发生在类加载的解析阶段,有的则发生在运行期间。

解析调用

在类加载的解析阶段会将一部分能够确定的目标方法的符号引用转化为直接应用,这类方法需要同时满足以下两个条件:

  1. 方法在程序运行之前就有一个可确定的调用版本。
  2. 方法的调用版本在运行期间不会发生改变。

这类方法称为 编译期可知,运行期不变 的方法,满足上述条件的方法包括静态方法、私有方法、实例构造方法、父类方法,以及 final 方法 5 种。这些方法在类加载的解析阶段就会将符号引用解析成直接引用,也称为 非虚方法 ,除此之外的方法称为 虚方法

JVM 中提供了 5 条方法调用字节码指令:

  1. invokestatic:调用静态方法。
  2. invokespecial:调用构造方法、私有方法,以及父类方法。
  3. invokevirtual:调用所有的虚方法。
  4. invokeinterface:调用接口方法,在运行期间再确定具体实现该接口的对象。
  5. invokedynamic:动态语言支持,先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

被 invokestatic 和 invokespecial 指令调用的方法都可以在解析阶段唯一确定方法版本,包括静态方法、私有方法、构造方法和父类方法。此外,final 方法虽然被 invokevirtual 指令调用,但是因为 final 方法不能被覆盖,所以也能够给在编译期唯一确定版本。

分派调用

解析操作发生在类加载阶段,是一个静态调用的过程,在编译期可以完全确定,而分派则可以是静态的,也可以是动态的。分派调用相对于解析调用要复杂很多,依据宗量(方法的接收者与方法的参数统称为方法的宗量)数可以进一步分为单分派和多分派。因此,分派调用按照组合理论可以细分为静态单分派、静态多分派、动态单分派,以及动态多分派。

静态分派

假设我们定义了一个 Parent 类,并派生出一个子类 Child,那么以 Parent person = new Child(); 为例,我们可以称 Parent 为变量 person 的 静态类型 ,称 Child 为变量 person 的 实际类型

所谓静态分派是指在编译期依据静态类型确定方法的执行版本,而动态分派则是在运行期依据实际类型确定方法的执行版本。

静态分派发生在编译阶段,典型的应用场景就是方法重载, JVM 在处理重载时依据的是参数的静态类型而非实际类型 ,在编译阶段就已经依据变量的静态类型确定了方法的执行版本。示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
abstract class Parent { }
class Boy extends Parent { }
class Girl extends Parent { }

public void foo(Parent person) {
System.out.println("Hello, this is the parent.");
}

public void foo(Boy boy) {
System.out.println("Hello, this is the boy.");
}

public void foo(Girl girl) {
System.out.println("Hello, this is the girl.");
}

public static void main(String[] args) {
StaticDispatch dispatch = new StaticDispatch();
Parent boy = new Boy();
Parent girl = new Girl();
dispatch.foo(boy); // Hello, this is the parent.
dispatch.foo(girl); // Hello, this is the parent.
}

上述示例中我们定义了类型为 Parent 的变量 boy 和 girl,我们称 Parent 为这两个变量的静态类型,这两个变量的实际类型分别是 Boy 和 Girl。由于 JVM 在处理重载时依据静态类型去判断方法的执行版本,所以这里的输出结果也就不难理解。如果我们将这两个变量的静态类型分别改为 Boy 和 Girl,那么输出也就变成了我们预期的样子,如下:

1
2
3
4
5
6
7
public static void main(String[] args) {
StaticDispatch dispatch = new StaticDispatch();
Boy boy = new Boy();
Girl girl = new Girl();
dispatch.foo(boy); // Hello, this is the boy.
dispatch.foo(girl); // Hello, this is the girl.
}

动态分派

动态分派发生在运行阶段,典型的应用场景就是方法覆盖, JVM 在处理方法覆盖时依据的是参数的实际类型而非静态类型 。示例:

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
30
31
32
abstract class Parent {

public void sayHello() {
System.out.println("Hello, this is the parent.");
}

}

class Boy extends Parent {

@Override
public void sayHello() {
System.out.println("Hello, this is the boy.");
}

}

class Girl extends Parent {

@Override
public void sayHello() {
System.out.println("Hello, this is the girl.");
}

}

public static void main(String[] args) {
Parent boy = new Boy();
Parent girl = new Girl();
boy.sayHello(); // Hello, this is the boy.
girl.sayHello(); // Hello, this is the girl.
}

下面的字节码对应源码中 main 方法调用 sayHello() 方法的两行代码:

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
30
31
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=3, locals=3, args_size=1
0: new #2 // class org/zhenchao/jvm/DynamicDispatch$Boy
3: dup
4: aconst_null
5: invokespecial #3 // Method org/zhenchao/jvm/DynamicDispatch$Boy."<init>":(Lorg/zhenchao/jvm/DynamicDispatch$1;)V
8: astore_1
9: new #4 // class org/zhenchao/jvm/DynamicDispatch$Girl
12: dup
13: aconst_null
14: invokespecial #5 // Method org/zhenchao/jvm/DynamicDispatch$Girl."<init>":(Lorg/zhenchao/jvm/DynamicDispatch$1;)V
17: astore_2
18: aload_1
19: invokevirtual #6 // Method org/zhenchao/jvm/DynamicDispatch$Parent.sayHello:()V
22: aload_2
23: invokevirtual #6 // Method org/zhenchao/jvm/DynamicDispatch$Parent.sayHello:()V
26: return
LineNumberTable:
line 36: 0
line 37: 9
line 38: 18
line 39: 22
line 40: 26
LocalVariableTable:
Start Length Slot Name Signature
0 27 0 args [Ljava/lang/String;
9 18 1 boy Lorg/zhenchao/jvm/DynamicDispatch$Parent;
18 9 2 girl Lorg/zhenchao/jvm/DynamicDispatch$Parent;

通过字节码可以看到,覆盖是通过 invokevirtual 指令去确认方法的具体执行版本,该指令的执行过程如下:

  1. 查找操作数栈顶第一个元素所指向的对象的实际类型,记作 C;
  2. 如果 C 中存在与常量中的描述符和简单名称都相符的方法(即方法签名与当前目标调用的方法匹配),则执行访问权限校验,通过则返回这个方法的直接引用,否则抛 IllegalAccessError 异常;
  3. 如果不存在,则 沿继承关系从下往上 依次遍历 C 的各个父类型,执行步骤 2 中的验证过程;
  4. 如果还是没有找到,则抛 AbstractMethodError 异常。

简单来说,该指令的执行过程就是从当前对象所属类开始沿着继承链从下往上检索,过程中判断方法签名和访问权限,如果都匹配则说明检索成功。

单分派和多分派

所谓的单分派和多分派是依据确定目标方法所需要的条件而定义的,分为两方面:方法签名和方法隶属的类。如果分派仅需一个条件就能唯一确定目标方法,那么称该分派为单分派,否则称为为多分派。

Java 语言中的静态分派就是多分派,动态分派则是单分派,所以到目前为止(至少 JDK 13 之前),java 语言仍然是一门 静态多分派,动态单分派 的语言。

参考

  1. Java 虚拟机规范(Java SE 8 版)
  2. 深入理解 java 虚拟机(第 2 版)
  3. 深入理解 java 虚拟机(第 3 版)