为什么hashCode方法比类似的方法慢?

36

通常情况下,Java会根据给定调用方上遇到的实现数量来优化虚拟调用。这可以在我基准测试结果中轻松地看出,当您查看返回存储的int的微不足道的方法myCode时。有一个微不足道的

static abstract class Base {
    abstract int myCode();
}

具有一些类似的相同实现,例如

static class A extends Base {
    @Override int myCode() {
        return n;
    }
    @Override public int hashCode() {
        return n;
    }
    private final int n = nextInt();
}

随着实现数量的增加,方法调用的时间从两个实现的0.4纳秒逐渐增长到11.6纳秒,然后缓慢增长。当JVM看到多个实现时,即使用preload=true,时间略有不同(因为需要进行instanceof测试)。

到目前为止都很清楚,但是hashCode的行为则相当不同。特别是,在三种情况下,它的速度要慢8-10倍。有任何想法吗?

更新

我很好奇是否可以通过手动分派来帮助可怜的hashCode,结果大幅改善了。

timing

几个分支完美地完成了工作:

if (o instanceof A) {
    result += ((A) o).hashCode();
} else if (o instanceof B) {
    result += ((B) o).hashCode();
} else if (o instanceof C) {
    result += ((C) o).hashCode();
} else if (o instanceof D) {
    result += ((D) o).hashCode();
} else { // Actually impossible, but let's play it safe.
    result += o.hashCode();
}
注意,由于大多数方法调用比简单字段加载要昂贵得多,编译器避免对超过两个实现进行此类优化,因此与代码膨胀相比,收益很小。原始问题“为什么JIT不像其他方法一样优化hashCode?”仍然存在,而hashCode2证明了它确实可以。更新2:最好sss是正确的,至少有这个笔记。调用扩展Base的任何类的hashCode()与调用Object.hashCode()相同,这是如何在字节码中编译的,如果在Base中添加显式hashCode,那将限制潜在的调用目标调用Base.hashCode()。我不完全确定发生了什么,但声明Base.hashCode()会使hashCode有竞争力。结果2:更新3:提供Base#hashCode的具体实现有所帮助,但JIT必须知道它永远不会被调用,因为所有子类都定义了自己的实现(除非加载另一个子类,这可能会导致取消优化,但对于JIT来说这并不新鲜)。因此,它看起来像是错过了优化机会1。提供Base#hashCode的抽象实现也可以工作。这是有道理的,因为它确保不需要进一步查找,因为每个子类都必须提供自己的实现(它们不能简单地从其祖父继承)。对于超过两个实现,myCode要快得多,编译器必须做一些次优的事情。也许是错过了优化机会2?

我会强调Base的多个实现和扩展,它们深埋在问题中(但标题中根本没有),并且大部分感觉都已经失落了。 - user2864740
你正在运行哪个版本的卡尺?我想自己测试一下。 - Sotirios Delimanolis
嗯... 更多的实现似乎会导致性能损失这个事实让我感到很有趣。我了解到Java使用虚拟方法表来确定调用哪个实现,因此我 认为 深度继承层次结构或多个实现都不应该影响性能,因为在Java中所有方法都是虚拟的,并且由于表格运行时只需查找适当的实现即可。显然,我的理解有所欠缺,可能与不知道这些表格的工作方式有关... - awksp
7
所有上方看得到的短条都源于避免虚方法表查找。最短的那个(0.4纳秒,即每个方法调用一个周期)之所以成为可能,是因为JVM“知道”只有一个实现,并直接内联字段读取。第二短的(0.6纳秒)除此之外,还包含正确预测分支测试'o'确实是'A'实例。第三短的(1.2纳秒)源于在inlinedA.myCode()B.myCode()之间切换。当表真正发挥作用时,就会出现10倍的减速。 - maaartinus
2
@user3580294 覆盖方法可能会对性能产生重大影响。更多信息请参见http://www.javaspecialists.eu/archive/Issue158.html @maaartinus 关于实际问题:hashCode方法受JIT的特殊处理。您可以尝试使用-XX:DisableIntrinsic=_hashCode,或查看http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/a57a165b8296/src/share/vm/opto/library_call.cpp#l3924(和#l3977,#l4000,#l4103 ...搜索“hashCode”)。我无法指出* THE原因*,但猜测它隐藏在那里.... - Marco13
显示剩余8条评论
5个回答

4

hashCode被定义在java.lang.Object中,所以在你自己的类中定义它并没有太多作用。 (虽然它是一个被定义的方法,但实际上没有什么区别)

JIT有几种优化调用站点(在这种情况下是hashCode())的方式:

  • 没有重写 - 静态调用(根本没有虚拟调用) - 具有完全优化的最佳情况
  • 2个站点 - 例如ByteBuffer:精确类型检查,然后静态分派。 类型检查非常简单,但根据使用情况可能或可能不会被硬件预测。
  • 内联缓存 - 当调用方体中使用了很少的不同类实例时,也可以将它们保持为内联 - 这就是一些方法可能被内联,有些方法可能通过虚拟表调用。 内联预算并不是很高。这正是问题的情况 - 不是命名为hashCode()的不同方法将具有内联缓存,因为只有四个实现,而不是虚拟表
  • 添加更多通过该调用者体的类将导致真正的虚拟调用,因为编译器放弃了。

虚拟调用不会被内联,需要通过虚方法表进行间接引用,并且可能导致缓存未命中。缺少内联实际上需要使用堆栈传递参数的完整函数存根。总体而言,真正的性能杀手是无法内联和应用优化。

请注意:调用任何扩展Base类的hashCode()与调用Object.hashCode()相同,并且在字节码中编译为这样,如果在Base中添加显式的hashCode,将限制潜在调用目标调用Base.hashCode()

太多类(在JDK本身中)有重写的hashCode(),因此在没有内联HashMap等结构的情况下,调用是通过虚表执行的 - 即较慢。

额外奖励:加载新类时,JIT必须使现有调用站点失效。


如果有人对进一步阅读感兴趣,我可以尝试查找一些来源


这怎么回答问题呢?显然,在您自己的类中明确定义hashCode()确实会有所不同,而JIT显然不会像内联其他方法一样内联hashCode()。请注意,对于每个测试,“正常”的hashCode()和自定义的myCode()的实现数量完全相同。那么为什么似乎hashCode()myCode()被内联的方式不同呢? - awksp
它确实回答了问题,那就是答案:简单来说,只有少数方法myCode()和大量的hashCode()。后者具有庞大的类层次结构,这阻止了优化 - 因为在JDK本身中定义了大量的hashCode()。再一次强调,没有对hashCode()进行特殊处理:toString()equals(Object)的命运完全相同。 - bestsss
1
hashCode() 可以在许多类中实现,但是 a) 许多这些类没有被加载,b) 正如 @laune 的答案所指出的那样,使用转发实现可以消除 hashCode()myCode() 之间的所有差异,这清楚地表明尽管可用的实现 hashCode() 的类数量很多,但优化是可能的。此外,如果 hashCode() 实现类的数量是一个因素,我不会期望 hashCode() 的计时 a) 在预加载实现类时有显著差异,并且 b) 对于单个实现类来说是相同的。 - awksp
1
那么调用hashCode()的函数可以被内联,尽管存在其他实现hashCode()的类,对吗?所以我不明白你的答案如何适用于这里。我正在使用OP使用的“预加载”——也就是使用代码使类加载器加载所有实现类,尽管只使用其中一些。 确切的基准由OP提供,变化由@laune描述,您应该能够自己复制此问题。如果您想要汇编语言,请在原始帖子上留下评论。 - awksp
2
@user3580294,我添加了一个段落来澄清通过Base.hashCode()调用hashCode()的问题--它们实际上是Object.hashCode(),因为Base没有hashCode()。我认为这非常清楚。因此,在JDK和calipher框架之间,有很多定义hashCode()的类,所以调用是一个Object.v-call。如果您只有一个具有hashCode的类,并且编译器可以将其视为NOT java.lang.Object,那么显然是一个静态调用。这是这里的最后一条评论。 - bestsss
显示剩余6条评论

3

我不确定...在这个 bug 中,case "i_i" 的性能很好,而且我是在 Base 上调用 hashCode,而不是在 Object 上。但原因可能仍然相同。 - maaartinus
如果您在“Base”中定义了“hashCode”,则不会出现性能回归。如果您没有在“Base”中定义“hashCode”,那么即使您编写了“((Base)b).hashCode()”,也将调用“Object.hashCode”。 - apangin

1
我可以确认这些发现。请查看以下结果(省略重新编译):
$ /extra/JDK8u5/jdk1.8.0_05/bin/java Main
overCode :    14.135000000s
hashCode :    14.097000000s

$ /extra/JDK7u21/jdk1.7.0_21/bin/java Main
overCode :    14.282000000s
hashCode :    54.210000000s

$ /extra/JDK6u23/jdk1.6.0_23/bin/java Main
overCode :    14.415000000s
hashCode :   104.746000000s

结果是通过重复调用类SubA extends Base的方法获得的。方法overCode()hashCode()相同,它们都只返回一个int字段。
现在,有趣的部分:如果将以下方法添加到Base类中
@Override
public int hashCode(){
    return super.hashCode();
}
< p > hashCode 的执行时间与 overCode 不再有区别。 < /p >

Base.java:

public class Base {
private int code;
public Base( int x ){
    code = x;
}
public int overCode(){
return code;
}
}

SubA.java:

public class SubA extends Base {
private int code;
public SubA( int x ){
super( 2*x );
    code = x;
}

@Override
public int overCode(){
return code;
}

@Override
public int hashCode(){
    return super.hashCode();
}
}

0
我正在查看你测试的不变量。它将scenario.vmSpec.options.hashCode设置为0。根据this 幻灯片(第37张幻灯片)的说法,这意味着Object.hashCode将使用随机数生成器。这可能是JIT编译器较少关注优化对hashCode 的调用的原因,因为它认为可能会不得不诉诸于昂贵的方法调用,从而抵消了避免vtable查找所带来的任何性能收益。
这也可能是将Base设置为具有自己的哈希码方法可以提高性能的原因之一,因为它防止了掉落到Object.hashCode的可能性。

http://www.slideshare.net/DmitriyDumanskiy/jvm-performance-options-how-it-works


-2

hashCode()的语义比普通方法更复杂,因此当您调用hashCode()时,JVM和JIT编译器必须执行更多的工作,而不是调用普通虚拟方法。

一个特殊情况对性能有负面影响:在空对象上调用hashCode()是有效的并返回零。这需要比常规调用多一个分支,这本身可以解释您所观察到的性能差异。

请注意,由于引入了Object.hashCode(target),这只适用于Java 7。很有趣知道您测试了哪个版本,并且是否会在Java6上出现相同的问题。

另一个特殊情况对性能有积极影响:如果您没有提供自己的hasCode()实现,则JIT编译器将使用内联哈希码计算代码,这比常规编译的Object.hashCode调用更快。

E.


4
你错了。1. null.hashCode() 和其他方法一样也会抛出异常。2. Objects 是一个实用类,不要与Object混淆。3. 不提供hashCode方法意味着继承自Object.hashCode,它被标记为native,但内部(可能)委托给System.identityHashCode。4. 这不是JIT,这只是普通的继承。5. 在基准测试中,myCode 同样是从 Base继承而来; Base.myCode 是抽象的,但这并没有改变什么。 - maaartinus
在阅读Marco13提供的链接中的JVM代码后,我相信我的答案是正确的。请查看inline_native_hashcode方法。 - Eric Nicolas
我非常非常想知道你从哪里得出“在null对象上调用hashCode()是有效的”这一部分,因为任何合理的程序员都会说这会导致NPE。请参见此处。它也在JLS的第15.12.4.4节中,查找要调用的方法:“如果目标引用为null,则在此时抛出NullPointerException。” - awksp
1
此外,JIT编译器能够像本地方法(例如Object#hashCode())一样内联“常规编译”的方法。如果有的话,“常规编译”的hashCode()只返回0或其他常量,几乎可以保证比默认的hashCode()实现更快,特别是在内联之后。maaartinus 的所有观点都是正确的。如果您持有不同意见,那么您需要更具体地说明您的看法。 - awksp
1
@EricNicolas 我也认为答案在 http://hg.openjdk.java.net/jdk8/jdk8/hotspot/file/a57a165b8296/src/share/vm/opto/library_call.cpp#l4000 的 "inline_native_hashcode" 方法的深处隐藏着 - 它谈到了“慢调用”,本地hashCode和identityHashCode的内部函数以及虚表 - 但我不理解它实际上在那里做什么,所以我没有试图将其表述为答案... - Marco13
显示剩余2条评论

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