Java分派

首先来看以下代码的运行结果

public class StaticDispatch {
    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

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

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

    public void sayHello(Woman guy) {
        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

我初次看到这段代码的时候,怎么也想不到这段代码的结果会是如上结果。后来看了解析,发现这里涉及到Java中的一个概念--分派

何为分派

Java具有面向对象的三个基本特征:封装、继承和多态,而分派,是多态性特征(如“重载”、“重写”)在虚拟机层面的一种体现。具体来说,分派是虚拟机确定正确的目标方法的一个过程。

静态分派和动态分派

首先来了解一下静态类型和实际类型这两个概念

Map map = new HashMap<String, String>();

上例中的Map叫静态类型/外观类型,HashMap叫做实际类型。可以看出来,静态类型一般为抽象类/基类/接口,实际类型一般为子类/实现类。
静态类型和实际类型在程序中都可以发生一些变化, 区别是静态类型的变化仅仅在使用时发生, 变量本身的静态类型不会被改变, 并且最终的静态类型是在编译期可知的; 而实际类型变化的结果在运行期才可确定, 编译器在编译程序的时候并不知道一个对象的实际类型是什么。

  1. 静态分派

    所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。

    静态分派的典型应用是方法重载。当在一个发生重载的方法中寻找最匹配的执行方法时,会优先根据静态类型来选择方法。现在再回头看我们的引导案例,实际执行方法为什么是sayHello(Human guy)就一目了然了。

    静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

    另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本,大家可以尝试运行一下以下程序,依次注释掉最匹配的结果,看看会打印出什么结果。

    public class Overload {
      public static void sayHello(Object arg) {
          System.out.println("Hello Object");
      }
    
      public static void sayHello(int arg) {
          System.out.println("Hello int");
      }
    
      public static void sayHello(long arg) {
          System.out.println("Hello long");
      }
    
      public static void sayHello(Character arg) {
          System.out.println("Hello character");
      }
    
      public static void sayHello(char arg) {
          System.out.println("Hello char");
      }
    
      public static void sayHello(char... arg) {
          System.out.println("Hello char...");
      }
    
      public static void sayHello(Serializable arg) {
          System.out.println("Hello serializable");
      }
    
      public static void main(String[] args) {
          sayHello('a');
      }
    }
    
  2. 动态分派

    在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

    我们来看一个动态分派的案例。

    public class DynamicDispatch {
      static abstract class Human {
          protected abstract void sayHello();
      }
    
      static class Man extends Human {
          @Override
          protected void sayHello() {
              System.out.println("hello, man");
          }
      }
    
      static class Woman extends Human {
          @Override
          protected void sayHello() {
              System.out.println("hello, woman");
          }
      }
    
      public static void main(String[] args) {
          Human man = new Man();
          Human woman = new Woman();
          man.sayHello();
          woman.sayHello();
      }
    }

    相信稍微了解一点Java的人都能回答出来,上面代码的运行结果是

    hello, man
    hello, woman

    这是一个明显的Java重写方法案例,实际上,这个就是动态分派。只有在运行时,虚拟机才知道真正的执行主体是谁。
    我们使用javap来分析一下上述代码的汇编指令
    动态分派的奥秘.png
    可以看到,最终都是要执行虚方法

    invokevirtual #6                  // Method cn/hewie/dispatch/dynamicdispatch/DynamicDispatch$Human.sayHello:()V

    这里的sayHello方法已经在编译阶段确定了,但是其执行的主体是从"aload_1"、"aload_2"这样的变量中获取的,这里的值分别在上面的代码中设置为了Man和Women,所以产生这样的运行结果。动态分派里的“动态”就是这样体现出来的,这也是重写的在虚拟机层面的一种体现。

单分派和多分派

方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量, 可以将分派划分为单分派和多分派两种。 单分派是根据一个宗量对目标方法进行选择, 多分派则是根据多个宗量对目标方法进行选择。

总结

网上常说Java语言是一门静态多分派、 动态单分派的语言,具体来说是因为

  • Java中方法的执行要经历两个过程的选择,一个是编译期的静态分派,一个是运行期的动态分派。这种分派场景出现的根本原因是多态。
  • 而一个方法需要确定的宗量有两个,一个是方法的接受者(调用者),一个是方法的参数。
  • 进行编译期的编译时,需要确定的参数有两个,一个是接受者,一个是参数,所以此时是多分派的。
  • 编译之后,方法的参数就已经确定了。等进入到运行期,此时宗量就只剩下方法的接受者了,所以此时是单分派的。
  • 确定方法参数的时候,能够影响到方法选择的,就是方法的重载。
  • 确定方法接受者的时候,能够影响到方法选择的,就是方法的重写。

参考文档:
深入理解Java虚拟机:JVM高级特性与最佳实践 周志明 著