对面向对象的3大特点,很多人可以绝不犹豫地讲出来,封装,继承,多态。封装,和继承自没必要说,而对多态的理解,可能对很多人来讲,总好像理解了,但是好像又有点迷惑,这篇文章侧重介绍这个特性。
多态的定义:指允许不同类的对象对同1消息做出响应。即同1消息可以根据发送对象的不同而采取多种不同的行动方式。这类技术称为动态绑定(dynamic binding),是指在履行期间判断所援用对象的实际类型,根据其实际的类型调用其相应的方法。
现实中,关于多态的例子不胜枚举。比方说按下 F1 键这个动作,如果当前在 Flash 界面下弹出的就是 AS 3 的帮助文档;如果当前在 Word 下弹出的就是 Word 帮助;在 Windows 下弹出的就是 Windows 帮助和支持。同1个事件产生在不同的对象上会产生不同的结果。
多态的作用:消除类型之间的耦合关系,增加程序的灵活性和扩大性。
Java多态性主要体现在以下两个方面。
子类继承父类,重写父类方法,注意的是方法签名必须相同, 返回类型必须是本类或其子类的实例(jdk 1.5 版本以后)。
类内部可以有很多同名的方法,注意的是名称相同, 参数及返回值类型可以不同, 这就叫重载。
要了解多态机制的具体实现机制,就需要深入了解Java 虚拟机对方法的调用进程和分派特性。
首先需要明白,方法调用其实不同等于方法履行,方法调用阶段唯1的任务就是肯定被调用方法的版本(即调用哪个方法),暂时还不触及方法内部的具体运行进程。在程序运行时,进行方法调用是最普遍、最频繁的操作,Class文件的编译进程中不包括传统编译中的连接步骤,1切方法调用在Class文件里面存储的都只是符号援用,而不是方法在实际运行时内存布局中的入口地址(相当于之前说的直接援用)。这个特性给Java带来了更强大的动态扩大能力,但也使得Java方法调用进程变得相对复杂起来,需要在类加载期间,乃至到运行期间才能肯定目标方法的直接援用。
所有方法调用中的目标方法在Class文件里面都是1个常量池中的符号援用,在类加载的解析阶段,会将其中的1部份符号援用转化为直接援用,这类解析能成立的条件是:方法在程序真正运行之前就有1个可肯定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须肯定下来。这类方法的调用称为解析(Resolution)。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都合适在类加载阶段进行解析。
与之相对应的是,在Java虚拟机里面提供了5条方法调用字节码指令,分别以下。
invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>方法、私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再肯定1个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所援用的方法,然后再履行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。
**只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中肯定唯1的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法**4类,它们在类加载的时候就会把符号援用解析为该方法的直接援用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法,后文会提到)。
Java中的非虚方法除使用invokestatic、invokespecial调用的方法以外还有1种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它没法被覆盖,没有其他版本,所以也不必对方法接收者进行多态选择,又或说多态选择的结果肯定是唯1的。,在Java语言规范中明确说明了final方法是1种非虚方法。
解析调用1定是个静态的进程,在编译期间就完全肯定,在类装载的解析阶段就会把触及的符号援用全部转变成可肯定的直接援用,不会延迟到运行期再去完成。而分派(Dispatch)调用则多是静态的也多是动态的,根据分派根据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。
所有依赖静态类型来定位方法履行版本的分派动作称为静态分派。静态分派的典型利用是方法重载。静态分派产生在编译阶段,因此肯定静态分派的动作实际上不是由虚拟机来履行的。另外,编译器虽然能肯定出方法的重载版本,但在很多情况下这个重载版本其实不是“唯1的”,常常只能肯定1个“更加适合的”版本。产生这类模糊结论的主要缘由是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。
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 sd = new StaticDispatch();
sd.sayHello(man);
sd.sayHello(woman);
}
}
运行结果
Hello guy!
Hello guy!
进程分析:
我们把上面代码中的“Human”称为变量的静态类型(Static Type),或叫做的外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以产生1些变化,区分是静态类型的变化仅仅在使用时产生,变量本身的静态类型不会被改变,并且终究的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可肯定,编译器在编译程序的时候其实不知道1个对象的实际类型是甚么。
main()里面的两次sayHello()方法调用,在方法接收者已肯定是对象“sd”的条件下,使用哪一个重载版本,就完全取决于传入参数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定根据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪一个重载版本,所以选择了sayHello(Human)作为调用目标,并把这个方法的符号援用写到main()方法里的两条invokevirtual指令的参数中。
我们把运行期根据实际类型肯定方法履行版本的分派进程称为动态分派,动态分派的典型利用是方法重写。
/**
* 动态分派
*
* @author bridge
*/
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHello();
}
static class Man extends Human {
@Override
protected void sayHello() {
System.out.println("Man say Hello");
}
}
static class Woman extends Human {
@Override
protected void sayHello() {
System.out.println("Woman say Hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}
运行结果:
Man say Hello
Woman say Hello
Woman say Hello
利用 javap -c 命令反编class文件,可以得到Main方法的字节码以下:
public static void main(java.lang.String[]);
Code:
0: new #2 // class methodInvoke/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method methodInvoke/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class methodInvoke/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method methodInvoke/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
24: new #4 // class methodInvoke/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method methodInvoke/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method methodInvoke/DynamicDispatch$Human.sayHello:()V
36: return
字节码分析:
0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的援用寄存在第1、2个局部变量表Slot当中,这个动作也就对应了代码中的这两句:
Human man=new Man();
Human woman=new Woman();
接下来的16~21句是关键部份,16、20两句分别把刚刚创建的两个对象的援用压到栈顶,这两个对象是将要履行的sayHello()方法的所有者,称为接收者(Receiver);
17和21句是方法调用指令,这两条调用指令单从字节码角度来看,不管是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号援用)完全1样的,但是这两句指令终究履行的目标方法其实不相同。缘由就需要从invokevirtual指令的多态查找进程开始说起,invokevirtual指令的运行时解析进程大致分为以下几个步骤:
1)找到操作数栈顶的第1个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描写符和简单名称都符合的方法,则进行访问权限校验,如果通过则返回这个方法的直接援用,查找进程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3)否则,依照继承关系从下往上顺次对C的各个父类进行第2步的搜索和验证进程。
4)如果始终没有找到适合的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令履行的第1步就是在运行期肯定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号援用解析到了不同的直接援用上,这个进程就是Java语言中方法重写的本质。
方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。
单分派是根据1个宗量对目标方法进行选择,多分派则是根据多于1个宗量对目标方法进行选择。
/**
* 单分派与多分派演示
*
* @author bridge
*/
public class Dispatch {
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");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());
}
}
运行结果
Father choose 360
Son choose QQ
Main 方法的字节码以下:
public class methodInvoke.Dispatch {
public methodInvoke.Dispatch();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class methodInvoke/Dispatch$Father
3: dup
4: invokespecial #3 // Method methodInvoke/Dispatch$Father."<init>":()V
7: astore_1
8: new #4 // class methodInvoke/Dispatch$Son
11: dup
12: invokespecial #5 // Method methodInvoke/Dispatch$Son."<init>":()V
15: astore_2
16: aload_1
17: new #6 // class methodInvoke/Dispatch$_360
20: dup
21: invokespecial #7 // Method methodInvoke/Dispatch$_360."<init>":()V
24: invokevirtual #8 // Method methodInvoke/Dispatch$Father.hardChoice:(LmethodInvoke/Dispatch$_360;)V
27: aload_2
28: new #9 // class methodInvoke/Dispatch$QQ
31: dup
32: invokespecial #10 // Method methodInvoke/Dispatch$QQ."<init>":()V
35: invokevirtual #11 // Method methodInvoke/Dispatch$Father.hardChoice:(LmethodInvoke/Dispatch$QQ;)V
38: return
}
分析:
在main函数中调用了两次hardChoice()方法,这两次hardChoice()方法的选择结果在程序输出中已显示得很清楚了。
我们来看看编译阶段编译器的选择进程,也就是静态分派的进程。这时候选择目标方法的根据有两点:1是静态类型是Father还是Son,2是方法参数是QQ还是360。这次选择结果的终究产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号援用。由于是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
再看看运行阶段虚拟机的选择,也就是动态分派的进程。在履行“son.hardChoice(new QQ())”这句代码时,更准确地说,是在履行这句代码所对应的invokevirtual指令时,由于编译期已决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”究竟是“腾讯QQ”还是“奇瑞QQ”,由于这时候参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯1可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。由于只有1个宗量作为选择根据,所以Java语言的动态分派属于单分派类型。
根据上述论证的结果,我们可以总结1句:到目前为止,Java语言是1门静态多分派、动态单分派的语言。
参考资料
【深入理解Java虚拟机】 周志明著
上一篇 PGM:贝叶斯网的参数估计
下一篇 计算从[1,n]的素数个数