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

11

考虑这个简单的Java类:

class MyClass {
  public void bar(MyClass c) {
    c.foo();
  }
}

我想讨论一下在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

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

问题是,为什么需要引用编译时类型(MyClass)?由于invokevirtual将在c的运行时类型上进行动态分派,存储对编译时类的引用是否重复?


这是由于验证的原因。请查看我的下面更新的答案。 - Itay Maman
5个回答

4
这是关于性能的问题。通过确定编译时类型(也称为静态类型),JVM可以计算运行时类型(也称为动态类型)的虚拟函数表中被调用方法的索引。利用这个索引,步骤3仅需访问一个数组,可以在常数时间内完成。不需要循环。
示例:
class A {
   void foo() { }
   void bar() { }
}

class B extends A {
  void foo() { } // Overrides A.foo()
}

默认情况下,A继承Object,该类定义了以下方法(忽略了被通过invokespecial调用的final方法):

class Object {
  public int hashCode() { ... }
  public boolean equals(Object o) { ... }
  public String toString() { ... }
  protected void finalize() { ... }
  protected Object clone() { ... }
}

现在,考虑这个调用:
A x = ...;
x.foo();

通过确定 x 的静态类型为 A,JVM 还可以找出在此调用点可用的方法列表:hashCode、equals、toString、finalize、clone、foo、bar。在此列表中,foo 是第 6 个条目(hashCode 是第一个,equals 是第二个等)。该索引计算只执行一次 - 当 JVM 加载类文件时。
之后,每当 JVM 处理 x.foo() 时,它只需要访问 x 提供的方法列表中的第 6 个条目,相当于 x.getClass().getMethods[5](如果 x 的动态类型为 A,则指向 A.foo()),然后调用该方法。无需详尽搜索这个方法数组。
请注意,方法的索引不会因 x 的动态类型而改变。也就是说:即使 x 指向 B 的实例,第 6 个方法仍然是 foo(尽管这次它将指向 B.foo())。

更新

[根据你的更新]: 你是对的。为了执行虚方法分派,JVM只需要方法名称+签名(或vtable中的偏移量)。然而,JVM并不盲目地执行所有操作。它首先通过一个叫做验证(也可参见这里)的过程检查加载到其中的class文件是否正确。

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


一个小修正是 final 方法会使用 invokevirtual 调用。 - Nándor Krácser

1

在阅读文档后,我不是这样理解的。我认为你把第二步和第三步颠倒了,这会使整个事件序列更合乎逻辑。


假设我把步骤2和3搞反了。(这是有可能的。在我参考的文档中,“已解析命名方法”这句话似乎有歧义。)你是否仍然同意JVM正在对编译时类型进行某种检查,或者你怀疑我也搞错了?(特别是,所有检查都针对运行时类型吗?)我仍然相当自信JVM知道MyClass是与调用foo相关联的编译时类型,即使我对它如何处理这些信息感到模糊不清。 - Chris
在进一步阅读后 :) 1)从invokevirtual的操作数计算出的索引用于查找MyClass的运行时常量池,该常量池将指向对方法的符号引用。类似于:MyClass / foo()V。2)从该符号引用中查找类“MyClass”,并在该类中查找方法“void foo()”,并检查其访问保护。3)检查变量“c”的运行时类型是否具有方法“void foo()”,如果没有,则递归到类层次结构中找到一个。也许它执行第一步是为了快速失败。迈克尔·E可能是正确的 ;) - Rob Heiser

1

很可能,编译器已经完成了#1和#2。我怀疑这个过程的至少一部分目的是确保它们在运行时环境中的类版本仍然与代码编译时使用的版本相同,因为两者可能不同。

我还没有消化invokevirtual文档来验证你的总结,所以Rob Heiser可能是正确的。


1

我猜答案是"B"。

#1和#2中进行的链接或访问修饰符检查对于防止某些不良事件发生至关重要,这些检查必须基于编译时类型执行,而不是运行时类型层次结构。 (请解释。)

#1由5.4.3.3方法解析描述,其中进行了一些重要的检查。例如,#1检查编译时类型中方法的可访问性,如果不可访问,则可能返回IllegalAccessError:

...否则,如果引用的方法对D不可访问(§5.4.4),则方法解析会抛出IllegalAccessError。...

如果您仅检查运行时类型(通过#3),则运行时类型可能会非法地扩大覆盖方法的可访问性(也称为“坏事”)。虽然编译器应该防止这种情况,但JVM仍在保护自己免受流氓代码(例如手动构建的恶意代码)的侵害。


0
为了完全理解这个东西,你需要了解Java中方法解析的工作原理。如果你正在寻找深入的解释,我建议看一下书籍《Java虚拟机内部结构》。第8章“链接模型”的以下部分可在网上找到,并且似乎特别相关:

(CONSTANT_Methodref_info条目是类文件头中描述该类调用的方法的条目。)

感谢Itay激励我进行必要的搜索以找到这个。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接