虚拟机字节码执行引擎【方法调用(二)分派之静态分派】
Java具有面向对象的3个基本特征:继承、封装和多态。分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的,这里的实现指的是虚拟机如何确定正确的目标方法。
静态分派
“分派”(Dispatch)这个词本身具有动态性,一般不应出现在静态语境之中。
为解释静态分派和重载(Overload),以下一段经常出现在面试题中的程序代码。
package com.test;
/**
* 方法静态变量分派演示
* @author huyl
*
*/
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 sr = new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
输出结果:
hello,guy!
hello,guy!
分析:
为什么虚拟机会选择执行参数类型为Human的重载版本?在解决这个问题之前需先了解两个概念。
Human man = new Man();
上面的代码,“Human”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type),而“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类型”(Runtime Type)。
静态类型和实际类型在程序中都可能会发生改变,区别在于静态变量的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
举个例子:
//实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
//静态类型变化
sr.sayHello((Man)human );
sr.sayHello((Woman)human );
对象human的实际类型是可变的,编译期间它完全是个“薛定谔的人”,到底是Man还是Woman,必须等到程序运行到这行的时候才能确定。而human的静态类型是Human,也可以在使用时(如sayHello()方法中的强制转型)临时改变这个类型,但这个改变是在编译期可知的,两次sayHello方法的调用,在编译期完全可以明确转型的是Man还是Woman。
了解了静态类型和实际类型的概念,对以上代码中:main()里面的两次sayHello()方法调用,在方法接收者已经明确是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中故意定义了两个静态类型相同,而实际类型不同的变量,但虚拟机(或准确说是编译器)在重装时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是分派的原因。
需注意Javac编译器虽能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一”的,往往 只能确定一个“相对更适合”版本。这种模糊的结论在由0和1构成的计算机世界中算比较稀罕,产生这种模糊结论的主要原因是字面量天生的模糊性,它不需要定义,所以字面量就没有显式的静态类型,它的静态类型只能通过语言、语法的规则去理解和推断。
“更合适的”版本(重载方法匹配优先级)代码:
package com.test;
import java.io.Serializable;
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 Object");
}
public static void main(String[] args) {
sayHello('a');
}
}
输出结果:
hello char
‘a’是一个char类型的数据,自然会寻找参数类型为char的重载方法,如果注释掉sayHello(char arg)方法,那输出会变为:
hello int
这时发生一次自动类型转换,‘a’除可代表一个字符,还可代表数字97(字符‘a’的Unicode数值为十进制数字97),因此参数类型为int的重载也是合适的,继续注释sayHello(int arg)方法,那输出会变为:
hello long
这时发生两次自动类型转化,'a'转化为整数97后,进一步转化为长整型97L。实际上自动转型还能继续发生多次,按照char>int>long>float>double的顺序转型进行匹配,但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。我们继续注释掉sayHello(long arg),那输出会变为:
hello Character
这是发生了一次自动装箱,‘a’被包装为它的封装类型java.lang.Character,继续注释掉sayHello(Characterarg),那输出会变为:
hello Serializable
因为java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱找不到装箱类,但是找到了装箱类所实现的接口类型,所以紧接着又发生一次自动转型。如果同时出现两个参数分别为Serializable和Comparable<Character>的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示“类型模糊”(Type Ambiguous),并拒绝编译。
下面继续注释掉sayHello(Serializable arg)方法,输出会变为:
hello Object
这是char装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接上层的优先级越低。即使方法调用传入的参数值为null时,这个规则仍然适用。我们继续把sayHello(Object arg)也注释掉,输入出会变成为:
hello char...
可见变长参数的重载优先级是最低的。
静态方法会在编译期确定、在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。