1. 分派与多态


分派 **是指按照对象的实际类型为其绑定对应方法体的过程。
多态就不多说了,沉浸面向对象的开发人员应该都已经门清了,而多态的特性就是根据分派实现的。

说起来有点抽象,下面看一段代码

  1. public class DispatchMain {
  2. static class Human {
  3. String name() {
  4. return "i am Human";
  5. }
  6. }
  7. static class Man extends Human {
  8. @Override
  9. String name() {
  10. return "i am man";
  11. }
  12. }
  13. static class Woman extends Human {
  14. @Override
  15. String name() {
  16. return "i am woman";
  17. }
  18. }
  19. void sayHello(Human human) {
  20. System.out.println("human say: " + human.name());
  21. }
  22. void sayHello(Man man) {
  23. System.out.println("man say: " + man.name());
  24. }
  25. void sayHello(Woman woman) {
  26. System.out.println("woman say: " + woman.name());
  27. }
  28. public static void main(String[] args) {
  29. Human man = new Man();
  30. Human woman = new Woman();
  31. DispatchMain dispatchTest = new DispatchMain();
  32. dispatchTest.sayHello(man);
  33. dispatchTest.sayHello(woman);
  34. }
  35. }

这个例子里实现了重载( sayHello )和覆写( name )两种多态的体现
运行的结果如下:

  1. human say: i am man
  2. human say: i am woman

有些工作经验的人应该都不难答出来,但是再深问为什么的话,可能有些人就比较含糊了,直觉?这在 0 和 1 的世界里并不能让人信服,其实秘密就在于编译器与 JVM 的分派实现,而这篇文章是做个探索的记录,希望能讲明白。

分派根据发生时机分类可以分为两大类:静态分派和动态分派

2. 静态分派

静态分派是指所有依赖静态类型来定位方法执行版本的分派动作,它发生在编译期间,所以也叫编译时多态。比如重载

像是开头的例子里在调用 sayHello 方法时,判断该调用哪个版本,这就属于静态分派
那什么叫做静态类型,什么叫做编译期呢,又是如何去定位的呢,下面会一一说到


2.1 静态类型与实际类型

静态类型和实际类型如下图所示:

分派原理 - 图1

通过静态类型,IDE 就可以在程序没有运行时就提供给程序员各种信息,例如代码提示和类型错误等
实际类型是在运行期才可以确定的,编译器在编译程序的时候并不知道一个对象的实际类型是什么,例如:

  1. Human man;
  2. if(new Random().nextBoolean()){
  3. man = new Man();
  4. }else{
  5. man = new Woman();
  6. }
  7. System.out.println(man.name());

上边的例子中,在执行到 man.name() 之前,编译器是不清楚 man 的实际类型是什么的

2.2 编译期

把 java 代码编译成 class 文件的过程,例如 javac 命令生成 class 文件

2.3 如何分派静态类型

编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的

现在要去说明 sayHello 是如何被静态分派的,首先回顾下代码如下:

  1. void sayHello(Human human) {
  2. System.out.println("human say: " + human.name());
  3. }
  4. void sayHello(Man man) {
  5. System.out.println("man say: " + man.name());
  6. }
  7. void sayHello(Woman woman) {
  8. System.out.println("woman say: " + woman.name());
  9. }
  10. public static void main(String[] args) {
  11. Human man = new Man();
  12. Human woman = new Woman();
  13. DispatchMain dispatchTest = new DispatchMain();
  14. dispatchTest.sayHello(man);
  15. dispatchTest.sayHello(woman);
  16. }

现在通过 javap 命令去反编译一下其 class 文件,三个 sayHello 方法的方法签名是

  1. (Lpolymorphism/DispatchMain$Human;)V
  2. (Lpolymorphism/DispatchMain$Man;)V
  3. (Lpolymorphism/DispatchMain$Woman;)V

而刚在在静态类型那一节也说明了,Human man = new Man();Human woman = new Woman(); 的静态类型都是 Human 所以自然就分派到了 void sayHello(Human human) 方法

3. 动态分派

在运行期根据实际类型确定方法执行版本的分派动作,它发生在运行期间,所以也叫运行期多态,比如覆写

像是开头的例子里在调用 name 方法时,判断该调由哪个实例去调用,这就属于动态分派
实际类型在前面已经说过了,那么下面就说下其在字节码上是如何规定的

先将如下的代码,通过 javap 反编译后得到汇编指令

  1. public static void main(String[] args) {
  2. Human man = new Man();
  3. Human woman = new Woman();
  4. DispatchMain dispatchTest = new DispatchMain();
  5. dispatchTest.sayHello(woman);
  6. dispatchTest.sayHello(man);
  7. }

汇编指令如下(只拷贝了执行部分)

  1. 0: new #14 // class polymorphism/DispatchMain$Man
  2. 3: dup
  3. 4: invokespecial #15 // Method polymorphism/DispatchMain$Man."<init>":()V
  4. 7: astore_1
  5. 8: new #16 // class polymorphism/DispatchMain$Woman
  6. 11: dup
  7. 12: invokespecial #17 // Method polymorphism/DispatchMain$Woman."<init>":()V
  8. 15: astore_2
  9. 16: new #18 // class polymorphism/DispatchMain
  10. 19: dup
  11. 20: invokespecial #19 // Method "<init>":()V
  12. 23: astore_3
  13. 24: aload_3
  14. 25: aload_2
  15. 26: invokevirtual #20 // Method sayHello:(Lpolymorphism/DispatchMain$Human;)V
  16. 29: aload_3
  17. 30: aload_1
  18. 31: invokevirtual #20 // Method sayHello:(Lpolymorphism/DispatchMain$Human;)V
  19. 34: return

现在去分析下上边的汇编指令

上边的指令集都有对应的解释,不懂的话可以参考下面指令集简述

  1. new:创建一个对像
    • … →
    • … , objectref
  2. dup:复制操作数栈栈顶的值,并插入到栈顶
    • … , value →
    • … , value , value
  3. invokespecial:调用实例方法,转本用来调用父类方法、私有方法和实例初始化方法
    • … , objectref , [arg1,[arg2…]] →
  4. astore_:将一个 reference 类型的数据保存到本地变量表中
    • … , objectref →
  5. aload_:从局部变量表加载一个 reference 类型值到操作数栈中
    • … →
    • … , objectref
  6. invokevirtual:调用实例方法,以及实例的类型进行分派
    • … , objectref , [arg1,[arg2…]] →
  • 首先在 0~15 行创建出了 man 和 woman 实例的引用,并把引用存在了本地变量表中
  • 在执行 invokevirtual 指令前(25、30 行),分别再取本地变量表中取出引用,压栈到操作数栈栈顶当做 invokevirtual 指令的参数
  • 所以传入参数的是实际引用,从而实现了分派

3.1 JVM 动态分派实现

以下内容加工于《深入理解 Java 虚拟机》

前面已经说明了 JVM 在分配中‘会做什么’这个问题,但是 JVM 是‘具体是如何做到的’,可能各种 JVM 的实现都会有些差别。
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能考虑,大部分实现都不会真正地进行如此频繁的搜索。
上边这话如何理解呢,下面先看一段代码

  1. static class Human {
  2. String name() {
  3. return "i am Human";
  4. }
  5. }
  6. static class Man extends Human {
  7. @Override
  8. String name() {
  9. return "i am man";
  10. }
  11. }
  12. static class Woman extends Human {
  13. @Override
  14. public String toString() {
  15. return "";
  16. }
  17. }
  18. public static void main(String[] args) {
  19. System.out.println(new Man().toString());
  20. System.out.println(new Woman().name());
  21. }

在这段程序中,如果按照原先的设想,那么在 Man 执行 toString 方法时的流程是 Man → Human → Object ,在Woman 执行 name 方法时的流程 Woman → Human。而这样每次调用方法的递归查询无疑是浪费性能的。
虚拟机的“稳定优化”就是在类的方法区中建立一个虚方法表(Vritual Method Table,也称为 vtable,与此对应的在 invokeinterface 执行时也会用到接口方法表 Inteface Method Table 简称 itable)

将上面的代码用虚方法表结构提现,则如下图

分派原理 - 图2

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

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