为什么Java的invokevirtual需要解析被调用方法的编译时类?

Chr*_*ris 11 java methods jvm virtual-functions

考虑这个简单的Java类:

class MyClass {
  public void bar(MyClass c) {
    c.foo();
  }
}
Run Code Online (Sandbox Code Playgroud)

我想讨论一下c.foo()行会发生什么.

原创,误导性问题

注意:并非所有这些实际上都发生在每个invokevirtual操作码上.提示:如果您想了解Java方法调用,请不要只阅读invokevirtual的文档!

在字节码级别,c.foo()的内容将是invokevirtual操作码,并且根据invokevirtual 的文档,或多或少会发生以下情况:

  1. 查找编译时类MyClass中定义的foo方法.(这涉及首先解析MyClass.)
  2. 做一些检查,包括:验证c不是初始化方法,并验证调用MyClass.foo不会违反任何受保护的修饰符.
  3. 找出实际调用的方法.特别是,查找c的运行时类型.如果该类型具有foo(),则调用该方法并返回.如果没有,查找c的运行时类型的超类; 如果该类型具有foo,则调用该方法并返回.如果没有,查找c的运行时类型的超类的超类; 如果该类型具有foo,则调用该方法并返回.等等..如果找不到合适的方法,那么错误.

单独的步骤#3似乎足以确定调用哪个方法并验证所述方法具有正确的参数/返回类型.所以我的问题是为什么第一步执行第一步.可能的答案似乎是:

  • 在步骤#1完成之前,您没有足够的信息来执行步骤#3.(乍一看似乎难以置信,所以请解释一下.)
  • 在#1和#2中完成的链接或访问修饰符检查对于防止发生某些不良事件至关重要,并且必须基于编译时类型而不是运行时类型层次结构执行这些检查.(请解释.)

修订问题

行c.foo()的javac编译器输出的核心将是这样的指令:

invokevirtual i
Run Code Online (Sandbox Code Playgroud)

其中i是MyClass的运行时常量池的索引.该常量池条目的类型为CONSTANT_Methodref_info,并将指示(可能是间接的)A)调用方法的名称(即foo),B)方法签名,以及C)调用该方法的编译时类的名称on(即MyClass).

问题是,为什么需要编译时类型(MyClass)的引用?由于invokevirtual将在c的运行时类型上进行动态调度,因此将引用存储到编译时类是不是多余的?

Ita*_*man 4

这一切都与性能有关。当通过计算出编译时类型(又名:静态类型)时,JVM 可以计算出被调用方法在运行时类型(又名:动态类型)的虚函数表中的索引。使用这个索引,第 3 步就变成了对数组的访问,可以在常数时间内完成。不需要循环。

例子:

class A {
   void foo() { }
   void bar() { }
}

class B extends A {
  void foo() { } // Overrides A.foo()
}
Run Code Online (Sandbox Code Playgroud)

默认情况下,A扩展Object定义了这些方法(最终方法被省略,因为它们是通过调用的invokespecial):

class Object {
  public int hashCode() { ... }
  public boolean equals(Object o) { ... }
  public String toString() { ... }
  protected void finalize() { ... }
  protected Object clone() { ... }
}
Run Code Online (Sandbox Code Playgroud)

现在,考虑这个调用:

A x = ...;
x.foo();
Run Code Online (Sandbox Code Playgroud)

通过找出 x 的静态类型,AJVM 还可以找出此调用站点可用的方法列表:hashCode, equals, toString, finalize, clone, foo, bar。在此列表中,foo是第 6 个条目(hashCode第 1 个、equals第 2 个等)。索引的计算只执行一次 - 当 JVM 加载类文件时。

之后,每当 JVM 处理时,x.foo()只需访问 x 提供的方法列表中的第 6 个条目,相当于x.getClass().getMethods[5], (它指向A.foo()x 的动态类型是否为A)并调用该方法。无需详尽地搜索这一系列方法。

请注意,无论 x 的动态类型如何,该方法的索引都保持不变。即:即使x指向B的实例,第6个方法仍然是foo(虽然这次它会指向B.foo())。

更新

[根据您的更新]:您是对的。为了执行虚拟方法分派,JVM 需要的只是方法的名称+签名(或 vtable 内的偏移量)。然而,JVM 不会盲目地执行事情。它首先在称为验证的过程中检查加载到其中的 casfile 是否正确(另请参阅此处)。

验证表达了 JVM 的设计原则之一:它不依赖编译器来产生正确的代码。它在允许代码执行之前检查代码本身。特别是,验证器检查每个调用的虚拟方法实际上是由接收者对象的静态类型定义的。显然,需要接收者的静态类型来执行这样的检查。