一、前言
我们在初学Java的时候就知道Java是一门面向对象的编程语言,而面向对象的编程语言有三大特性:多态、继承、封装。封装继承自不必说,那么大家在初学Java的时候想过Java是如何实现多态的吗,说实话我就没有想过,毕竟这些实现对我来说是透明的,我只要会用多态就可以了,但是随着学习的深入,发现在不清楚原理的情况下,对于多态的运用总是感觉很陌生,终于在学习《深入理解Java虚拟机》这本书时,书中给出了解答,所以写一篇文章,增加一下印象,也希望能帮助到他人吧。
二、方法调用
在介绍Class文件的时候我们知道,Class文件的编译过程并不包含传统编译的连接阶段,Class文件中方法都是以符号引用的形式存储的,而不是方法的入口地址(直接引用)。这个特性使得Java具有强大的动态扩展的能力,但同时也增加了Java方法调用过程的复杂性,因为方法需要在类加载期间甚至是运行时才能确定真正的入口地址,即将符号引用转换为直接引用。
这里所说的方法调用并不等同于方法执行,这个阶段的唯一目的就是确定被调用方法的版本,还不涉及方法内部的具体运行过程。对于方法的版本,需要解释的就是由于重载与多态的存在,一个符号引用可能对应多个真正的方法,这就是方法的版本。
在Java虚拟机中提供了5条方法调用的字节码指令,分别是:
-
invokestatic
:调用静态方法; -
invokespecial
:调用实例构造器<init>
方法、私有方法和父类方法; -
invokevirtual
:调用所有的虚方法; -
invokeinterface
:调用接口方法,会在运行时再确定一个实现此接口的对象; -
invokedynamic
:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑都是固化在Java虚拟机中的,而invokedynamic
指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic
和invokespecial
指令调用的方法,都可以在类加载过程中的解析阶段中确定唯一的调用版本,符合这个条件的方法有静态方法、私有方法、实例构造器和父类方法四种,它们在类加载过程中的解析阶段就会将符号引用解析为该方法的直接引用。这些方法可以称为非虚方法(也就是不涉及到多态的方法),与之对应的就是虚方法(也就是涉及到多态的方法)(除去final
方法,后面会有介绍)。虚方法需要在运行阶段才能确定目标方法的直接引用。这样,对于方法的调用就分为两种,一种可以在类加载过程中的解析阶段完成,另一种要在运行时完成,叫做分派。
三、解析
在类加载过程中,我们知道解析阶段就是将符号引用转换为直接引用的过程,在这个阶段,会将Class文件中的一部分方法的符号引用解析为直接引用,这种解析能够成立的条件是,方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期间是不变的。也就是说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。很明显复合条件的方法就是非虚方法。
下面的代码演示了一个最常见的解析调用的例子,代码如下:
package temp;
public class HelloWprld {
public HelloWprld() x{
}
private void myMethod() {
System.out.println("My Method");
}
public static void sayHello() {
System.out.println("Hellow");
}
public static void main(String[] args) {
new HelloWprld().myMethod();
sayHello();
HelloWprld helloWprld = new HelloWprld();
}
}
这里测试了实例构造器方法、静态方法、私有方法三种的方法调用,程序编译后,可以使用javap -verbose HelloWprld.class
指令得到这个类的字节码指令,部分内容如下:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=2, args_size=1
0: new #6 // class temp/HelloWprld
3: dup
4: invokespecial #7 // Method "<init>":()V
7: invokespecial #8 // Method myMethod:()V
10: invokestatic #9 // Method sayHello:()V
13: new #6 // class temp/HelloWprld
16: dup
17: invokespecial #7 // Method "<init>":()V
20: astore_1
21: return
我们可以发现,对于非虚方法,确实使用invokespecial
和invokestatic
调用的。
四、分派
正是由于多态的存在,使得在判断方法调用的版本的时候会存在选择的问题,这也正是分派阶段存在的原因。这一部分会在Java虚拟机的角度介绍重载和重写的底层实现原理。
分派调用既可能是静态的也可能是动态的,根据分派的宗量数可以分为单分派和多分派,这两类分派方法的两两组合就构成了静态单分派、静态多分派、动态单分派和动态多分派四种。我们首先讲解静态分派和动态分派。
1、静态分派
首先先看一段代码:
package temp;
public class StaticDispatch {
static class Human{
}
static class Man extends Human{
}
static class Woman extends Human{
}
public void sayHello(Human human){
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 sr=new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
}
}
运行结果是:
我们来探究一下为啥是这样的结果:
首先需要了解两个概念:静态类型、实际类型,静态类型可以理解为变量声明的类型,比如上面的man这个变量,它的静态类型就是Human。而实际类型就是创建这个对象的类型,man这个变量的实际类型就是Man。这两种类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会发生变化,并且最终的静态类型是编译期间可知的。而实际类型变化的结果在运行期才可以确定,编译器在编译程序时并不知道一个对象的实际类型是什么。比如下面的代码:
//实际类型变化
Human man=new Man();
man=new Woman();
//静态类型变化
sr.sayHello((Man)man);
sr.sayHello((Woman)man);
我们只有在还没有编译代码的时候可以修改变量的静态类型,比如类型的强制转化,但是我们却可能在运行期间修改变量的实际类型,比如在代码中将引用指向一个其他的对象。
了解了这两个概念之后,回头看看上面的代码。main
方法里的两次sayHello
方法调用,在方法接收者已经确定是对象sr
的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。但是这里的代码定义了两个静态类型相同但实际类型不同的变量,编译器在重载时是通过静态类型而不是实际类型作为判断依据的。并且静态类型是编译期间可知的,因此,在编译阶段,Javac
编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)
这个版本作为调用目标,并把这个方法的符号引用写到main
方法里的invokevirtual
指令的参数中。下面是虚拟机执行的字节码指令:
我们可以看到调用的确实是sayHello(Human)
这个版本的方法。
所有依赖静态类型来定位方法版本的分派动作叫做静态分派,静态分派的典型应用是方法重载。静态分派发生在编译期间,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只是一个相对来说更加合适的版本。上面介绍了对于有明显的静态类型的情况下编译器进行静态分派是如何选择的,那么对于没有显式的静态类型的字面量时,虚拟机会如何应对呢?我们先看一下程序:
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 Int
。这就是说在确定方法时,如果静态类型没有匹配的,可以发生类型转换,这里将a
转换为了数字97
,然后调用参数是int
类型的版本。
那么我们再把这个方法也注释掉
执行结果:
结果又变为了Hello Long
。这又发生了一次类型转换,将97
转换为了long
。
这种类型转换会按照char->int->long->float->double
的顺序继续下去。但不会转换到byte
和short
,因为这种转换是不安全的。
这里就不一一的实验了,直接总结一下规律:
字面量会先按照char->int->long->float->double
这样的顺序去查找相应的方法,如果找不到,会按照自动装箱的类型(int
对应Integer
、char
对应Character
)进行查找,如果还没有相应的方法,会找自动装箱后的对象的接口作为参数的方法,如果还没有,会找相应的父类作为参数的方法,直到Object
,如果还没有,则会选择变长参数的方法。
上面演示了编译期间选择静态分派的目标的过程,这也是Java语言实现方法重载的本质。这里需要注意,静态分配和解析之间并不是排他的关系,而是不同层次上的筛选,静态方法也是可以拥有重载版本的,也是通过静态分派来实现重载的。
2、动态分配
在了解了静态分派后,再看看动态分派的过程,它和多态性的另一个重要的特性重写有关。下面用一个例子来介绍,代码如下:
package temp;
public class DynamicDispatch {
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
public void sayHello(){
System.out.println("Hello gentleman");
}
}
static class Woman extends Human{
public void sayHello(){
System.out.println("Hello lady");
}
}
public static void main(String[] args) {
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
}
}
运行结果:
这个结果对于熟悉Java面向对象编程的人来说都不陌生。这里要说明的是,虚拟机是如何知道要调用哪个版本的。
显然这不是根据静态类型决定的,因为两个对象的静态类型都是Human。但是调用的结果却不同,这是因为这两个对象的实际类型不同。所以,Java虚拟机是通过实际类型来判断要调用方法的版本的。
不过Java虚拟机又是如何做到的呢?我们来看一下字节码指令:
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 temp/DynamicDispatch$Man
3: dup
4: invokespecial #3 // Method temp/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #4 // class temp/DynamicDispatch$Woman
11: dup
12: invokespecial #5 // Method temp/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #6 // Method temp/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #6 // Method temp/DynamicDispatch$Human.sayHello:()V
24: new #4 // class temp/DynamicDispatch$Woman
27: dup
28: invokespecial #5 // Method temp/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #6 // Method temp/DynamicDispatch$Human.sayHello:()V
36: return
0~15行是准备阶段,是为了建立man
和woman
的内存空间,调用man
和woman
的类实例构造器,然后将这两个实例的引用放在局部变量表中的第一和第二的位置。
接下来的16~21是方法调用的关键。16、20两句分别把刚才创建的两个对象的引用压入栈顶,这两个对象是将要执行的sayHello
方法的所有者,称为接收者;17和21两句诗方法调用指令,这两条指令在这里看来都是一样的,指令都是invokevirtual
,参数也都是一样的,但这两条指令最终执行的结果却不同。原因就在invokevirtual
指令的多态查找过程上。invokevirtual
指令的运行时解析过程大致分为以下几个步骤:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记为C;
- 如果在类型C中找到与常量中的描述符和简单名称一样的方法,,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,返回
java.lang.IllegalAccessError
异常; - 否则,按照继承关系从下到上依次对C的各个父类进行搜索和验证;
- 如果还没有找到合适的方法,抛出
java.lang.AbstractMethodError
异常。
由于invokevirtual
指令执行的第一步就是在运行期间确定接收者的实际类型,所以两次调用中的invokevirtual
指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程叫做动态分派。
3、单分派和多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是基于多个宗量。
下面以一个例子介绍一下单分派或多分派,代码如下:
package temp;
public class Dispatch {
static class Football{}
static class Basketball{}
public static class Father{
public void like(Football f){
System.out.println("父亲喜欢足球");
}
public void like(Basketball b){
System.out.println("父亲喜欢篮球");
}
}
public static class Son extends Father{
public void like(Football f){
System.out.println("儿子喜欢足球");
}
public void like(Basketball b){
System.out.println("儿子喜欢篮球");
}
}
public static void main(String[] args) {
Father father=new Father();
Father son=new Son();
father.like(new Basketball());
son.like(new Football());
}
}
运行结果:
先看看静态分派过程,这个时候选择的依据有两个:静态类型是Father
还是Son
,方法参数是还Football
是Basketball
。这次选择产生了两个invokevirtual
指令,两条指令的参数分别为常量池中指向Father.like(Football)
和Father.like(Basketball)
方法的符号引用。
因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。
然后看看运行时虚拟机的选择,即动态分派过程。在执行son.like(new Football());
时,也就是说在执行invokevirtual
指令时,由于编译期间已经决定目标方法的签名必须是like(Football)
,虚拟机此时不会关心传递过来的参数是什么,因为这时参数的静态类型、实际类型都对方法的选择不会构成影响,唯一有影响的就是方法的接收者的实际类型是Father
还是Son
。因为只有一个宗量,所以Java的动态分派属于单分派。
四、虚拟机如何实现动态分派
由于动态分派是非常频繁的操作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此虚拟机会进行优化。常用的方法就是为类在方法区中建立一个虚方法表(Virtual Method Table
,在invokeinterface
执行时也会用到接口方法表,Interface Method Table
),使用虚方法表索引来替代元数据查找以提升性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了父类的方法,子类方法表中的地址会替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类和子类的虚方法表中都应该具有一样的索引号,这样当类型变换时,仅仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。