一、基本概念

Java是一门面向对象的程序设计语言,因为Java具备面向对象的三个基本特征:封装、继承和多态。这三个特征并不是各自独立的,从一定角度上看,封装和继承几乎都是为多态而准备的。多态性主要体现在对象的方法调用上:

1.编译期根据对象的静态类型进行静态分派。

2.运行期根据对象的实际类型进行动态分派。

在进一步解释分派的原理之前,先熟悉几个概念:

1.静态类型和实际类型

1 Map map = new HashMap();
2 System.out.println((Object)map);
3 map = new IdentityHashMap();

上面的第1行代码中,定义了一个变量map,其中‘Map’称为变量的静态类型(Static Type)或外观类型(Apparent Type),‘HashMap’称为变量的实际类型(Actual Type)。

静态类型和实际类型在程序中都可以发生变化,区别是静态类型的变化仅仅在使用时发生变化,并且静态类型是在编译期可知的。而实际类型变化的结果在运行期才能确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。如上面的第2行代码:变量的静态类型改变成了Object,第三行代码:对象的实际类型变成了IdentityHashMap。

这里可能有人会问:为什么编译器在编译程序的时候不知道一个对象的实际类型是什么?像上面的第3行代码中对象的实际类型变成了IdentityHashMap不是一眼就看出来了吗?编译器不是应该能解析出来的吗?如果你有这样的疑问,那么请看下面的代码:

1 public void type(Map map){
2     // doSomething
3 }

这个方法提供一个Map类型的入参,在方法中使用这个map实例做一些事情。那么这个时候,编译器是无法知道map的实际类型的。这个方法在项目中可能被很多地方调用,有的调用方传HashMap,有的调用方传IdentityHashMap,还有的调用方传LinkedHashMap,等等。所以编译器在编译期间是无法确定对象的实际类型的。不过对象的静态类型是始终能确定的,就如上面的这个方法,不管入参传的什么,该对象的静态类型都是Map。

2.方法宗量

方法的接收者与方法的参数统称为方法的宗量。

二、静态分派

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。Java里面的静态分派的具体体现是方法的重载,这里我们不去讨论重载的语法,直接讨论重载的实现。

 1 public class StaticDispatch {
 2     static class Shape{
 3     }
 4 
 5     static class Circle extends Shape{
 6     }
 7 
 8     public void draw(Shape shape){
 9         System.out.println("It is shape!");
10     }
11 
12     public void draw(Circle circle){
13         System.out.println("It is circle!");
14     }
15 
16     public static void main(String[] args) {
17         Shape shape = new Shape();
18         Shape circle = new Circle();
19         StaticDispatch staticDispatch =new StaticDispatch();
20         staticDispatch.draw(shape);
21         staticDispatch.draw(circle);
22     }
23 }

上面的例子,第2-6行代码定义了两个类:一个形状的抽象类Shape,一个具体的形状-圆形类Circle。

第8-14行代码定义了StaticDispatch的两个重载方法draw,根据不同的形状参数(Shape或Circle),打印出不同的形状信息。

第16-22行代码对重载方法进行测试。需要注意的是第18行,变量circle的实际类型是Circle,静态类型是Shape。程序运行的结果为:

1 It is shape!
2 It is shape!

上面的结果对于Java有经验的程序员不足为奇,但是对于初学者或多或少会感到疑惑。21行代码的调用参数明明是Circle的实例,怎么打印的信息是“It is shape!”而不是“It is circle!”。为了解释这个现象,我们来看一下这段代码经过编译之后的汇编代码。(这里的汇编和我们一般说的汇编代码有些区别,一般意义上的汇编代码是机器指令的可读形式。这里说的汇编代码是JVM指令的可读形式)。

1 ALOAD 3
2 ALOAD 1
3 INVOKEVIRTUAL StaticDispatch.draw (LStaticDispatch$Shape;)V
4 ALOAD 3
5 ALOAD 2
6 INVOKEVIRTUAL StaticDispatch.draw (LStaticDispatch$Shape;)V

这里我们把非关注点的代码都省略了,只留下20和21行代码对应的汇编代码。第1-2行将变量槽中的引用对象推送至操作栈,两个对象分别是staticDispatch和shape。第3行的INVOKEVIRTUAL才是方法的真正调用,调用的方法描述符是StaticDispatch.draw (LStaticDispatch$Shape;)V。对应的Java代码方法:public void draw(Shape)。执行方法调用的时候,INVOKEVIRTUAL指令将先前入栈的操作数弹出栈作为方法的参数和接收者,这里的参数是shape,接收者是staticDispatch。这两者的结合也就是我们上面提及的方法宗量。

第4-6行也是类似的逻辑。唯一不同的是第五行压入操作栈参数是circle。可是调用的方法仍然是StaticDispatch.draw (LStaticDispatch$Shape;)V

由此可见,编译器在编译的时候是只认静态类型,由于变量shape和circle的静态类型都是Shape,所以最终编译后的汇编代码都是调用的同一个方法。这样一来,两次调用都打印出“It is shape!”就解释的通了。这里需要额外注意的一个细节点是,两次方法调用的接收者是一样的,都是staticDispatch

编译器虽然能确定出方法的重载版本,但是在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。对于上面的例子,变量的静态类型是显式指明的,所以是能唯一确定的,不会存在二义性。但是如果变量的静态类型没有显式指明,那么该怎么去确定方法的执行版本呢?下面的例子演示了编译器如何选择“更加合适的”版本。

 1 public class Overload {
 2     public static void sayHello(Object arg){
 3         System.out.println("Hello Object!");
 4     }
 5 
 6     public static void sayHello(int arg){
 7         System.out.println("Hello int!");
 8     }
 9 
10     public static void sayHello(long arg){
11         System.out.println("Hello long!");
12     }
13 
14     public static void sayHello(Character arg){
15         System.out.println("Hello Character!");
16     }
17 
18     public static void sayHello(char arg){
19         System.out.println("Hello char!");
20     }
21 
22     public static void sayHello(char... arg){
23         System.out.println("Hello char...!");
24     }
25 
26     public static void sayHello(Serializable arg){
27         System.out.println("Hello Serializable!");
28     }
29 
30     public static void main(String[] args) {
31         sayHello('a');
32     }
33 }

Overload类中定义了一系类的重载方法sayHello,在main函数中进行了调用,传入的参数不是带类型的变量,而是字符字面量'a'。这里并没有显式的指明变量的静态类型,哪个重载的方法是满足的呢?

答案是:每个方法都是满足的。因为你可以说字面量'a'是char型,可以说它是Character型,甚至可以说它是Object型。那么编译器该如何抉择呢?是随机的选择一个方法吗?

答案是:不是的。编译器是根据匹配优先级确定方法的执行版本。

因为'a'最符合char型的定义,所以优先匹配sayHello(char arg)方法。上面的代码执行后将输出"Hello char...!"。

如果把sayHello(char arg)注释掉,会输出什么呢?这个时候编译器会自动将'a'转型为int,将会调用sayHello(int arg),输出"Hello int!"。这是由于'a'除了可以代表一个字符,还可以代表数字97(字符'a'的Unicode值是97)。

如果再把sayHello(int arg)注释掉呢?这里我们就不挨个的去解释了。这些类型匹配的优先级是:char->int->long->float->double->Character->Serializable->Object->char...,读者自行体会。

三、动态分派

了解了静态分派,我们接下来看一下动态分派。所有依赖动态类型来定位方法执行版本的分派动作称为动态分派,动态分派发生在运行期。Java里面的动态分派主要体现在“重写”上。请看下面的例子:

 1 public class DynamicDispatch {
 2     static class Shape {
 3         protected void draw(){
 4             System.out.println("It is shape!");
 5         }
 6     }
 7 
 8     static class Circle extends Shape {
 9         protected void draw(){
10             System.out.println("It is circle!");
11         }
12     }
13 
14     public static void main(String[] args) {
15         Shape shape = new Shape();
16         Shape circle = new Circle();
17         shape.draw();
18         circle.draw();
19     }
20 }

上面的代码分别创建了两个类Shape和Circle,Circle继承Shape并且重写了draw方法。在main函数中,分别定义了它们的两个实例shape和circle,把两个实例的的静态类型都设置为Shape。那么这次调用它们的draw方法会输出什么呢?

1 It is shape!
2 It is circle!

根据上面的结果可以看出,虽然shape和circle的静态类型都是Shape,但是虚拟机在执行的时候并没有傻乎乎的都去执行Shape类中定义的draw方法。为何如此,我们还是来看一下main函数的汇编代码:

 1 NEW DynamicDispatch$Shape
 2 DUP
 3 INVOKESPECIAL DynamicDispatch$Shape.<init> ()V
 4 ASTORE 1
 5 NEW DynamicDispatch$Circle
 6 DUP
 7 INVOKESPECIAL DynamicDispatch$Circle.<init> ()V
 8 ASTORE 2
 9 ALOAD 1
10 INVOKEVIRTUAL DynamicDispatch$Shape.draw ()V
11 ALOAD 2
12 INVOKEVIRTUAL DynamicDispatch$Shape.draw ()V

代码1-4行是用NEW指令新建Shape实例,并调用实例的初始化方法init(这个就是我们常说的默认构造函数,虽然代码中没写,但是编译器为程序猿自动生成的),实例化完成后,把实例的引用存储在1号变量槽中。第5-8行是类似的逻辑,只是创建的实例不同。这里需要注意的一个细节点是:存储的引用都是实际对象的句柄,就是能通过这个引用找到堆中的实际对象。第9-12行就是两个实例方法的调用,读者可以看到,两次调用的方法描述符都是一样的DynamicDispatch$Shape.draw ()V,不同的地方在于两次调用之前推入操作栈的变量不一样,分别是1号槽和2号槽的变量(也就是shape和circle),这两个变量也就是方法的接收者。问题的关键就在这,在JVM真正去执行方法调用的时候,会去方法的接收者那去寻找方法。所以执行draw方法的时候,shape和circle会去各自的draw方法。

到这里读者应该明白了,虽然编译器按照静态类型生成了方法执行的版本。但是在JVM运行的时候是不看静态类型的,JVM只看方法签名(如上面的draw ()V)和方法的接收者。也就是说:对于一个要调用的方法(比如例子中的draw),最终决定方法执行版本的因素就是方法的宗量。

四、单分派和多分派

前面我们介绍过宗量的概念。根据分派基于多少种宗量,可以将分派划分为单分派和多分派。根据上面静态分派和动态分派的阐述,我们可以知道:

在编译期间,编译器需要根据方法的变量的静态类型和参数才能确定方法的描述符。所以Java的静态分派属于多分派。

在运行期间,方法的名称和描述符已经是确定了的,但是在执行真正的方法调用时,JVM需要根据方法的接收者的实际类型去决定执行的方法版本。所以Java的动态分派属于单分派。

五、虚拟机动态分派的实现

上面介绍动态分派的时候,我们了解到虚拟机是根据实际的方法接收者决定执行方法的版本。那假如方法的接收者对应的类里面没有该方法的定义呢?请看下面的例子。

 1 public class DynamicDispatch {
 2     static class Shape {
 3         protected void draw(){
 4             System.out.println("It is shape!");
 5         }
 6     }
 7 
 8     static class Circle extends Shape {
 9     }
10 
11     public static void main(String[] args) {
12         Shape circle = new Circle();
13         circle.draw();
14     }
15 }

代码很简单,类Circle继承Shape,但是没有重写父类的draw方法。main函数中实例化Circle,赋静态类型Shape并调用实例的draw方法。这里的输出相信各位读者都知道:"It is shape!"。这里方法的接收者是circle,虚拟机会先去Circle类里面寻找draw方法,但是Circle类中并没有这个方法,所以虚拟机会向上查找Circle的父类Shape,调用Shape的draw方法。

道理很简单,但是动态分派是个非常频繁的动作,如果每次都这么向上查找的话,会严重影响虚拟机的执行性能。所以虚拟机针对这种情况会做出一些优化手段,最常用的”稳定优化“手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable),使用虚方法表的索引来代替元数据查找以提高性能。

虚拟机会为每个类建立一个虚方法表,如上图左右两个表格分别为Shape和Circle的虚方法表。虚方法表中存放各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的。对于上面的例子,Shape和Circle都默认继承Object,所以它们的vtable里面继承的方法clone、hashCode、equals等都指向Object中对应的方法。这里Circle没有重写父类Shape的draw方法,所以它的vtable中draw方法地址入口指向Shape中的draw方法。如果这里Circle重写了draw方法,它的vtable里面这一项就会指向Circle类的draw方法地址入口。

由于使用了vtable技术,虚拟机在执行动态分派的时候,只需要找到方法接收者所对应的类的虚方法表,就能立即找到实际的方法,不用再向上查找。我们的例子比较简单,只有一个继承层级,真实应用中很可能类存在多个层级,使用vtable技术可以很大程度上提高虚拟机的执行性能。与此对应的,对于接口方法的查找也会用到方法表,只是换了个名字”接口方法表“--Interface Method Table,简称itable。

 

作者:南唐三少
出处:http://www.cnblogs.com/nantang
如果您觉得阅读本文对您有帮助,请点一下“推荐”按钮,您的“推荐”将是我们最大的写作动力!欢迎各位转载,但是未经作者本人同意,转载文章之后必须在文章页面明显位置给出作者和原文链接,否则保留追究法律责任的权利。