方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法)。但是Class文件的编译过程不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局的入口地址(相当于直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使方法调用过程变得复杂,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
1. 解析
所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,类加载的解析阶段会将一部分符号引用转化为直接引用,这种解析成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,且这个方法的调用版本在运行期不可变。当然符合这个要求的方法主要包括静态方法和私有方法两大类,前者是与类型直接关联,后者在外部不能被访问,所以他们不会存在其他版本,适合在类加载阶段进行解析。
Java虚拟机提供了5条方法调用字节码指令,分别如下:
- invokestatic:调用静态方法(可在解析时确定方法版本)
- invokespecial:调用实例构造器<init>方法、私有方法和父类方法(可在解析时确定方法版本)
- invokevirtual:调用所有的虚方法
- invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
- invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。上面的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分配逻辑是由用户所设定的引导方法决定的。
能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合条件的有静态方法、私有方法、实例构造器、父类方法4类,他们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法称为非虚方法,与之相反其他方法称为虚方法(除去final方法,因为它也不会被覆盖也就不会有其他版本)。
解析调用一定是个静态过程,即在编译期间就完全确定,类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到执行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。所以这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派共四4种分派组合情况。
2. 分派
2.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,guy!");
}
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 man = new Man()
我们首先要定义两个概念:“Human”称为变量的静态类型,或者叫做外观类型,后面的“Man”则称为变量的实际类型。静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅是在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译期在编译程序的时候并不知道一个对象的实际类型是什么。例如:
//实际类型变化
Human man = new Man();
man = new Woman();
//静态类型变化,使用时发生了变化,但其实本身并未改变
sr.sayHello((Man) man);
sr.sayHello((Woman) man);
知道了这两个概念后,回到上面的代码,main()方法里两次调用sayHello(),要如何确定使用哪个sayHello()呢?取决于传入参数的数量和数据类型。代码中故意定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确说应该是编译器决定的)在重载时是通过参数的静态类型而不是实际类型作为判断依据的。并且静态类型是编译期可知的,因此在编译阶段,Javac编译期会根据参数的静态类型决定使用哪个重载版本。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,而是编译器。但很多情况下编译器确定的方法版本并不是“唯一的”,只能说是一个更合适的版本,即可能一个变量能转为多种参数,而每种参数都有一个对应的方法。产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。例如:
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');
}
}
//代码的执行结果会因为注释掉不同的方法而有所改变,
//但是每一个方法都有执行的可能,只是隐藏了一个优先级
当然,解析和分派两者之间的关系并不是二选一的排他关系,他们是在不同层次上去筛选、确定目标方法的过程。前面说过,静态方法会在类加载期间就进行解析,但静态方法也会有重载版本,选择重载版本的过程也是通过静态分派完成的。
2.2 动态分派
动态分派和多态性的另一个重要体现————重写有很密切的关联。还是看一个例子:
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
此处就不能再根据静态类型来决定调用哪个方法了,因为man和woman两个变量的静态类型都是Human,但是在调用sayHello()时却执行了不同的行为,而且变量man在两次调用中执行了不同的方法。我们需要从Java代码的字节码中找到答案,如下为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 chapter8/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method chapter8/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class chapter8/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method chapter8/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method chapter8/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method chapter8/DynamicDispatch$Human.sayHello:()V
24: new #4 // class chapter8/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method chapter8/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method chapter8/DynamicDispatch$Human.sayHello:()V
36: return
两个对象执行sayHello()的方法就是第17、21两行,即都是执行的invokevirtual指令,而且参数也都是chapter8/DynamicDispatch$Human,但是最后出来的结果并不一样,所以原因就在于invokevirtual指令,此指令运行时解析过程大致分为以下几步:
- 找到操作数栈顶的第一个元素所指向对象的实际类型,记做C。
- 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;否则返回java.lang.IllegalAccessError异常。
- 如果没找到,则按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
- 如果最终没有找到合适方法,则抛出java.lang.AbstractMethodError异常。
所以由于invokevirtual指令执行的第一步就是在运行期间确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
2.3 单分派与多分派
方法的接收者与方法的参数 统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。看一个例子:
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 {
@Override
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}
@Override
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
静态分派
先看一下编译阶段编译器的选择过程,也就是静态分派过程。这时选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360.这次选择的最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
动态分派
再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行son.hardChoice(new QQ())
(对应的invokevirtual指令)时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机不会关心参数QQ是哪一种,即参数的静态类型、实际类型对方法的选择不会构成任何影响,唯一可以影响虚拟机进行选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。
2.4 虚拟机动态分派的实现
动态分派是很频繁的,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法。基于性能考虑最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。如图所示:
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
为了程序实现的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按同一个索引转换出所需的入口地址。方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。