多态在底层如何实现? --- Java虚方法详解
面试官:java的多态你能聊聊么?说说是什么是多态?
候选人:多态是指同一种事务的多种形态,在java通过继承就可以实现一个对象的多态性。可以理解为人既是人也是动物,男人和女人都是人。
面试官:那你认为重载属于多态么?说说你的理由。
候选人:。。。
面试官:你知道java是如何实现多态的么?
候选人:。。。
前言
多态作为 Java 三大特性之一自然是十分重要,在各种设计模式中多态的影子也是随处可见。所以能更好更深的理解多态,可以大大提交我们的代码能力,写出优雅的代码。
(我将本文的内容分为两大块用分割线隔开,前半段的内容会讲java中实现多态的基本规则,后半段算是进阶篇主要讲解JVM是如何实现多态的和即时编译器对虚方法的优化。)
目录:
- java中方法的分派机制
- 虚方法与非虚方法
- 重载到底算不算多态?
========分割线========
- 后期绑定
- 字节码指令invokevirtual的详解
- 虚方法表
- 去虚化(devirtualization)
- 类层级分析(Class Hierarchy Analysis,简称CHA)
那么废话不多说,我们开始吧~
Java中方法的分派机制
Java中的分派也被称为动态分派,它实现了Java中的“重写”(Override),而重写则是Java多态性的重要体现。我们先来看一段代码:
public class Person {
public void sayHello(){
System.out.println("Hello person!");
class Man extends Person{
@Override
public void sayHello() {
System.out.println("Hello man!");
class Women extends Person{
@Override
public void sayHello() {
System.out.println("Hello women!");
class DispatchDemo {
public static void main(String[] args) {
Person man = new Man();
Person women = new Women();
man.sayHello();
women.sayHello();
执行结果
Hello man!
Hello women!
这段代码是一段平平无奇的代码,它的执行结果也并不出人意料。但请各位小伙伴思考虚拟机为什么没有采用“Person”的sayHello()而是分别采用了“Man”和“Women”的sayHello()呢?在搞清楚这个问题之前,我想先说两个关键概念。
静态类型与实际类型
Person man = new Man();
上面这段代码中“Person”我们称之为“ 静态类型 ”或“ 外观类型 ”,而“Man”被称为“ 实际类型 ”或“ 运行时类型 ”。他们的区别在于静态类型是编译期可知的,即使在静态变量发生变化时。而实际类型则在编译期不可知,“Person”的类型到底是“Man”还是“Women”则必须等待程序运行时才能确定。
搞清楚了静态类型与实际类型的概念,我们在回到刚才的那段代码中。显然java在选择方法的版本时采用了实际类型,因为Person的两个变量产生了不同的行为。导致这个现象的根本原因就是两个变量实际类型的不同。为了再次确认我们的猜想,下面我们来看看这段代码的字节码:
0 new #2 <com/chris/spring/dispatch/Man> //创建一个类实例的引用,将这个引用压入操作数栈顶
3 dup //复制栈顶的这个引用,并让这个引用入栈(额外多复制一个引用)
4 invokespecial #3 <com/chris/spring/dispatch/Man.<init> : ()V> //调用构造方法(会消耗一个栈顶的引用)
7 astore_1 //将剩余的对象引用存入变量槽_1
8 new #4 <com/chris/spring/dispatch/Women>
11 dup
12 invokespecial #5 <com/chris/spring/dispatch/Women.<init> : ()V>
15 astore_2
16 aload_1
17 invokevirtual #6 <com/chris/spring/dispatch/Person.sayHello : ()V>
20 aload_2
21 invokevirtual #6 <com/chris/spring/dispatch/Person.sayHello : ()V>
24 return
这段字节码中 0-7 与 8-15 我们不用关注(PS:但作为良心博主,我还是把这段字节码的翻译写了出来),它对应的代码就是这两行:
Person man = new Man();
Person women = new Women();
重点在于16-21行,这才是我们应该关注的。 可以看到17行与21行的invokevirtual字节码指令是完完全全一样的,他们甚至指向了同一个常量池索引(index #6) ,很明显这段完全相同的字节码指令在编译期是无论如何都无法判断最终调用的方法版本的,这个动作的完成显然是在运行期。 这类在编译期无法被确定的方法被称为“虚方法” 。
虚方法与非虚方法
与虚方法相反的则是非虚方法, 在Java中符合“编译期可知,运行期不可变”的都可以被称作“非虚方法” ,非虚方法包含:静态方法、私有方法、实例构造器、父类方法和被final修饰的方法。Java为调用不同类型的方法,设计了不同的字节码指令,目前JVM支持的指令有5种,如下:
- invokestatic :用于调用静态方法。
- invokespecial :用于调用实例构造器<init>()方法、私有方法和父类中的方法。
- invokevirtual :调用所有的虚方法(final除外)。
- invokeinterface :用于调用接口方法,会在运行时再确定一个实现对象。
- invokedynamic :先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。它的分派逻辑并不由JVM内部来决定,而是根据用户设定的引导方法来决定的。
这里必须额外说明final修饰的方法,虽然这种方法被invokevirtual调用,但它实际上是一个非虚方法。它在执行时会告知编译器关闭这种“动态绑定”的规则,它的派生类中是必不能有重写的方法的,于是虚拟机可以判断这个虚方法只有一种可能的调用目标。
补充说明:理论上被final修饰的方法在执行时有更好的性能,但实际上并非如此。例如:hotspot可以通过CHA(类层次分析)同样做到判断一个虚方法是否只有一个调用的版本,他们的优化程度是一摸一样的。所以如果单纯为了提高性能而使用final修饰这个方法并不是一个好的选择。
“重载”到底算不算多态?
这个问题事实上一直争论不休,至今一直没有一个准确的答案,可以说是公说公有理婆说婆有理,我个人倾向于“重载”并不算多态。在说明理由前我们先来看看JVM是如何实现“重载”的。
代码:
public class StaticDispatchDemo {
public void sayHello(Person person) {
System.out.println("Hello person!");
public void sayHello(Man man) {
System.out.println("Hello man!");
public void sayHello(Women women) {
System.out.println("Hello women!");
public static void main(String[] args) {
Person man = new Man();
Person women = new Women();
StaticDispatchDemo sdd = new StaticDispatchDemo();
sdd.sayHello(man);
sdd.sayHello(women);
sdd.sayHello((Man) man);
}
执行结果:
Hello person!
Hello person!
Hello man!
上面这段代码中我们可以清楚的看到sdd.sayHello()方法参数的实际类型并没有在重载中起到作用,编译器在重载时采用了静态类型而不是实际类型作为判断的依据,编译期完全可知,这种通过静态类型实现的分派方式被称为“ 静态分派 ”。(PS:静态分派是否是一种“分派”行为,和重载是否属于多态一样存在争议。因为静态分派完全由javac在编译期决定方法调用的版本,这个过程甚至都不需要JVM的参与。)
这里需要注意javac编译器在确认重载版本时并不是“唯一的”。我们再来看一段代码:
public class StaticDispatchDemo02 {
public void sayHello(char c) {
System.out.println("Hello char : " + c);
public void sayHello(int i) {
System.out.println("Hello int : " + i);
public static void main(String[] args) {
StaticDispatchDemo02 sdd2 = new StaticDispatchDemo02();
sdd2.sayHello('1');
}
输出结果:
Hello char : 1
字节码:
0 new #12 <com/chris/spring/dispatch/StaticDispatchDemo02>
3 dup
4 invokespecial #13 <com/chris/spring/dispatch/StaticDispatchDemo02.<init> : ()V>
7 astore_1
8 aload_1
9 bipush 49
11 invokevirtual #14 <com/chris/spring/dispatch/StaticDispatchDemo02.sayHello : (C)V>
14 return
代码执行到这里似乎没有什么不正常的,编译期正确的找到了应该被重载的版本“<com/chris/spring/dispatch/StaticDispatchDemo02.sayHello : (C)V>”
但如果此时我们将第一个方法注释掉呢?
public class StaticDispatchDemo02 {
// public void sayHello(char c) {
// System.out.println("Hello char : " + c);
// }
public void sayHello(int i) {
System.out.println("Hello int : " + i);
public static void main(String[] args) {
StaticDispatchDemo02 sdd2 = new StaticDispatchDemo02();
sdd2.sayHello('1');
}
执行结果:
Hello int : 49
字节码:
0 new #10 <com/chris/spring/dispatch/StaticDispatchDemo02>
3 dup
4 invokespecial #11 <com/chris/spring/dispatch/StaticDispatchDemo02.<init> : ()V>
7 astore_1
8 aload_1
9 bipush 49
11 invokevirtual #12 <com/chris/spring/dispatch/StaticDispatchDemo02.sayHello : (I)V>
14 return
我们可以的发现,此时此刻程序并没有报错,而是重载了参数为int的类型的版本“<com/chris/spring/dispatch/StaticDispatchDemo02.sayHello : (I)V>”
方法的描述符从“(C)V”变成了“(I)V”。这是为什么呢?其根本的原因在于字面量的模糊性,字面量是没有显式的静态类型的,它的静态类型只能从语言、语法的规则去理解和推敲。
如果你还不理解,请再看一段下面的代码:
public class Test01 {
private static final int i = 49;
private static final char c = '1';
}
用这段代码作为比喻其实是十分不贴切的,只是为了让给位小伙伴更好的理解,此时的属性“i”与属性“c”再字段表中是可以区别的,他们有着不同的描述符,我们在这里先抛开这两个属性的描述符不谈 。
我们只关注他们的 Constant Value(常量值),属性“i”和属性“c”他们的常量值索引都指向了常量池中同一个字面量 CONSTANT_Integer_info:49。我们根据这一特性反向推敲一下刚才的重载时编译器的逻辑,对于编译期来说‘1’其实它的本体就是字面量49,至于它的静态类型到底是什么则取决于此时的语法,因为我们在传参的时候并未指定‘1’的静态类型是什么, 所以此时的编译器会根据自己的优先级找到合乎逻辑的重载方法 。
看到这你应该对重载很清楚了,我为什么认为重载不属于多态的一个重要原因是因为我认为java的多态性来自于“分派”这一特征,或者说是对方法的后期绑定,而重载则是在编译期就能完全确定方法的版本。但各位小伙伴也无需纠结于这一点,对于重载是否属于多态的争论事实上是毫无意义的,我们更重要的是更好的了解重载与重写的本质,而不是对一个名词刨根问底。
后期绑定
下面的内容会相对难一些,能坚持看到这里的小伙伴先给你们点个赞。
此刻我们已经对Java中的分派与静态分派有了一些基本的了解,貌似懂了但又没完全懂。JVM到底是如何在运行时进行分派的?我们还没有搞清楚,那我们就继续探索,看看JVM是如何在运行期找到对应的方法版本的。
我们再次将目光回到本文开头展示“重写”的那段字节码继续分析:
0 new #2 <com/chris/spring/dispatch/Man> //创建一个类实例的引用,将这个引用压入操作数栈顶
3 dup //复制栈顶的这个引用,并让这个引用入栈(额外多复制一个引用)
4 invokespecial #3 <com/chris/spring/dispatch/Man.<init> : ()V> //调用构造方法(会消耗一个栈顶的引用)
7 astore_1 //将剩余的对象引用存入变量槽_1
8 new #4 <com/chris/spring/dispatch/Women>
11 dup
12 invokespecial #5 <com/chris/spring/dispatch/Women.<init> : ()V>
15 astore_2
16 aload_1
17 invokevirtual #6 <com/chris/spring/dispatch/Person.sayHello : ()V>
20 aload_2
21 invokevirtual #6 <com/chris/spring/dispatch/Person.sayHello : ()V>
24 return
我们可以看到,虽然17和21行的两段invokevirtual字节码指令虽然完全相同,但细心的小伙伴应该已经发现在这两段字节码执行前有两个aload指令,而这两个指令并不相同。
字节码指令aload:将局部变量表中相应位置的元素压入栈顶
aload_1将局部变量槽中下标为1的对象引用压入栈中,这个引用则刚好是实例对象“Man”的引用,然后字节码invokevirtual根据栈顶的元素执行方法的调用,这个动作是只能发生在运行期的。
这种在运行期根据对象的实际类型进行方法版本绑定的形式,被称为“ 后期绑定 ”,java中后期绑定的机制是实现多态性的关键。
invokevirtual字节码详解
我们现在知道方法的版本会根据操作数栈栈顶元素来决定,但这里似乎还存在一些问题。比如栈顶对象中如果并没有我们要调用的方法呢?
我们来看一段代码:
public class Person {
public void sayHello(){
System.out.println("Hello person!");
class Man extends Person{
class DispatchDemo {
public static void main(String[] args) {
Person man = new Man();
man.sayHello(); //Hello person!
}
字节码:
0 new #2 <com/chris/spring/dispatch/Man>