Java hashCode():覆盖本机实现的速度更快?

Java hashCode(): Override faster that native implementation?

我有点惊讶 hashCode() 方法的默认(本机)实现比下面的基准测试方法的简单覆盖慢 ~50 倍

考虑一个不覆盖 hashCode() 的基本 Book class:

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 class、BookWithHash,它使用 Intellij 的默认实现覆盖 hashCode() 方法:

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 class 中的 默认 hashCode() 方法几乎慢了 50 倍BookWithHash class:

hashCode() 的(看似更复杂的)实现
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 class 上调用 hashCode() 比在 Book 上调用 hashCode() 快一个数量级class(完整的 JMH 输出见下文):

我对此感到惊讶的原因是我理解默认的 Object.hashCode() 实现(通常)是对象初始内存地址的散列,(至少对于内存查找)我期望在微体系结构级别上非常快。这些结果似乎向我表明,与上面给出的简单覆盖相比, 内存位置的散列 Object.hashCode() 中的瓶颈。我将感谢其他人对我的理解以及可能导致这种令人惊讶的行为的原因的见解。


完整的 JMH 输出:

性能差异是因为您正在为基准测试中的每个 hashCode() 调用创建一个新的 object,并且默认的 hashCode() 实现将其值缓存在object header,而习惯的则没有。写入 object header 需要很多时间,因为它涉及到本地调用。

重复调用默认 hashCode() 实现比自定义实现稍好一些。

如果设置-XX:-UseBiasedLocking,您会发现性能差异会减小。由于偏向锁定信息也存储在 object header 中,禁用它会影响 object 布局,这是一个额外的证明。

你误用了JMH,所以基准分数没有多大意义。

  1. 通常不需要 运行 基准测试中的循环中的某些东西。 JMH 运行本身就是一个基准循环,可以防止 JIT 编译器过度优化被测量的代码。
  2. 需要使用正在测量的代码的结果和副作用,方法是调用 Blackhole.consume 或从方法返回结果。
  3. 代码的参数通常从@State变量中读取,以避免常量折叠和常量传播。

在您的情况下,BookWithHash 对象是瞬态的:JIT 意识到对象不会转义,并且完全消除了分配。此外,由于某些对象字段是常量,JIT 可以通过使用常量而不是读取对象字段来简化 hashCode 计算。

相反,默认hashCode依赖对象身份。所以不能去掉Book的分配。因此,您的基准测试实际上是将 20000 个对象(注意 Double 对象)的分配与对局部变量和常量的一些算术运算进行比较。不出意外,后者要快得多。

另外需要注意的是,identity hashCode的第一次调用比后面的调用要慢很多,因为需要先生成hashCode放到object header中。这又需要调用 VM 运行time。 hashCode 的第二次和后续调用将直接从对象头中获取缓存值,这样确实会快得多。

这是比较 4 个案例的更正基准:

  • 获取(生成)新对象的身份哈希码;
  • 获取现有对象的身份哈希码;
  • 计算新创建对象的重写哈希码;
  • 正在计算现有对象的重写哈希码。
@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 与 5 ns)。然而,生成一个新的身份 hashCode 是一个非常慢的操作,甚至与对象分配相比也是如此。