查看原文
其他

OOP 多态机制在 JVM 中的实现

ImportNew ImportNew 2022-10-28

(给ImportNew加星标,提高Java技能)

编译:ImportNew/覃佑桦

www.coderbuzz.com/2019/11/21/how-does-jvm-handle-polymorphism-internally/



本文将介绍面向对象编程多态机制在JVM中的内部实现。


本文将讨论JVM内部如何处理方法重载与覆写,如何确定应该调用哪个方法。


使用前一篇博客的示例,父类Mammal和子类Human:


public class OverridingInternalExample {
private static class Mammal {
public void speak() {
System.out.println("ohlllalalalalalaoaoaoa");
}
}

private static class Human extends Mammal {
@Override
public void speak()
{
System.out.println("Hello");
}

// Valid overload of speak
public void speak(String language) {
if (language.equals("Hindi"))
System.out.println("Namaste");
else
System.out.println("Hello");
}

@Override
public String toString()
{
return "Human Class";
}
}

// 下面的代码包含输出与方法调用字节码
public static void main(String[] args) {
Mammal anyMammal = new Mammal();
anyMammal.speak();
// Output - ohlllalalalalalaoaoaoa
// 10: invokevirtual #4 // Method org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V
Mammal humanMammal = new Human();
humanMammal.speak();
// Output - Hello
// 23: invokevirtual #4 // Method org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V
Human human = new Human();
human.speak();
// Output - Hello
// 36: invokevirtual #7 // Method org/programming/mitra/exercises/OverridingInternalExample$Human.speak:()V
human.speak("Hindi");
// Output - Namaste
// 42: invokevirtual #9 // Method org/programming/mitra/exercises/OverridingInternalExample$Human.speak:(Ljava/lang/String;)V
}
}


我们可以从实现逻辑和物理实现两种方式回答开头的问题。


实现逻辑


从逻辑上讲,在编译阶段可以根据引用类型确定调用的方法。但实际执行时,会从对象引用的地址调用方法。


例如humanMammal.speak();这行代码,由于humanMammal的类型是Mammal,编译器会调用Mammal.speak()。在执行过程中,JVM知道humanMammal是一个Human对象,因此会调用Human.speak()。


目前为止只是从概念上理解,很简单对吧。当试图理解JVM如何在内部实现这些功能,以及如何计算应该调用哪个方法,就没那么简单了。


此外,我们知道方法重载是在编译时决定的,不能称作多态。这就是为什么有时候方法重载也称为编译时多态、早期绑定或静态绑定


而方法覆写会在运行时解决,因为编译器不知道调用的对象是否覆写了对应的方法。


物理实现


本节会通过阅读字节码查找上面分析对应的物理实现,执行javap -verbose OverridingInternalExample。使用-verbose选项,会得到Java程序对应的描述性字节码。


上面命令得到的字节码包含两部分:


1.常量池:包含了执行程序所需的几乎所有内容,比如方法引用(#Methodref)、类对象(#Class)、字符串(#String)。



2.程序字节码:可执行的字节码指令。



为什么方法重载也称为静态绑定


前面提到的humanMammal.speak(),编译器会从Mammal类中调用speak()。但实际执行中,将从humanMammal对应的Human对象中调用。


从上面的代码和图中可以看到,由于编译器会根据类的不同进区别处理,因此humanMammal.speak()、human.speak()和human.speak("Hindi")的字节码完全不同。


所以,在方法重载情况下,编译器能够在编译时识别字节码指令和方法的地址,这就是为什么方法重载也被称为静态绑定或编译时多态


为什么方法覆写也称为动态绑定


anyMammal.speak()和humanMammal.speak()生成的字节码相同(invokevirtual #4 // Method org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V)。站在编译器的角度看,这两个调用的方法都来自Mammal对象。


现在的问题是,如果两个方法具有相同的字节码,那么JVM如何知道要调用哪一个?


答案就隐藏在字节码中。根据JVM规范,invokevirtual会调用对象的实例方法,并根据对象的(virtual)类型分派调用。这是Java编程语言中普通方法的分派。


JVM使用invokevirtual指令调用Java方法,与C++虚方法类似。在C++中,要覆写另一个类中某个方法,需要将其声明为虚方法。在Java中,所有方法默认都是虚方法(final和static方法除外)。我们可以在子类中覆写父类的每个方法。


invokevirtual操作接受一个指针作为参数,指向方法引用(#4是常量池中的索引)。


invokevirtual #4 // Method org/programming/mitra/exercises //OverridingInternalExample$Mammal.speak:()V


方法引用#4指向的方法名和Class。

#4 = Methodref #2.#27 // org/programming/mitra/exercises/OverridingInternalExample$Mammal.speak:()V
#2 = Class #25 // org/programming/mitra/exercises/OverridingInternalExample$Mammal
#25 = Utf8 org/programming/mitra/exercises/OverridingInternalExample$Mammal
#27 = NameAndType #35:#17 // speak:()V
#35 = Utf8 speak
#17 = Utf8


结合这些引用信息,可以确定具体引用的类和方法。JVM规范中也提到了这一点。


对于#4这样的对象,Java虚拟机不要求对象具备任何特定的内部结构。


规范中还指出:


在Oracle的一些Java虚拟机实现中,对类实例的引用是一个指向句柄的指针,句柄本身也是一对指针:一个指向包含了对象方法的table和表示对象类型的Class对象指针,另一个指向为对象数据分配的堆内存。


这意味着每个对象引用都包含两个隐藏的指针。


  1. table包含了对象方法以及指向Class对象的指针,例如[speak()、speak(String)、Class对象]。

  2. 堆内存中包含了对象数据,例如实例变量值。


那么问题又来了,JVM如何在内部调用virtualual?嗯,这个问题的答案根据JVM的具体实现各有不同。


从上面的内容可以得出结论,一个对象引用间接持有了table指针和Class指针。table中保存了该对象的所有方法引用。Java从C++借用了这个概念,有很多名字,例如virtual method table (_VMT_)、virtual function table (vftable)、virtual table (vtable)、dispatch table。


虽然不能确定vtable在Java中是如何实现的,因为这与具体的JVM相关,但是我们可以预期它将遵循与C++相同的策略。其中vtable是一种类似数组的结构,其中包含方法的名字及方法引用在数组中的索引。当JVM尝试执行虚拟方法时,总会向vtable请求方法的地址。


每个类只有一个vtable,这意味着它是唯一的,而且所有对象的vtable都与Class对象相同。我在“为什么Java外部类不能是静态的”和“Java为什么是或者不是纯粹的面向对象编程语言”文章中介绍了更多Class对象相关内容。


因此,Object类只有一个vtable,其中包含了所有11个方法(不计算registerNatives)以及对各自方法体的引用。



当JVM把Mammal类加载到内存中时,会为它创建一个Class对象及一个vtable。由于Mammal不覆写Object中的任何方法,因此vtable包含了Object类vtable中的所有方法(方法引用相同),同时添加了新的speak方法。



现在轮到Human类,JVM会把Mammal类中所有条目拷贝到Human类的vtable中,并为重载过的speak(String)方法新增条目。


JVM现在知道Human类已经覆写了两个方法,一个是Object的toString(),另一个是Mammal的speck()。现在,JVM不用为这些方法创建新条目,找到已经存在的方法索引更新引用即可,方法名不作修改。



invokevirtual发生时,JVM会把#4存储的值作为方法引用在当前对象的vtable中查找对应方法。


希望现在您已经开始理解JVM是怎样结合常量池和vtable信息决定调用哪个方法。


在Github仓库可以找到完整源代码,欢迎随时反馈提出宝贵的意见。


https://github.com/njnareshjoshi/exercises/blob/master/src/org/programming/mitra/exercises/OverridingInternalExample.java


推荐阅读

(点击标题可跳转阅读)

Java 多态的实现机制

程序员喜欢的 5 款最佳代码比较工具

MySQL 用 limit 为什么会影响性能?


看完本文有收获?请转发分享给更多人

关注「ImportNew」,提升Java技能

好文章,我在看❤️

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存