多态(polymorphism)是面向对象编程的三大特性之一,它建立在继承的基础之上。在《Java核心技术卷》中这样定义:
一个对象变量可以指示多种实际类型的现象称为多态。
在面向对象语言中,多态性允许你将一个子类型的实际对象赋予给一个父类型的变量。在这样的赋值完成之后,父类变量就可以根据实际赋予它的子类对象的不同,而以不同的方式工作。
在下面的示例中,Son类继承了Father类并重写了f()
方法,又将Son类型的对象赋值给Father类型的变量,再用它调用f()
方法,稍微有点Java基础的程序员都知道,此时会使用的是Son类中的f()
,这种重写就是一种典型的多态的体现。
class Father{ f(){ ... } } class Son extends Father{ f(){ ... } } // 调用代码 Father object = new Son(); object.f();
在一些资料中,也把重载称为一种多态的表现形式,本文也将重载视为多态的一种进行讲解,但这种说法确实尚存争议。
Java虚拟机规范中,为所有的Java虚拟机字节码执行引擎规定了统一的输入输出:
在解释运行阶段,JVM以方法作为最基本的执行单元,栈帧是用于支持虚拟机进行方法调用和执行的数据结构,每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。处于栈顶的栈帧就是当前栈帧,对应的方法就是正在运行的当前方法。
在这里我们以服务解释方法调用为前提,简单说明JVM的运行时栈帧结构。
针对不同类型的方法,Java虚拟机支持以下五种方法调用字节码指令。
<init>()
方法、私有方法和父类中的方法。
非虚方法指那些能够在解析阶段确定唯一的调用版本的方法,即上面由invokestatic
和invokespecial
调用的那些方法。而其他那些属于类的,需要在运行时动态确定调用版本的方法,我们称之为虚方法,最常见的虚方法就是普通的实例方法。
下面我们用字节码的形式看看这些方法调用指令。
// Java代码 public class Test { public static void staticMethod() { System.out.println("static method"); } private void privateMethod() { System.out.println("private method"); } public static void main(String[] args) { Test.staticMethod(); new Test().privateMethod(); } } javac Test.java javap -verbose Test // javap工具得到的main部分的字节码文件 public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=1, args_size=1 0: invokestatic #23 // Method staticMethod:()V 3: new #24 // class Test 6: dup 7: invokespecial #28 // Method "<init>":()V 10: invokevirtual #29 // Method privateMethod:()V 13: return LineNumberTable: line 12: 0 line 14: 3 line 15: 13
在上面的代码中,我们显然可以看到,staticMethod
使用invokestatic
来进行调用,"<init>"
构造方法使用了invokespecial
来调用,这些都符合上面的约定。
但是!作为私有方法的privateMethod
方法,却在字节码中被编译为使用invokevirtrual
指令来调用。这是为什么呢?
笔者查阅资料后,发现在JEP181中,对方法调用字节码指令进行了一定程度上的修改。在Java11版本及以后,嵌套类之间的私有方法的访问权限控制,就从编译期转移到了运行时,从而这样的私有方法也被使用invokevirtual
指令来调用,
总而言之,在Java11及以后,类中的私有方法往往用invokevirtual
来调用,接口中的私有方法往往用invokeinterface
调用,invokespecial
往往仅用于实例构造器方法和父类中的方法。
解析过程是JVM将常量池内的符号引用替换为直接引用的过程。
《Java虚拟机规范》中明确要求在执行方法调用字节码指令之前,必须先对它们使用的符号引用进行解析。即所有invoke...
指令之前。由于对同一个符号引用收到多次解析请求是很常见的事,虚拟机实现可以对第一次解析的结果进行缓存,譬如在运行时直接引用常量池中的记录,并把常量标识为已解析状态,从而避免解析动作重复进行。(invokedynamic有一些特殊性质,这里不做解释)。
方法解析第一步需要解析出方法表的class_index
项中索引的方法所属的类或接口的符号引用,如果解析成功,那么用C表示这个类,接下来虚拟机将按照以下步骤进行后续的方法搜索。
如果我们在解析一个类方法,但C是一个接口,直接抛出java.lang.IncompatibleClassChangeError
异常。
java.lang.IncompatibleClassChangeError
异常。如果通过了第一步,在C中查找是否有简单名称和描述符都与目标匹配的方法,有则返回直接引用。
否则,依次在C的父类、接口列表、父接口中进行查找。如果找到则根据情况返回直接引用或者抛出java.lang.AbstractMethodError
异常。
如果都找不到,说明方法查找失败。抛出java.lang.NoSuchMethodError
。
最后,如果成功返回了直接引用,就对这个方法进行权限验证,如果发现不具备对此方法的访问权限,则抛出java.lang.IllegalAccessError
异常。
已知有类Father
、Son
,且Son
类继承了Father
类。假设我们以以下方式初始化变量。
class Father{} class Son extends Father{} Father object = new Son();
那我们把上面代码中的Father
称为变量object的静态类型或外观类型,将Son
称为object的实际类型或运行时类型。
当变量被定义的时候,它的静态类型就已经确定,而实际类型可能会在运行过程中不断变化,例如下面给出一个例子。
class Father{} class Son extends Father{} class Daughter extends Father{} Father object = new Random().nextBoolean() ? new Son() : new Daughter();
这个例子中,object的静态类型始终是Father
,而实际类型就只有到运行时才知道了。
非虚方法,即使用invokespecial
和invokestatic
指令调用的方法,由于无法被覆盖,不可能存在其他版本,所以可以在类加载的解析阶段直接进行方法解析,将符号引用全部转变为明确的直接引用,不必延迟到运行期完成。
解析调用一定是一个静态的过程,在编译期间就完全确定。
值得说明的一点是,《Java虚拟机规范》明确地将final方法定义为非虚方法,但final方法是使用invokevirtual
调用的,故使用下面讲的分派机制,而非解析。
静态分派用于解释重载的场景,下面给出一个简单的例子
public class Test { public void overLoad(Father father){ System.out.println("get father method"); } public void overLoad(Son father){ System.out.println("get son method"); } public static void main(String[] args) { Test test = new Test(); Father object = new Son(); test.overLoad(object); } } class Father{} class Son extends Father{} //运行结果 get father method
显然,JVM选择了参数类型为Father的重载方法。
在虚拟机处理重载的情况时,是通过参数的静态类型而不是实际类型作为判断依据的。由于静态类型在编译期可知,所以在编译阶段Javac编译器就根据参数的静态类型决定了会使用哪个重载版本。比如上面会选择overload(Father)
作为调用目标,并把这个方法的符号引用写入到main()
方法的invokevirtual
指令的参数中,后续在解释阶段执行invokevirtual
时,这个选好的方法就会直接被使用。这个操作是在Javac前端编译的语法分析阶段直接完成的。
值得注意的是Javac编译器确定的重载版本并非确定的某一个,而是在现有的选择中选择的“最合适的”一个。下面给出一个示例。
public class Overload { // 从上到下,优先级递减 public static void sayHello(char arg) { System.out.println("hello char"); } 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(Object arg) { System.out.println("hello Object"); } public static void sayHello(Serializable arg) { System.out.println("hello Serializable"); } public static void sayHello(char... arg) { System.out.println("hello char ..."); } public static void main(String[] args) { sayHello('a'); } }
假如按照上面的代码运行,那么会被调用的是sayHello(char arg)
方法,这就是Javac认为的最合适的方法。但假如我们将sayHello(char arg)
注释掉,那么会被调用的是sayHello(int arg)
方法,以此类推。
当然,一个脑子正常的程序员,不应该在自己的任何工程中写出上述这样的重载代码。
静态分派用于解释重写的场景,下面给出一个简单的例子
public class Test { public static void main(String[] args) { Father object = new Son(); object.override(); } } class Father{ public void override(){ System.out.println("get father method"); } } class Son extends Father{ public void override(){ System.out.println("get son method"); } } //运行结果 get son method
显然,JVM选择了子类Son的重写方法。显然,在进行动态分派的时候,选择方法的依据是调用方法的变量的实际类型。为了解释清楚invokevirtual
的作用方式,我们使用javap
命令输出这段代码中main
部分的字节码。
public static void main(java.lang.String[]); descriptor: ([Ljava/lang/String;)V flags: (0x0009) ACC_PUBLIC, ACC_STATIC Code: stack=2, locals=2, args_size=1 0: new #7 // class Son 3: dup 4: invokespecial #9 // Method Son."<init>":()V 7: astore_1 8: aload_1 9: invokevirtual #10 // Method Father.override:()V 12: return LineNumberTable: line 3: 0 line 5: 8 line 6: 12
0 ~ 7 行的字节码是一些准备工作。创建了用于存放变量object
的内存空间,调用了对应的构造器,并将对象实例存放在了局部变量表的第一个槽中。实际上对应代码中下面这行。
Father object = new Son();
第 8 行 的aload_1
指令将刚刚创建的object
对象引用压到了操作数栈顶,这个对象即将调用override()
方法。
第 9 行,正式使用了方法调用字节码指令invokevirtual
。根据《Java虚拟机规范》,invokevirtual
指令的运行时解析过程分为以下几步。
invokevirtual
作为参数java.lang.AbstractMethodError
异常。java.lang.NoSuchMethodError
。java.lang.IllegalAccessError
异常。你应该可以看出来,其实就是我们在2.3
节中讲的字节码方法解析。重点就是我们从操作数栈顶找到了第一个元素指向的实际类型,并用它为基础来做接下来的方法查找。这种运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
这里再给出一个示例,帮助读者更深入地了解动态分派。
public class FieldHasNoPolymorphic { static class Father { public int money = 1; public Father() { money = 2; showMeTheMoney(); } public void showMeTheMoney() { System.out.println("I am Father, i have $" + money); } } static class Son extends Father { public int money = 3; public Son() { money = 4; showMeTheMoney(); } public void showMeTheMoney() { System.out.println("I am Son, i have $" + money); } } public static void main(String[] args) { Father gay = new Son(); System.out.println("This gay has $" + gay.money); } } // 输出结果 I am Son, i have $0 I am Son, i have $4 This gay has $2
应该不难理解,第一行的输出来自父类Father构造器调用子类的showmeTheMoney()
方法,此时子类尚未初始化,所以结果为0。
第二行的输出来自子类调用showmeTheMoney()
方法,此时子类已经初始化,结果为4。
第三行的输出,使用gay.money
直接取值,注意这个时候通过静态类型访问变量,自然没有类似invokevirtual
的东西来找所谓的实际类型。所以使用的是变量 gay 的静态类型,那么就从Father
类中取值,取到money
的值为2。
所以,动态分派仅限于方法!
方法的接收者和方法的参数统称为方法的宗量。选择方法时使用一种宗量称为单分派,使用多种宗量称为多分派。那么显而易见的,我们可以总结出Java是一种静态多分派,动态单分派的语言。
注:方法的接收者指调用方法的对象。如
object.f()
,那么object就是方法的接收者。
我们可以想见的是,在代码运行过程中,一个虚方法可能会被大量多次地调用。所以一种在现代JVM中常见的优化手段是创建一个虚方法表,同理对于invokeinterface
指令,也有接口方法表,它们的结构如下所示。
虚方法表中存放的是各种方法的实际入口地址。如果父类的方法在子类中没有重写,那么子类虚方法表中的地址入口和父类虚方法表中的入口地址是一致的,都指向父类的实现。否则子类的地址入口就会指向自己的实现。这样可以节省大量的,动态分派过程中搜索方法的开销。
同时要求在父类和子类的虚方法表中,具有相同签名的方法应该具有相同的索引序号,这样当类型动态发生变化的时候,只需要动态改变要查找的虚方法表,而不需要重新考虑在表中的位置。
虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机就会为该类的虚方法表进行初始化。
方法内联是编译器最重要的优化手段!简单说就是把目标代码以类似复制的方式替换到调用方法的位置,避免发生真实的方法调用。下面是一个示例。
// 内联前的代码 static class C { int val; final int get(){ return val; } } public void f(){ C c = new C(); int x = c.get(); int y = c.get(); int sum = x + y; } // 内联后的代码 public void f(){ C c = new C(); int x = c.val; int y = c.val; int sum = x + y; }
方法内联有两个重要功能
所以我们称方法内联为最重要的优化手段。然而在Java虚拟机中,方法内联却有着一些天生的问题存在。对于Java中的虚方法,在将Java代码翻译为字节码的编译阶段,很多情况下编译器根本不可能确定该使用哪个方法版本。而Java作为面向对象的语言,在Java编程中绝大多数的方法都是虚方法,绝大多数的方法调用都是invokevirtual
或invokeinterface
负责的。
但是方法内联对于优化来说又过于重要,所以Java虚拟机的设计者们想了很多办法来尽量解决问题。
Java虚拟机引入了一种名为类型继承关系分析(CHA)的技术,它用于确定在目前已经加载的类中,那些虚方法是否存在多个版本。根据分析结果的不同,Java虚拟机可以采取不同的处理方法。