探秘 JVM:方法解析与分派调用

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

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

一. 解析调用

在类加载的解析阶段,会将一部分能够确定的的目标方法的符号引用转化为直接应用,只有在程序运行之前就可以确定调用版本,且在运行期间版本不可变的方法 才能够在这一阶段被解析,即 编译期可知,运行期不变 的方法。JVM 中提供了 5 条方法调用字节码指令:

  1. invokestatic: 调用静态方法
  2. invokespecial: 调用构造方法、私有方法,以及父类方法
  3. invokevirtual: 调用所有的虚方法
  4. invokeinterface:调用接口方法
  5. invokedynamic:动态语言支持

被 invokestatic 和 invokespecial 指令调用的方法都可以在解析阶段中唯一确定方法版本,包括静态方法、私有方法、构造方法、父类方法。此外 final 方法虽然被 invokevirtual 指令调用,但是因为 final 方法不能被覆盖(可以被重载),所以也能够给在编译期被唯一确定,但是这个时候使用的技术不是静态解析,而是静态分派(可以参考 2.1)。

二. 分派调用

解析发生在类加载的阶段,是一个静态调用的过程,在编译期可以完全确定,并在类加载的解析阶段将方法的符号引用转变为直接引用,而分派则可以是静态的,也可以是动态的。

2.1 静态分派:方法重载的处理机制

所有依赖静态类型定位方法具体执行版本的分派动作称为静态分派,这一过程发生在编译阶段,典型的应用场景就是重载的处理,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
public class StaticDispatch {

static abstract class A {}

static class B extends A {}

static class C extends A {}

public void foo(A a) {
System.out.println("hello, this is a");
}

public void foo(B b) {
System.out.println("hello, this is b");
}

public void foo(C c) {
System.out.println("hello, this is c");
}

public static void main(String[] args) {
StaticDispatch sd = new StaticDispatch();
A b = new B(); // 静态类型 A
A c = new C(); // 静态类型 A
sd.foo(b);
sd.foo(c);
}
}

程序输出如下:

1
2
hello, this is a
hello, this is a

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

1
2
3
4
5
6
7
public static void main(String[] args) {
StaticDispatch sd = new StaticDispatch();
B b = new B(); // 静态类型 B
C c = new C(); // 静态类型 C
sd.foo(b);
sd.foo(c);
}

程序的输出如下:

1
2
hello, this is b
hello, this is c

2.2 动态分派:方法覆盖的处理机制

动态分派发生在运行阶段,典型的应用场景就是方法覆盖的处理,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
public class DynamicDispatch {

static abstract class A {
public void sayHello() {
System.out.println("Hello, this is a.");
}
}

static class B extends A {
@Override
public void sayHello() {
System.out.println("Hello, this is b.");
}
}

static class C extends A {
@Override
public void sayHello() {
System.out.println("Hello, this is c.");
}
}

public static void main(String[] args) {
A b = new B();
A c = new C();
b.sayHello();
c.sayHello();
}
}

程序输出如下(与我们的预期一致):

1
2
Hello, this is b.
Hello, this is c.

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

1
2
3
4
5
6
7
8
L2
LINENUMBER 32 L2
ALOAD 1
INVOKEVIRTUAL org/zhenchao/jvm/DynamicDispatch$A.sayHello ()V
L3
LINENUMBER 33 L3
ALOAD 2
INVOKEVIRTUAL org/zhenchao/jvm/DynamicDispatch$A.sayHello ()V

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

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

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

2.3 单分派和多分派

所谓的 是依据确定目标方法所需要的条件而定义的,确定一个方法一般存在两方面的限制:

  1. 方法签名
  2. 方法隶属的类

如果分派仅需要上述条件中的一个就能确定目标方法,那么称该分派为单分派,对于需要多个条件的分派则称之为多分派,而 java 语言中的静态分配就是多分派,动态分派则是单分派,所以我们总结到目前为止(至少 jdk1.8 之前),java 语言是一门 静态多分派,动态单分派 的语言。


转载声明 : 版权所有,商业转载请联系作者,非商业转载请注明出处
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议
Powered by hexo & Theme by hiero   Copyright © 2015-2019 浙ICP备 16010916  号,指 · 间 All Rights Reserved.