Java-分派

Java 是一门面向对象的程序语言,Java 具备面向对象的3个基本特征:封装、继承与多态。分派调用过程将会解释多态性特征的一些最基本的体现,如 Java 虚拟机如何实现 “重载” 和 “重写”。

静态分派

“分派” (Dispatch) 本身就带有动态性,一般不应用在静态语境中,在英文原版的 《Java 虚拟机规范》和《Java 语言规范》里的说法都是 “Method Overload Resolution”,实际应当归于 “解析”。但许多翻译的中文资料将其称为 “静态分派”。

为解释静态分派与重载 (Overload),请看如下代码

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
33
34
/**
* hello, guy!
* hello, guy!
*/
public class StaticDispatch {
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(woman);
}

static abstract class Human {
}

static class Man extends Human {
}

static class Woman extends Human {
}

private void sayHello(Human guy) {
System.out.println("hello, guy!");
}

private void sayHello(Man guy) {
System.out.println("hello, gentleman!");
}

private void sayHello(Woman guy) {
System.out.println("hello, lady!");
}
}

相信对 Java 稍有了解的程序员看完代码后都能判断出正确的结果。但为何虚拟机会选择执行参数为 Human 的重载呢?首先需要弄清两个关键概念:

1
Human man = new Man();

以上代码中的 “Human” 称为变量的 “静态类型” (Static Type),或者叫做 “外观类型” (Apparent Type),后面的 “Man” 则被称为变量的 “实际类型” (Actual Type) 或者叫 “运行时类型” (Runtime Type)。外观类型和实际类型在程序中都可能会发生变化,区别是外观类型的变化仅仅在使用时发生,变量本身的外观类型不会改变,并且最终的外观类型在编译期是可知的;而实际类型变化的结果在运行期才可以确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

不妨通过以下代码解释

1
2
3
4
5
6
// 实际类型变化
Human human = new Random().nextBoolean() ? new Man() : new Woman();

// 外观类型变化
sd.sayHello((Man) human);
sd.sayHello((Woman) human);

human 的实际类型是可变的 (根据 nextBoolean() 的值决定),编译期是不可知的,必须等到运行时才可以确定。human 的外观类型是 Human,可以在使用时临时改变类型,但这种改变在编译期是可知的,两次 sayHello 的调用,在编译期完全可以明确是 Man 还是 Women。

回到最先的代码 main 中两次调用 sd.sayHello ,此时调用哪个重载版本完全取决于传入参数的数据类型。代码中定义了两个实际类型不同,外观类型却相同的对象。虚拟机 (准确的来说是编译器) 重载时是通过参数的外观类型而不是实际类型作为判定依据的。外观类型在编译期可知,在编译阶段 Javac 编译器根据参数的外观类型决定了使用哪个重载版本,因此选择了 sayHello(Human) 作为调用的目标,并将这个方法的符号引用写到 main 里的两条invokevirtual 指令的参数中,如下反汇编的 26: 与 31:。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// javap -c 反汇编
public static void main(java.lang.String[]);
Code:
0: new #7 // class StaticDispatch$Man
3: dup
4: invokespecial #9 // Method StaticDispatch$Man."<init>":()V
7: astore_1
8: new #10 // class StaticDispatch$Woman
11: dup
12: invokespecial #12 // Method StaticDispatch$Woman."<init>":()V
15: astore_2
16: new #13 // class StaticDispatch
19: dup
20: invokespecial #15 // Method "<init>":()V
23: astore_3
24: aload_3
25: aload_1
26: invokevirtual #16 // Method sayHello:(LStaticDispatch$Human;)V
29: aload_3
30: aload_2
31: invokevirtual #16 // Method sayHello:(LStaticDispatch$Human;)V
34: return

所有依赖外观类型来决定方法执行版本的分派动作,都称为静态分派。静态分派最典型的应用表现就是方法的重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行,这也是一些资料选择把静态分派归于 “解析” 而不是 “分派” 的原因。

(未完待续)

动态分派

动态分派与 Java 语言动态性的另一重要体现 —— 重写 (Override) 有着很密切的关联。请看如下代码段

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
33
/**
* man say hello
* woman say hello
* woman say hello
*/
public class StaticDispatch {
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}

static abstract class Human {
protected abstract void sayHello();
}

static class Man extends Human {
@Override
public void sayHello() {
System.out.println("man say hello");
}
}

static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
}

对于习惯了面向对象的 Java 程序员来说,运行结果正如预期。但 Java 虚拟机是如何判断应该调用哪个方法的?

显然这里的选择调用的方法不可能再根据外观类型来决定。因为该实例的两个对象外观类型都是 Human 产生了不同的行为。man 在两次调用中还执行了两个不同的方法。原因很明显,因为这两个变量的实际类型不同。

Java 是如何根据实际类型来分派方法执行的版本的呢?请看如下代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public static void main(java.lang.String[]);
Code:
0: new #7 // class dispatch/DynamicDispatch$Man
3: dup
4: invokespecial #9 // Method dispatch/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #10 // class dispatch/DynamicDispatch$Woman
11: dup
12: invokespecial #12 // Method dispatch/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #13 // Method dispatch/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #13 // Method dispatch/DynamicDispatch$Human.sayHello:()V
24: new #10 // class dispatch/DynamicDispatch$Woman
27: dup
28: invokespecial #12 // Method dispatch/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #13 // Method dispatch/DynamicDispatch$Human.sayHello:()V
36: return

0 ~ 15 行是准备动作,建立 man 和 woman 的内存空间、调用 Man 和 Woman 类型的实例构造器,将这个实例引用存放到第 1、2 个局部变量表的变量槽中,这些动作实际对应以下的 Java 源码:

1
2
Human man = new Man();
Human woman = new Woman();

16 ~ 21 的 aload 指令分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是执行 sayHello 的所有者,称为接收者 (Receiver);17 和 21 行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是 invokevirtual) 还是参数 ( 都是常量池中第 22 项的常量,注释显示了这个常量是 Human.sayHello 的符号引用) 都完全一样,但是这两句指令最终执行的目标方法并不相同。解决问题的关键必须从 invokevirtual 指令本身入手,要弄清楚它是如何确定调用方法版本、如何实现多态查找来着手分析才行。

根据 《Java 虚拟机规范》,invokevirtual 指令的运行时解析过程大致分为以下几个部分:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作 C

  2. 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,

    如果通过则返回这个方法的直接引用,查找过程结束;

    不通过则返回 java.lang.IllegalAccessError 异常。

  3. 否则,按继承关系自下而上依次对 C 的各个父类进行第二步的搜索及验证。

  4. 若始终没有合适的方法,抛出 java.lang.AbstractMethodError 异常。

正是因为 invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的 invokevirtual 指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是 Java 语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

这种多态性的根源在于虚方法调用指令 invokevirtual 的执行逻辑,所以这只会对方法有效,对字段无效,因为字段不使用这条指令。在 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
28
29
30
31
32
33
34
35
36
37
38
// DON'T DO THIS!
/**
* I'm Son, I have $0
* I'm Son, I have $4
* This guy has $2
*/
public class FieldHasNoPolymorphic {
public static void main(String[] args) {
Father guy = new Son();
System.out.println("This guy has $" + guy.money);
}

static class Father {
public int money = 1;

public Father() {
money = 2;
showMeTheMoney();
}

public void showMeTheMoney() {
System.out.println("I'm Father, I have $" + money);
}
}

static class Son extends Father {
public int money = 1;

public Son() {
money = 4;
showMeTheMoney();
}

public void showMeTheMoney() {
System.out.println("I'm Son, I have $" + money);
}
}
}

输出的两句都是 “I’m Son”,因为 Son 类在创建的时候,首先隐式调用了 Father 的构造函数,而 Father 构造函数中对 showMeTheMoney 的调用是一次虚方法的调用,执行的版本是 Son::showMeTheMoney 方法,所以输出的是 “I’m Son”。虽然父类的 money 已经初始化成 2,但 Son::showMeTheMoney 方法中访问的是子类的 money,这里的结果是 0,因为它要到子类的构造函数执行时才会被初始化。之后子类构造方法执行输出 4,main 的最后一句通过外观类型访问到了父类中的 money,输出 2。

单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,该定义最早出现在《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
28
29
30
31
32
33
34
35
36
37
38
39
40
/**
* father choose 360
* son choose qq
*/
public class Dispatch {
public static void main(String[] args) {
Father father = new Father();
father.hardChoice(new _360());
Father son = new Son();
son.hardChoice(new QQ());
}

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 {
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}

public void hardChoice(_360 arg) {
System.out.println("son choose 360");
}
}
}

这两次 hardChoice 的结果已在注释标注,重点是编译阶段中编译器的静态分派过程。选择目标方法的依据有两点:一是外观类型是 Father 还是 Son,二是方法参数是 QQ 还是 360.这次选择结果的最终产物是产生了两条 invokevirtual 指令,两条指令的参数分别为常量池中指向 Father::hardChoice(360) 及 Father::hardChoice(QQ) 方法的符号引用。因为是根据两个宗量进行选择,所以 Java 语言的静态分派属于多分派类型

再看运行阶段中虚拟机的动态分派的过程。在执行 “son.hardChoice(new QQ());”,也就是指对应的 invokevirtual 指令时,由于编译期已经决定目标方法的签名必须为 hardChoice(QQ) ,虚拟机不管关心此时传递过来的参数到底是什么,因为其外观类型、实际类型都不会对选择构成任何影响,唯一可以影响虚拟机的该方法接受者的实际类型是 Father 还是 Son。因为只有一个宗量作为选择的依据,所以 Java 语言的动态分派属于单分派类型

根据上述论证,如今的 Java 语言是一门静态多分派、动态单分派的语言。强调如今是因为这个结论未必会一直保持。 C# 3.0 及之前的版本与 Java 一样是动态单分派语言,但在 C# 4.0 加入dynamic 类型后,就可以很方便的实现多分派。JDK 10 时 Java 语言出现新关键字 var,但请不要将其与 C# dynamic 混淆,实际上 Java var 对应的是 C# var。它们与 dynamic 有本质区别:var 是在编译时根据声明语句的右侧表达式类型进行静态推断的,本质上这是一种语法糖(见 Effective-CSharp-1优先使用隐式类型的局部变量);而 dynamic 在编译时完全不关心类型是什么,等到运行的时候再做类型判断。 与 C# dynamic 功能相近的是 JDK 9 时通过 JEP 276 引入的 jdk.dynalink 模块,使用 jdk.dynalink 可以实现在表达式中使用动态类型,Javac 编译器可以将其操作翻译为 invokedynamic 指令的调用点。

虚拟机动态分派实现

前文介绍的分派过程,作为对于 Java 虚拟机概念模型的解释已基本足够了,明确的解释了虚拟机在分派时会做什么这个问题。但要问 Java 虚拟机 “具体如何做到”,答案则可能因虚拟机的实现而不同而有差别。

动态分派是执行非常频繁的动作,且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法。因此, Java 虚拟机实现基于执行性能的顾虑,真正运行时一般不会如此频繁地去反复搜索类型元数据,面对这种情况,一种基础且常见的优化手段是为类型在方注区中建立一个虚方法表(Virtual Method Table,也称为 vtable,与此对应的、在 invokeinterface 执行时也会用到接口方法表 —— Interface Method Table,简称 itable),使用虚方法表索引代替元数据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,子类的虚方法表中的地址入口将与父类相同方法的入口地址一致,都是指向父类的实现入口。如果子类重写了这个方法,子类虚方法表中的地址则会被替换为指向子类实现版本的入口地址。

为了程序实现方便,拥有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

上述的查虚方法表是分派调用的一种优化手段,由于 Java 对象里面的方法默认 (即不使用 final) 就是虚方法,虚拟机除了使用虚方法表之外,为了进一步提高性能,还会用类型继承关系分析 (Class Hierarchy Analysis,CHA)、守护内联 (Guarded Inlining)、内存缓存 (Inline Cache) 等多种非稳定的激进优化来争取更大的性能空间。