Java hashCode(): 重写比原生实现更快?

7

关于以下基准测试,我有点惊讶于hashCode()方法的默认(本机)实现速度出乎意料地慢约50倍。

考虑一个基本的Book类,它没有覆盖hashCode()方法:

public class Book {
private int id;
private String title;
private String author;
private Double price;

public Book(int id, String title, String author, Double price) {
    this.id = id;
    this.title = title;
    this.author = author;
    this.price = price;
}
}

考虑另一种情况,一个与之前相同的Book类,名为BookWithHash,它重写了hashCode()方法,并使用了Intellij中的默认实现:

public class BookWithHash {
private int id;
private String title;
private String author;
private Double price;


public BookWithHash(int id, String title, String author, Double price) {
    this.id = id;
    this.title = title;
    this.author = author;
    this.price = price;
}

@Override
public boolean equals(final Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;

    final BookWithHash that = (BookWithHash) o;

    if (id != that.id) return false;
    if (title != null ? !title.equals(that.title) : that.title != null) return false;
    if (author != null ? !author.equals(that.author) : that.author != null) return false;
    return price != null ? price.equals(that.price) : that.price == null;
}

@Override
public int hashCode() {
    int result = id;
    result = 31 * result + (title != null ? title.hashCode() : 0);
    result = 31 * result + (author != null ? author.hashCode() : 0);
    result = 31 * result + (price != null ? price.hashCode() : 0);
    return result;
}
}

接下来,以下 JMH 基准测试的结果表明,Object 类中默认的 hashCode() 方法比 BookWithHash 类中更复杂的 hashCode() 实现要慢近50倍:

public class Main {

public static void main(String[] args) throws RunnerException {
    Options opt = new OptionsBuilder().include(Main.class.getSimpleName()).forks(1).build();
    new Runner(opt).run();
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public long bookWithHashKey() {
    long sum = 0L;
    for (int i = 0; i < 10_000; i++) {
        sum += (new BookWithHash(i, "Jane Eyre", "Charlotte Bronte", 14.99)).hashCode();
    }
    return sum;
}

@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
public long bookKey() {
    long sum = 0L;
    for (int i = 0; i < 10_000; i++) {
        sum += (new Book(i, "Jane Eyre", "Charlotte Bronte", 14.99)).hashCode();
    }
    return sum;
}
}

总结的结果表明,在BookWithHash类上调用hashCode()比在Book类上调用hashCode()快一个数量级(完整的JMH输出请见下文):Summarized JMH

我感到惊讶的原因是,我理解默认的Object.hashCode()实现(通常)是对象初始内存地址的哈希值,这应该在微体系结构层面上非常快。这些结果似乎暗示了相对于上述简单覆盖的方法,内存位置的哈希化Object.hashCode()的瓶颈。我很欣赏其他人对我的理解和可能导致这种令人惊讶的行为的原因的见解。


JMH完整输出:

Full JMH output


5
你实际上需要对哈希码进行某些操作(例如将它们相加),这样整个循环不会因为没有修改任何数据而被优化掉。 - Aplet123
@Aplet123 不错的想法,我按照建议更新了基准测试(对哈希码求和)。结果与之前有所不同,但仍然相差50倍。 - bcf
我没有JMH,但我做了一个简单的重复测试,并得到了与你类似的结果。但是,只需在nohash版本中加入public int hashCode() { return 0;},它们都需要大约相同的时间。 - WJS
我得到了与@WJS大致相同的结果,但在10亿次重复的情况下,本地哈希实际上总是比覆盖哈希快约0.5秒。 - Ecto
2
就默认的hashCode实现而言,它使用了Marsaglia的异或移位线性随机数生成算法的线程本地变体来计算值(https://github.com/openjdk/jdk/blob/master/src/hotspot/share/runtime/synchronizer.cpp#L838),然后将其存储在对象头中。在最好的情况下,可以从对象头中加载缓存的值,但由于您每次都创建新对象,我认为您每次都会命中慢路径,这是一种非常慢的本地调用(即相对较慢,因为其中没有任何内容可以被优化)。 - Jorn Vernee
2个回答

6
你误用了JMH,所以基准分数没有太大意义。
1. 通常不需要在基准测试中循环运行某个程序。 JMH会以一种防止JIT编译器过度优化正在测量的代码的方式自己运行基准测试循环。 2. 需要消耗代码的结果和副作用,通过调用Blackhole.consume或从方法返回结果来实现。 3. 代码的参数通常是从@State变量中读取的,以避免常量折叠和常量传播。
在你的情况下,BookWithHash对象是暂时的:JIT意识到对象没有逃逸,因此完全消除了分配。此外,由于一些对象字段是常量,JIT可以使用常量而不是读取对象字段来简化hashCode计算。
相反,默认的hashCode依赖于对象的身份。这就是为什么不能消除Book的分配。因此,你的基准测试实际上是比较20000个对象(注意Double对象)的分配与本地变量和常量的算术操作。毫不奇怪,后者更快。
另一个需要考虑的事情是,第一次调用ID hashCode比后续调用要慢得多,因为需要首先生成hashCode并将其放入对象头中。这反过来需要调用VM运行时。 第二次和以后的hashCode调用只会从对象头中获取缓存的值,这确实会快得多。
下面是一个经过纠正的基准测试,可以比较4种情况:
1. 获取(生成)新对象的身份hashCode; 2. 获取现有对象的身份hashCode; 3. 计算新创建对象的重写hashCode; 4. 计算现有对象的重写hashCode。
@State(Scope.Benchmark)
public class HashCode {

    int id = 123;
    String title = "Jane Eyre";
    String author = "Charlotte Bronte";
    Double price = 14.99;

    Book book = new Book(id, title, author, price);
    BookWithHash bookWithHash = new BookWithHash(id, title, author, price);

    @Benchmark
    public int book() {
        return book.hashCode();
    }

    @Benchmark
    public int bookWithHash() {
        return bookWithHash.hashCode();
    }

    @Benchmark
    public int newBook() {
        return (book = new Book(id, title, author, price)).hashCode();
    }

    @Benchmark
    public int newBookWithHash() {
        return (bookWithHash = new BookWithHash(id, title, author, price)).hashCode();
    }
}

Benchmark                 Mode  Cnt   Score   Error  Units
HashCode.book             avgt    5   2,907 ± 0,032  ns/op
HashCode.bookWithHash     avgt    5   5,052 ± 0,119  ns/op
HashCode.newBook          avgt    5  74,280 ± 5,384  ns/op
HashCode.newBookWithHash  avgt    5  14,401 ± 0,041  ns/op

结果表明,获取现有对象的标识hashCode比计算对象字段的hashCode要快得多(2.9 vs. 5 ns)。但是,生成新的标识hashCode是一个非常慢的操作,甚至比对象分配还要慢。

那么它是因为需要放入对象头而变慢的,即将某些东西放入对象头是一种缓慢的操作吗?还是生成速度较慢? - Oleg
@Oleg 因为执行此操作的代码未内联到编译方法中,而是始终通过调用VM运行时函数来执行。 - apangin

2
性能差异是因为在基准测试中,每次调用hashCode()时都会创建一个新对象,而默认的hashCode()实现会将其值缓存到对象头中,而自定义实现则没有。写入对象头需要很长时间,因为它涉及本地调用。
重复调用默认的hashCode()实现比自定义实现略好一些。
如果设置-XX:-UseBiasedLocking,您将看到性能差异减小。由于偏向锁定信息也存储在对象头中,并且禁用它会影响对象布局,这是另一个证明。

@Oleg “写入对象头需要很长时间,因为它涉及本地调用。” 我会期望本地调用是所有调用中最快的,不是吗? - bcf
@bcf 不行,因为它必须跨越 JVM 边界。 - spongebob

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