方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及到方法内部的具体运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这使得java有着更强大的动态扩展能力,但也使得java方法的调用过程变得相对复杂起来,需要在类的加载甚至运行期间才能确定目标方法的直接引用。
按照调用方式共分为两类:
1、解析调用时是静态的过程,在编译期间就是完全确定目标方法,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用。
2、分派调用则即可能是静态,也可能是动态的,根据分派标准可以分为单分派和多分派。这两类分派方式两两组合形成了静态单分派、静态多分派、动态单分派、动态多分派。
解析
在Class文件中,所有方法调用中的目标方法都是常量池中的符号引用,在类加载的解析阶段,会将一部分符号引用转为直接引用,也就是在编译阶段就能够确定唯一的目标方法,这类方法的调用成为解析调用。此类方法主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可访问,因此决定了他们都不可能通过继承或者别的方式重写该方法,符合这两类的方法主要有以下几种:静态方法、私有方法、实例构造器、父类方法。虚拟机中提供了以下几条方法调用指令:
invokestatic:调用静态方法,解析阶段确定唯一方法版本
invokespecial:调用<init>方法、私有及父类方法,解析阶段确定唯一方法版本
invokevirtual:调用所有虚方法
invokeinterface:调用接口方法
invokedynamic:动态解析出需要调用的方法,然后执行
只要被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父方法4类,他们在类加载的时候会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反的,其他方法称为虚方法(除final方法)。
分派
分派调用过程将会揭示多态特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机中是怎样实现的,当然这里的实现并不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。
静态分派
在理解静态分派之前,先看下面一个例子。
class Human{
}
class Man extends Human{
}
class Woman extends Human{
}
public class StaticPai{
public void say(Human hum){
System.out.println("I am human");
}
public void say(Man hum){
System.out.println("I am man");
}
public void say(Woman hum){
System.out.println("I am woman");
}
public static void main(String[] args){
Human man = new Man();
Human woman = new Woman();
StaticPai sp = new StaticPai();
sp.say(man);
sp.say(woman);
}
}
上面代码的执行结果如下:
I am human
I am human
以上结果的得出应该不难分析。在分析为什么会选择参数类型为Human的重载方法去执行之前,先看如下代码:
Human man = new Man();
我们把上面代码中的“Human”称为变量的静态类型或者叫外观类型,后面的“Man”称为变量的实际类型。静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的,而实际类型变化的结果在运行期才可确定。
回到上面的代码分析中,在调用say()方法时,方法的调用者(回忆上面关于宗量的定义,方法的调用者属于宗量)都为sp的前提下,使用哪个重载版本,完全取决于传入参数的数量和数据类型(方法的参数也是数据宗量)。代码中刻意定义了两个静态类型相同、实际类型不同的变量,可见编译器(不是虚拟机,因为如果是根据静态类型做出的判断,那么在编译期就确定了)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,所以在编译阶段,Javac编译器就根据参数的静态类型决定使用哪个重载版本sayHello(Human)。这就是静态分派最典型的应用。
依赖静态类型来定位方法执行的分派动作,称为静态分派。静态分派的最典型的应用就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
动态分派
动态分派和多态的另外一个重要体现——重写有着很密切关系。对于重写,我们已经很熟悉了,那么Java虚拟机是如何在程序运行期间确定方法的执行版本的呢?
解释这个现象,就不得不涉及Java虚拟机的invokevirtual指令了,这个指令的解析过程有助于我们更深刻理解重写的本质。该指令的具体解析过程如下:
- 找到操作数栈栈顶的第一个元素所指向的对象的实际类型,记为C
- 如果在类型C中找到与常量中描述符和简单名称都相符的方法,则进行访问权限的校验,如果通过则返回这个方法的直接引用,查找结束;如果不通过,则返回非法访问异常
- 如果在类型C中没有找到,则按照继承关系从下到上依次对C的各个父类进行第2步的搜索和验证过程
-
如果始终没有找到合适的方法,则抛出抽象方法错误的异常
从这个过程可以发现,在第一步的时候就在运行期确定接收对象(执行方法的所有者称为接受者)的实际类型,所以当调用invokevirtual指令就会把运行时常量池中符号引用解析为不同的直接引用,这就是方法重写的本质。
虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此再虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的”稳定优化“手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable),使用虚方法表索引来代替元数据查找以提高性能
虚方法表中存放着各个方法的实际入口地址(由于Java虚拟机自己建立并维护的方法表,所以没有必要使用符号引用,那不是跟自己过不去嘛),如果子类没有覆盖父类的方法,那么子类的虚方法表里面的地址入口与父类是一致的;如果重写父类的方法,那么子类的方法表的地址将会替换为子类实现版本的地址。
方法表是在类加载的连接阶段(验证、准备、解析)进行初始化,准备了子类的初始化值后,虚拟机会把该类的虚方法表也进行初始化。