Java是一门面向对象的语言,因为Java具备面向对象的三个特性:封装、继承、多态。分派的过程会揭示多态特性的一些最基本的体现,如“重载”和“重写”在Java虚拟机中是如何实现的,并不是语法上如何写,我们关心的依然是虚拟机如何确定正确的目标方法。
一、静态分派
先看一段代码
package cn.zjm.show.polymorphic;
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 man) {
System.out.println("hello,gentleman!");
}
public void sayHello(Woman woman) {
System.out.println("hello,lady!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch s = new StaticDispatch();
s.sayHello(man);
s.sayHello(woman);
}
}
运行结果:
hello,guy!
hello,guy!
相信对Java稍有理解的人都能想到正确的执行结果,但为什么会选择执行参数类型为Human的重载呢?先按如下代码定义两个重要的概念:
Human man = new Man();
我们把上面代码的 Human 称为变量的 静态类型(Static Type),或者叫做 外观类型(Apparent Type),后面的 Man 则称之为变量的 实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是 静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如下面的代码:
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化
s.sayHello((Man) man);
s.sayHello((Woman) man);
运行结果:
hello,gentleman!
hello,lady!
解释了这两个概念,回到第一段代码中。main()里面两次sayHello()方法调用,在方法的接收者已经确定是对象 s 的前提下,使用哪个重载版本,就完全取决于 传入参数的数量和数据类型。代码刻意地定义了两个静态类型相同但是实际类型不同的变量,但编译器在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 最为调用目标。
二、动态分派
动态分派和多态性另一个重要体现——重写(Override)有着很密切的关联。我们还是用前面的Man和Woman一起sayHello的栗子来讲解动态分派。
package cn.zjm.show.polymorphic;
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
这个运行结果不会出乎任何人的预料,对于习惯了面向对象思维的Java程序员或觉得这是理所应当的。现在的问题还和前面的一样,虚拟机是如何知道要调用哪个方法的?
显然这里不可能再根据静态类型来决定,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时执行了不同的行为,并且变量man在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?用javap进行反编译 。
main()方法字节码:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class cn/zjm/show/polymorphic/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method cn/zjm/show/polymorphic/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class cn/zjm/show/polymorphic/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method cn/zjm/show/polymorphic/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method cn/zjm/show/polymorphic/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method cn/zjm/show/polymorphic/DynamicDispatch$Human.sayHello:()V
24: new #4 // class cn/zjm/show/polymorphic/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method cn/zjm/show/polymorphic/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method cn/zjm/show/polymorphic/DynamicDispatch$Human.sayHello:()V
36: return
LineNumberTable:
line 24: 0
line 25: 8
line 26: 16
line 27: 20
line 28: 24
line 29: 32
line 30: 36
}
0~15行的字节码是创建两个对象的过程,分别调用了 Man 和 Woman 类型的实例构造器,将两个实例的引用存放在1、2局部变量表Slot中,这两个动作对应了代码中的这两句:
Human man = new Man();
Human woman = new Woman();
接下来16~21句是关键部分,16句和20句分别把刚刚创建的两个对象的引用压入栈顶,这两个对象是将要执行sayHello()方法的所有者,称为接收者(Receiver);17和21句是方法调用指令,这两条调用指令但从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第6项的常量,第六项常量为:#6 = Methodref #12.#25 // cn/zjm/show/polymorphic/DynamicDispatch$Human.sayHello:()V)都是完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从invokevirtual执行的多态查找过程开始说起,invokevirtual指令的运行时解析过程大致分为以下几个步骤:
1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.langIllegalAccessError异常。
3)否则,按照继承关系从下往上依此对C的各个父类进行第2步的搜索和验证过程。
4)如果始终没有找到合适的方法,则抛出java,lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
静态分派、动态分派、虚拟机、Java