JVM内部如何实现多态

本文的翻译是专门为Java Developer课程的学生准备的





在我之前的文章《关于方法重载与方法重载的一切》中 ,我们研究了方法重载和重载的规则和差异。 在本文中,我们将看到如何在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"); } //   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(); 编译器认为将调用Mammal.speak() ,因为humanMammal声明为Mammal 。 但是在运行时,JVM会知道humanMammal包含一个Human对象,并将实际调用Human.speak()方法。

只要我们保持概念上的水平,这一切都非常简单。 但是,JVM如何在内部处理所有这些呢? JVM如何计算应调用哪个方法?

我们还知道重载方法不称为多态方法,而是在编译时解析的。 尽管有时方法重载称为编译时多态性或早期/静态绑定

重写的方法(重写)在运行时解析,因为编译器不知道分配给链接的对象中是否存在重写的方法。

物理角度


在本节中,我们将尝试为上述所有陈述寻找“物理”证据。 为此,请查看通过运行javap -verbose OverridingInternalExample可以获得的字节码。 -verbose参数将使我们能够获得与我们的Java程序相对应的更直观的字节码。

上面的命令将显示字节码的两个部分。

1.常量池 。 它包含了运行程序所需的几乎所有内容。 例如,方法引用( #Methodref ),类( #Class ),字符串文字( #String )。



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



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


在上面的示例中,编译器认为将从Mammal类中调用humanMammal.speak()方法,尽管在运行时将从humanMammal引用的对象中humanMammal它-这将是Human类的对象。

查看我们的代码和javap的结果,我们看到使用了不同的字节码来调用方法humanMammal.speak()human.speak()human.speak("Hindi") ,因为编译器可以根据类引用来区分它们。

因此,在方法过载的情况下,编译器能够在编译时识别字节码指令和方法地址。 这就是为什么将其称为静态链接或编译时多态性的原因。

为什么方法重写称为动态绑定


要调用anyMammal.speak()humanMammal.speak()方法,字节码是相同的,因为从编译器的角度来看,这两种方法都是针对Mammal类调用的:

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

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

答案隐藏在字节码本身和invokevirtual指令中。 根据JVM规范(译者注: 参考JVM规范2.11.8
invokevirtual指令通过分派对象的(虚拟)类型来调用实例方法。 这是Java编程语言中方法的常规分配。
JVM使用invokevirtual在Java方法中调用等效于C ++虚拟方法的方法。 在C ++中,要覆盖另一个类中的方法,必须将该方法声明为virtual。 但是在Java中,默认情况下,所有方法都是虚拟的(最终和静态方法除外),因此在子类中,我们可以覆盖任何方法。

invokevirtual指令采用指向要调用的方法的指针(#4是常量池中的索引)。

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

但是参考文献#4进一步引用了另一个方法和类。

 #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 ()V 

所有这些链接一起使用以获得对方法和所需方法所在的类的引用。 JVM规范中也提到了这一点( 译者注: 对JVM规范2.7的引用 ):
Java虚拟机不需要任何特定的对象内部结构。
在Oracle的一些Java虚拟机实现中,对类实例的引用是对处理程序的引用,该处理程序本身由一对链接组成:一个指向对象方法表,一个指向表示对象类型的Class对象的指针,另一个指向区域包含对象数据的堆上的数据。

这意味着每个引用变量都包含两个隐藏的指针:

  1. 指向包含该对象的方法的表的指针和一个指向Class对象的指针,例如[speak(), speak(String) Class object]
  2. 指向堆上分配给对象数据(例如对象字段值)的内存的指针。

但是,问题再次出现:如何在此方面进行invokevirtual ? 不幸的是,没有人可以回答这个问题,因为这完全取决于JVM的实现,并且因JVM而异。

根据以上推理,我们可以得出结论,对一个对象的引用间接包含指向该表的链接/指针,该表包含对该对象方法的所有引用。 Java从C ++借用了这个概念。 该表以各种名称众所周知,例如虚拟方法表(VMT),虚拟功能表(vftable),虚拟表(vtable),调度表

我们不确定vtable如何用Java实现,因为它取决于特定的JVM。 但是我们可以预期该策略将与C ++中的策略大致相同,其中vtable是一个类似数组的结构,其中包含方法名称及其引用。 每当JVM尝试执行虚拟方法时,它都会在vtable中请求其地址。

对于每个类,只有一个vtable,这意味着该表是唯一的,并且对于该类的所有对象都是相同的,类似于Class对象。 类对象在文章为什么外部Java类不能是静态的以及为什么Java是纯面向对象的语言或为什么不是这样的文章中有更详细的讨论。

因此, Object类只有一个vtable,其中包含所有11种方法(如果不考虑registerNatives话)以及与它们的实现相对应的链接。



当JVM将Mammal类加载到内存中时,它将为其创建一个Class对象并创建一个vtable,该vtable包含Object类的vtable中具有相同引用的所有方法(因为Mammal不会覆盖Object的方法),并为speak()方法添加新条目。



然后,进入Human类的类,并且JVM将所有条目从Mammal类的vtable复制到Human类的vtable,并为speak(String)的重载版本添加新条目。

JVM知道Human类重写了两个方法: Object toString()Mammal speak() 。 现在,对于这些方法,JVM不再使用更新的链接来创建新记录,而是将链接更改为以前存在它们的相同索引中现有方法的链接,并保留相同的方法名称。



invokevirtual指令使JVM处理对方法4的引用中的值,而不是作为地址,而是作为要在vtable中查找当前对象的方法的名称。
我希望现在可以更清楚地了解JVM如何使用常量池和虚拟方法表来确定要调用的方法。
您可以在Github存储库中找到示例代码。

Source: https://habr.com/ru/post/zh-CN467197/


All Articles