作者:小牛呼噜噜 | https://xiaoniuhululu.com计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」

大家好,我是呼噜噜,好久没更新文章了,今天我们来填个坑,在之前的一篇文章深挖⾯向对象编程三⼤特性 --封装、继承、多态中我们遗留了一个问题:当父类引用指向子类对象时,JVM是如何知晓调用的是哪个子类的方法?

动态绑定和静态绑定

我们下文还是用之前文章的例子,简单修改一下:


(资料图片仅供参考)

public class ClassTest {    static class Animal {        public void eat(){            System.out.println("动物吃饭!");        }        public void work(){            System.out.println("动物可以帮助人类干活!");        }    }    static class Cat extends Animal {        public void eat() {            System.out.println("吃鱼");        }        public void sleep() {            System.out.println("猫会睡懒觉");        }    }    static class Dog extends Animal {        public void eat() {            System.out.println("吃骨头");        }    }    public static void main(String[] args) throws Exception {        Animal cat=new Cat();        cat.eat();        cat.work();      //cat.sleep();//此处编译会报错。    }}

当父类引用指向子类对象时,也就是Animal cat=new Cat();这个也叫做向上转型,重写式多态。

这种多态其实是通过动态绑定(dynamic binding)技术来实现,是指在执行期间判断所引用对象的实际类型,根据其实际的类型调用其相应的方法。也就是说,只有程序运行起来,你才知道调用的是哪个子类的方法。这种多态可通过函数的重写以及向上转型来实现。

与动态绑定相对应的就是静态绑定,指的是在JVM解析时便能够直接识别目标方法的情况。网上有些文章说,重载和静态绑定直接挂钩,这其实是不完全正确的,笔者举个极端的例子:当某个类中的重载方法被它的子类重写时,那它其实通过了动态绑定。

重载指的是方法名相同而参数类型不相同的方法之间的关系,重写指的是方法名相同并且参数类型也相同的方法之间的关系

需要注意的是:本文一直在说程序在运行期间发生的事,而方法调用在静态阶段(编译)以声明的静态类型为准,不管符号引用指向的是哪个实例对象。编译成字节码再进入JVM,进行类加载

我们回到刚刚的例子上:cat.eat();这句的结果打印:吃鱼。程序这块调用我们子类Cat定义的方法,而不是父类的同名方法。cat.work();这句的结果打印:动物可以帮助人类干活!我们上面Cat类没有定义work方法,但是却使用了父类的方法,这是不是很神奇。其实此处调的是父类的同名方法cat.sleep();这句 编译器会提示 编译报错。表明:当我们当子类的对象作为父类的引用使用时,只能访问子类中和父类中都有的方法,而无法去访问子类中特有的方法。虽然向上转型是安全的。但是缺点是:一旦向上转型,子类会丢失的子类的扩展方法,其实就是 子类中原本特有的方法就不能再被调用了。所以cat.sleep()这句会编译报错。

由此我们可以发现规律:当发生向上转型,去调用方法时,首先检查父类中是否有该方法,如果没有,则编译错误;如果有,再去调用子类的同名方法。如果子类没有同名方法,会再次去调父类中的该方法。这种根据对象的实际类型而不是声明类型来选择并调用方法的过程也叫做动态分派(Dynamic Dispatch)但如果直接这样去查找,会发生循环查找,效率较低,为了解决这个问题,虚方法表就出现了,也就是动态绑定的底层原理。

虚方法表与虚方法

JVM 虚方法表(Virtual Method Table),也称为vtable,是动态调度用来依次调用虚方法的一种表结构,是一种特殊的索引表

面向对象编程,会频繁地触发动态分派,如果每次动态分配的过程都要重新在类的方法 元数据中搜索合适的目标的方法,就可能影响到执行效率,所以JVM选择了 用空间换取时间的策略来实现动态绑定,为每个类生成一张虚方法表,然后直接通过虚方法表,使用索引来代替循环查找,快速定位目标方法。

在类加载器与双亲委派机制一网打尽一文中,我们知道 类的生命周期一般有如下图有7个阶段,其中阶段1-5为类加载过程,验证、准备、解析统称为连接虚方法表会在类加载的连接阶段被创建,JVM扫描类的方法信息,识别哪些是虚方法,并在虚方法表中储存其对应的 方法的相关信息以及这些方法在虚拟机内存方法区中的入口地址。这入口地址就是该方法的虚拟方法表的索引,JVM可以通过这个索引地址找到对应的方法。也就是说,每个类的对象都会拥有自己的虚方法表

那什么是虚方法和非虚方法?

非虚方法:如果方法在编译期就确定了具体的调用版本,则这个版本在运行时是不可变的,这样的方法称为非虚方法静态方法。比如私有方法,final 方法,实例构造器,父类方法都是非虚方法,除了这些以外都是虚方法

当Java中发生向上转型,呈现重写式多态时,如果子类没有重写父类方法,子类并不会复制一份父类的方法到自己的虚方法表中,就会去父类的虚方法表中查找 目标方法

子类的重写的方法和父类中的同名方法在字节码层面方法索引通常来说是一样的,如果在子类找到方法eat(),其索引是0,发现不是要调用的方法后,而是要调用父类的eat(),就会直接去父类方法索引为0的地方查找,这样能进一步提高查找效率。

JVM方法调用的指令

从JVM底层来了解方法调用,我们还需知晓 在JVM中和方法调用有关的指令有5种:

invokeinterface:调用接口中的方法,实际上是在运行期决定的,决定到底调用实现该接口的哪个对象的特定方法。invokestatic:调用静态方法。invokespecial: 调用私有实例方法、构造器方法;使用super关键词调用父类的实例方法、构造器;调用所实现接口的default方法invokevirtual:调用非私有实例方法,也就是虚方法,运行期动态查找的过程。invokedynamic: 调用动态方法,JDK7新加入的一个虚拟机指令,相比于之前的四条指令,他们的分派逻辑都是固化在JVM内部,而invokedynamic则用于处理新的方法分派:它允许应用级别的代码来确定执行哪一个方法调用,只有在调用要执行的时候,才会进行这种判断,从而达到动态语言的支持。(Invoke dynamic method)

我们javap来反编译上文例子生成的class文件ClassTest.class:

public com.zj.ideaprojects.demo.test4.ClassTest();    descriptor: ()V    flags: ACC_PUBLIC    Code:      stack=1, locals=1, args_size=1         0: aload_0         1: invokespecial #1                  // Method java/lang/Object."":()V         4: return      LineNumberTable:        line 3: 0  public static void main(java.lang.String[]) throws java.lang.Exception;    descriptor: ([Ljava/lang/String;)V    flags: ACC_PUBLIC, ACC_STATIC    Code:      stack=2, locals=2, args_size=1         0: new           #2                  // class com/zj/ideaprojects/demo/test4/ClassTest$Cat         3: dup         4: invokespecial #3                  // Method com/zj/ideaprojects/demo/test4/ClassTest$Cat."":()V         7: astore_1         8: aload_1         9: invokevirtual #4                  // Method com/zj/ideaprojects/demo/test4/ClassTest$Animal.eat:()V        12: aload_1        13: invokevirtual #5                  // Method com/zj/ideaprojects/demo/test4/ClassTest$Animal.work:()V        16: return      LineNumberTable:        line 30: 0        line 31: 8        line 32: 12        line 34: 16    Exceptions:      throws java.lang.Exception

我们可以发现: Java 中所有非私有实例方法调用都会被编译成 invokevirtual指令,而接口方法调用都会被编译成 invokeinterface 指令。这两种指令,均属于Java 虚拟机中的虚方法调用,会进行函数的动态绑定。

invokevirtual指令在执行时,首先在运行期确定方法接收者的实际类型,并不是把常量池中方法的符号引用(在这里相当于常量池里的方法信息)解析到直接引用上就结束了,而是接着根据方法接收者的实际类型来选择方法版本,这个过程也就是Java多态的本质。

针对于invokeinterface指令来说,虚拟机会建立一个叫做接口方法表的数据结构(interface method table,简称itable),和虚方法表类似。

另外,当我们了解invokespecial指令,invokestatic指令时,可以知晓,父类引用在调用静态方法,私有方法或是接口default方法是不会发生多态,而是直接调用声明类型的方法。

在Java 8中Lambda表达式和默认方法时,底层会生成和使用invokedynamic,很有意思的一个指令,本文就不详细介绍该指令了,以后有机会再讲讲。

小结

小结一下,本文主要讲解了方法调用在Java虚拟机的实现方式,以及虚方法表在 JVM 方法调用中充当了一个中介的角色,使得 JVM 能够实现多态性和动态分派。最后带大家了解一下JVM常见的方法调用的指令,Java可不仅仅只有CRUD哦

参考资料:

https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.2

《Java虚拟机规范》

《深入理解Java虚拟机:JVM高级特性与最佳实践第3版》

全文完,感谢您的阅读,如果我的文章对你有所帮助的话,还请点个免费的,你的支持会激励我输出更高质量的文章,感谢!

原文镜像:聊聊JVM虚方法表和方法调用

计算机内功、源码解析、科技故事、项目实战、面试八股等更多硬核文章,首发于公众号「小牛呼噜噜」,我们下期再见!

推荐阅读

更多 >

最近更新

更多 >