今天讲一下分派调用,首先,分派调用是一种方法调用形式,它可能为静态的,也可能为动态的,还可分为单分派和多分派。
之所以讲分派调用,是因为其过程将会揭示多态性特征的一些最基本的体现,比如大家关心的“重载”和“重写”。
静态分派–方法重载首先看一段面试代码:
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的重载版本呢?先看下面这行代码:
Human man = new Man();
在以上代码中,我们称“Human”为变量man的“静态类型”,或者叫“外观类型”,后面的“Man”称为变量的“实际类型”或者叫“运行时类型”。
两者最重要的区别在于:
- 最终的静态类型是在编译期可知的;实际类型在运行时才可确定,编译期并不知道一个对象的实际类型。
在代码中,man和woman的静态类型相同,而实际类型却不同。但是虚拟机(准确的说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。
所有依赖静态类型来决定方法执行版本的分派动作,被称为静态分派。静态分派的最典型应用表现就是方法重载。
静态分派发生在编译阶段。因此确定静态分派动作的是编译器,而非虚拟机。
重载方法-选择相对适合版本再看一段代码:
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');
}
}
上面的代码运行后会输出:
hello char
这很好理解,因为’a’是char类型数据,现在注释掉sayHello(char arg)方法,输出变为:
hello int
这里发生了一次自动类型转换,因为’a’还可以表示数字97(Unicode数值),现在注释掉sayHello(int arg),输出变为:
hello long
这里发生了两次自动类型转换,整数97转型为长整数97L,现在注释掉sayHello(long arg),输出变为:
hello character
这时发生了一次自动装箱,'a’被包装为它的封装类型Character,继续注释掉sayHello(Character arg),输出变为:
hello Serializable
这是因为Serializable是Character实现的一个接口,'a’自动装箱后发现没有装箱类,但是找到了装箱类实现的接口,所以发生了一次自动转型。
注意,当出现两个优先级一样的参数的重载方法时,编译器无法确定要转型为哪种类型,因此提示类型模糊,并拒绝编译。下面继续注释掉sayHello(Serializable arg),输出会变为:
hello object
这时是char装箱后转型为父类,如果有多个父类,那将在继承关系中从下往上开始搜索,越接上层的优先级越低,最后注释掉sayHello(Object arg),输出会变为:
hello char...
可见变长参数的重载优先级是最低的。
动态分派–方法重写Java语言里动态分派的实现过程与Java语言的多态性的另一体现——重写(Override)有密切关联。请先看下面的代码:
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();
}
}
输出结果符合面向对象的思维模式,那么虚拟机是如何判断调用哪个方法的呢?
显然这里选择调用的方法版本是不可能再根据静态类型来决定的,因为静态类型均为Human,然而它们的实际类型是不同的,那么Java虚拟机是如何根据实际类型来分派方法执行版本的呢?
下面使用javap命令输出这段代码的字节码:
javap -c DynamicDispatch
Compiled from "DynamicDispatch.java"
public class polymorphic.DynamicDispatch {
public polymorphic.DynamicDispatch();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class polymorphic/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method polymorphic/DynamicDispatch$Man."":()V
7: astore_1
8: new #4 // class polymorphic/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method polymorphic/DynamicDispatch$Woman."":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method polymorphic/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method polymorphic/DynamicDispatch$Human.sayHello:()V
24: new #4 // class polymorphic/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method polymorphic/DynamicDispatch$Woman."":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method polymorphic/DynamicDispatch$Human.sayHello:()V
36: return
}
0-15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,对应于Java源码中以下两行:
Human man = new Man(); Human woman = new Woman();
接下来的16-21行是关键部分。16和20行的aload指令分别把刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello方法的所有者,称为接收者。而17和21行单从字节码指令角度是完全相同的,但是最终执行的目标方法却完全不同,因此需要搞清楚invokevirtual指令。
根据《Java虚拟机规范》,invokevirtual指令执行的第一步为在运行期确定接收者的实际类型,因此调用该指令时会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。
这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
由以上还可以看出,这种多态性的根源在于虚方法调用指令invokevirtual的执行逻辑,因此得出的结论也只适用于方法,而非字段,因为字段不使用这条指令。而且,实际上Java中只有虚方法,而没有虚字段,因此字段不会参与多态,当子类声明了与父类同名的字段时,虽然在子类的内存里两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。
下面是说明字段没有多态性的示例:
public class FiledHasNoPolymorphic {
static class Father{
public int money = 1;
public Father() {
money = 2;
showMeTheMoney();
}
public void showMeTheMoney(){
System.out.println("Father, has $" + money);
}
}
static class Son extends Father{
public int money = 3;
public Son() {
money = 4;
showMeTheMoney();
}
@Override
public void showMeTheMoney() {
System.out.println("Son, has $" + money);
}
}
public static void main(String[] args) {
Father guy = new Son();
System.out.println("guy, has $" + guy.money);
}
}
输出如下:
Son, has $0 Son, has $4 guy, has $2
输出两句都是“Son”,这是因为在Son类创建的时候,首先会隐式调用其父类的构造函数,而父类构造函数中对showMeTheMoney()的调用是一次虚方法调用,因此会根据实际类型Son来执行相应的Son::showMeTheMoney()方法,而这时候虽然父类的money字段已经是2了,但是Son::showMeTheMoney()方法中访问的是Son类的money字段,这时候结果是零,因为子类的构造函数还未执行,所以子类的money还未进行初始化。而后面两行的输出结果也就顺理成章了。



