关于 HashMap 中 tableSizeFor 方法的性能问题

2022-05-08 17:41:43 +08:00
 Dmego

最近看 HashMap 的源码。有一个 tableSizeFor 方法,原来在 JDK7 的时候,这个方法叫做 roundUpToPowerOf2

    static int roundUpToPowerOf2(int cap) {
        return cap >= MAXIMUM_CAPACITY
                ? MAXIMUM_CAPACITY
                : (cap > 1) ? Integer.highestOneBit((cap - 1) << 1) : 1;
    }

在 JDK8 的时候给改成了这样:

    static int tableSizeFor(int cap) {
        int n = cap - 1;
        n |= n >>> 1;
        n |= n >>> 2;
        n |= n >>> 4;
        n |= n >>> 8;
        n |= n >>> 16;
        return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
    }

但是在 JDK11 的时候,对这块又进行了一个优化,可以参考 JDK-8203279, 给改成了下面的代码:

static int tableSizeFor(int cap) {
   int n = -1 >>> Integer.numberOfLeadingZeros(cap - 1);
   return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}

这个方法的用途其实没有变: 都是计算返回大于等于 cap 且与之最接近的一个 2 的次幂值。 为了验证这个方法的性能,我用 JHM 跑了一下基准测试,测试代码:

@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Warmup(iterations = 2, time = 1, timeUnit = TimeUnit.SECONDS) // 预热 2 轮,每次 1s
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) // 测试 5 轮,每次 1s
@Fork(1)
@State(Scope.Thread) 
public class TableSizeForTest {

    static final int MAXIMUM_CAPACITY = 1 << 30;

    static final int roundSize = 100_000_000;

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

    @Benchmark
    public void jdk8() {
        for (int i = 1; i <= roundSize; i++) {
            tableSizeFor(i);
        }
    }

    @Benchmark
    public void jdk7() {
        for (int i = 1; i <= roundSize; i++) {
            roundUpToPowerOf2(i);
        }
    }

    @Benchmark
    public void jdk11() {
        for (int i = 1; i <= roundSize; i++) {
            tableSizeFor_JDK11(i);
        }
    }
   // 字数原因不贴 tableSizeFor 方法了
}

最后结果如下:

Benchmark               Mode  Cnt          Score          Error  Units
TableSizeForTest.jdk11  avgt    5   88015092.847 ± 57126449.190  ns/op
TableSizeForTest.jdk7   avgt    5          0.397 ±        0.042  ns/op
TableSizeForTest.jdk8   avgt    5  123458345.756 ±  2528234.087  ns/op

可以看到,jdk11 确实比 jdk8 的性能提高了不少,但是 相比于 jdk7 时使用的 roundUpToPowerOf2 方法,差距也太大了,有大佬能给说一下这是我测试的问题,还是说确实就是这样?

1633 次点击
所在节点    Java
5 条回复
seanthecat
2022-05-08 22:15:34 +08:00
jdk7 跑 1 亿次 for loop 循环只需要 0.397 纳秒不太可能吧? 1GHz 的 CPU 大概就是一个纳秒跑一个指令
aguesuka
2022-05-09 01:40:38 +08:00
The @IntrinsicCandidate annotation is specific to the HotSpot Virtual Machine. It indicates that an annotated method may be (but is not guaranteed to be) intrinsified by the HotSpot VM. A method is intrinsified if the HotSpot VM replaces the annotated method with hand-written assembly and/or hand-written compiler IR -- a compiler intrinsic -- to improve performance. The @IntrinsicCandidate annotation is internal to the Java libraries and is therefore not supposed to have any relevance for application code. Maintainers of the Java libraries must consider the following when modifying methods annotated with @IntrinsicCandidate.
since 16
三个方法都是一样的, 所以在相同的 jdk 中结果都是相同的. 问题出在你的测试, 你需要返回一个结果, jvm 会优化没有副作用的函数
bxb100
2022-05-09 12:34:50 +08:00
@aguesuka 感谢提醒,然后我看到 JMH 确实在 example 里面提到为了防止优化,可以使用 Blackhole
不过结果还是和设想不一致
```
Benchmark Mode Cnt Score Error Units
TableSizeForTest.jdk11 avgt 5 41780240.203 ± 1975632.998 ns/op
TableSizeForTest.jdk7 avgt 5 28159668.744 ± 992751.027 ns/op
TableSizeForTest.jdk8 avgt 5 79449943.585 ± 591363.422 ns/op
```
bxb100
2022-05-09 13:38:47 +08:00
看提交历史好像没说为什么要改用 numberOfLeadingZeros

http://hg.openjdk.java.net/jdk8/jdk8/jdk/rev/d62c911aebbb?revcount=80
Dmego
2022-05-09 22:42:29 +08:00
@bxb100 我用 Blackhole 也试了一下,jdk7 版本的没之前那么离谱了,但是还是有些差距。
我又尝试了一下把 大小的校验 和 三元运算 都去掉:

```java
static int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return n + 1;
}

static int roundUpToPowerOf2(int cap) {
return Integer.highestOneBit((cap - 1) << 1);
}

static int tableSizeFor_JDK11(int cap) {
return (-1 >>> Integer.numberOfLeadingZeros(cap - 1)) + 1;
}
```
这次跑出来结果就比较真实了,jdk7 和 jdk11 的很接近:

```shell
Benchmark Mode Cnt Score Error Units
TableSizeForTest2.jdk17 avgt 5 54189763.703 ± 5052484.742 ns/op
TableSizeForTest2.jdk7 avgt 5 55255800.741 ± 3588948.883 ns/op
TableSizeForTest2.jdk8 avgt 5 125044190.547 ± 15537228.379 ns/op
```

观察了一下,jdk7 的 roundUpToPowerOf2 方法里是先 判断大小和 三元运算 后再调用 位运算方法(highestOneBit) 得出结果。而 jdk11 的 tableSizeFor 方法是先调用 位运算方法(numberOfLeadingZeros) 得出结果后,再判断大小和三元运算。对计算机和 JVM 底层不是很熟悉,感觉像这块的影响

这是一个专为移动设备优化的页面(即为了让你能够在 Google 搜索结果里秒开这个页面),如果你希望参与 V2EX 社区的讨论,你可以继续到 V2EX 上打开本讨论主题的完整版本。

https://www.v2ex.com/t/851555

V2EX 是创意工作者们的社区,是一个分享自己正在做的有趣事物、交流想法,可以遇见新朋友甚至新机会的地方。

V2EX is a community of developers, designers and creative people.

© 2021 V2EX