Java多态性--分派

一、基本概念

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

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

时间: 2024-10-14 18:38:48

Java多态性--分派的相关文章

java 多态性详解及常见面试题

java多态性 多态分两种: (1)   编译时多态(设计时多态):方法重载. (2)   运行时多态:JAVA运行时系统根据调用该方法的实例的类型来决定选择调用哪个方法则被称为运行时多态.(我们平时说得多的事运行时多态,所以多态主要也是指运行时多态) 运行时多态存在的三个必要条件: 一.要有继承(包括接口的实现): 二.要有重写: 三.父类引用指向子类对象. 多态的好处: 1.可替换性(substitutability).多态对已存在代码具有可替换性.例如,多态对圆Circle类工作,对其他任

java多态性

深入理解java多态性 昨天看到一个关于多态性的帖子,参考了回帖者的理解,加入了一些自己的看法,整理出来供大家参考,不一定完全正确,欢迎大家批评指正. (一)相关类 class A ...{         public String show(D obj)...{                return ("A and D");         }          public String show(A obj)...{                return (&quo

java 多态性

引言:多态是指程序中定义的引用变量所指向的具体类型和通过该引用变量发出的方法调用在编程时并不确定,而是在程序运行期间才确定,即一个引用变量倒底会指向哪个类的实例对象,该引用变量发出的方法调用到底是哪个类中实现的方法,必须在由程序运行期间才能决定.因为在程序运行时才确定具体的类,这样,不用修改源程序代码,就可以让引用变量绑定到各种不同的类实现上,从而导致该引用调用的具体方法随之改变,即不修改程序代码就可以改变程序运行时所绑定的具体代码,让程序可以选择多个运行状态,这就是多态性.多态性增强了软件的灵

[转载]深入理解java多态性

FROM:http://blog.csdn.net/thinkGhoster/article/details/2307001 昨天看到一个关于多态性的帖子,参考了回帖者的理解,加入了一些自己的看法,整理出来供大家参考,不一定完全正确,欢迎大家批评指正. (一)相关类 class A ...{         public String show(D obj)...{                return ("A and D");         }          public

Java多态性的表现

Java实现运行时多态性的基础是动态方法调度,它是一种在运行时而不是在编译期调用重载方法的机制. 方法的重写(Overriding)和重载(Overloading)是Java多态性的不同表现. 重写是父类与子类之间多态性的一种表现,重载是一个类中多态性的一种表现. 如果在子类中定义某方法与其父类有相同的名称和参数,我们就说该方法被重写:子类的对象使用这个方法时,将调用子类中的定义,对它而言,父类中的定义如同被"屏蔽"了.如果在一个类中定义了多个同名的方法,它们或有不同的参数个数或有不同

Java多态性的体现方式

Java实现运行时多态性的基础是动态方法调度,它是一种在运行时而不是在编译期调用重载方法的机制. 方法的重写(Overriding)和重载(Overloading)是Java多态性的不同表现. 重写是父类与子类之间多态性的一种表现,重载是一个类中多态性的一种表现. 如果在子类中定义某方法与其父类有相同的名称和参数,我们就说该方法被重写:子类的对象使用这个方法时,将调用子类中的定义,对它而言,父类中的定义如同被"屏蔽"了.如果在一个类中定义了多个同名的方法,它们或有不同的参数个数或有不同

Java多态性理解

Java多态性理解 Java中多态性的实现 什么是多态 面向对象的三大特性:封装.继承.多态.从一定角度来看,封装和继承几乎都是为多态而准备的.这是我们最后一个概念,也是最重要的知识点. 多态的定义:指允许不同类的对象对同一消息做出响应.即同一消息可以根据发送对象的不同而采用多种不同的行为方式.(发送消息就是函数调用) 实现多态的技术称为:动态绑定(dynamic binding),是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法. 多态的作用:消除类型之间的耦合关系.

Java多态性的&ldquo;飘渺之旅&rdquo;

原文出处:斯武丶风晴 摘要: 如何从Java多态性进行飘渺之旅呢? 我们用例子来旅行. 1 朵星人A:人类,是一个很奇妙的物种. 2 朵星人B:他们好像分为两种,嗯 先生,以及美女? 3 朵星人C:对,更年轻的有叫 美少女的. 4 朵星人D:他们之间怎么打招呼的?我们问问AI(编译器大佬)吧.. 5 朵星人A:可以有.启动吧~ 第一次启动: 1 /** 2 * 编译时多态 3 * 4 * @author Sven Augustus 5 */ 6 public class StaticTest {

Java 多态性理解

什么是多态 面向对象的三大特性:封装.继承.多态.从一定角度来看,封装和继承几乎都是为多态而准备的.这是我们最后一个概念,也是最重要的知识点. 多态的定义:指允许不同类的对象对同一消息做出响应.即同一消息可以根据发送对象的不同而采用多种不同的行为方式.(发送消息就是函数调用) 实现多态的技术称为:动态绑定(dynamic binding),是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法.特别注意:当所引用的类型存在继承关系时候,会结合参数的类型,选择相应的方法.可以结合