11

考虑这个简单的 Java 类:

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

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

原始的,误导性的问题

注意:并非所有这些都发生在每个单独的调用虚拟操作码上。提示:如果你想了解 Java 方法调用,不要只阅读 invokevirtual 的文档!

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

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

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

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

修改后的问题

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

invokevirtual i

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

问题是,为什么需要对编译时类型(MyClass)的引用?既然invokevirtual 会在c 的运行时类型上做动态调度,那么存储对编译时类的引用不是多余的吗?

4

5 回答 5

4

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

例子:

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

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

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

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

现在,考虑这个调用:

A x = ...;
x.foo();

通过找出 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 不会盲目地执行事情。它首先在称为验证的过程中检查加载到其中的 cassfile 是否正确(另请参见此处)。

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

于 2010-04-01T23:23:18.257 回答
1

这不是我阅读文档后理解的方式。我认为您将第 2 步和第 3 步调换了,这将使整个系列事件更加合乎逻辑。

于 2010-04-01T21:37:09.723 回答
1

据推测,#1 和#2 已经由编译器发生了。我怀疑至少部分目的是确保它们仍然与运行时环境中的类版本保持一致,这可能与编译代码所针对的版本不同。

不过,我还没有消化invokevirtual文档来验证您的摘要,因此 Rob Heiser 可能是对的。

于 2010-04-01T21:43:02.320 回答
1

我猜答案是“B”。

在 #1 和 #2 中完成的链接或访问修饰符检查对于防止某些坏事发生是必不可少的,并且这些检查必须基于编译时类型而不是运行时类型层次结构来执行。(请解释。)

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

...否则,如果 D 无法访问引用的方法(第 5.4.4 节),则方法解析会引发 IllegalAccessError。...

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

于 2010-04-01T23:08:51.253 回答
0

要完全理解这些东西,您需要了解方法解析在 Java 中是如何工作的。如果您正在寻找深入的解释,我建议您阅读《深入了解 Java 虚拟机》一书。第 8 章“链接模型”中的以下部分可在线获取,并且似乎特别相关:

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

感谢 Itay 启发我进行谷歌搜索以找到这个。

于 2010-04-02T01:42:58.890 回答