在数组上循环遍历,索引循环和增强型循环的性能差异。

5
根据 JLS,对于数组,“增强的 for 循环语句等同于基本的 for 循环语句形式”。然而,如果我检查 JDK8 生成的字节码,两种变体都会生成不同的字节码。令人惊讶的是,如果我尝试测量性能,在 jdk8 上增强型的表现似乎更好... 有人能解释为什么吗?我猜这是因为 jmh 测试不正确,如果是这样,请建议如何修复。(我知道 JMH 声明不要使用循环进行测试,但我认为这里不适用,因为我实际上正在尝试测量循环)

我的 JMH 测试相当简单(可能太简单了),但我无法解释结果。以下是测试 JMH 代码,典型结果为:

JdkBenchmarks.enhanced  avgt    5  2556.281 ±  31.789  ns/op
JdkBenchmarks.indexed   avgt    5  4032.164 ± 100.121  ns/op

意味着通常增强的循环比索引循环更快,它的测量结果也更准确,因此我们无法将差异归因于测量误差。对于使用随机整数初始化的数组或更大的数组,基本上得出相同的结果。

public class JdkBenchmarks {

    @Benchmark
    @BenchmarkMode(AverageTime)
    @OutputTimeUnit(NANOSECONDS)
    public void indexed(Blackhole blackhole, TestState testState) {
        int length = testState.values.length;
        for(int i = 0; i < length; i++) {
            blackhole.consume(testState.values[i]);
        }
    }

    @Benchmark
    @BenchmarkMode(AverageTime)
    @OutputTimeUnit(NANOSECONDS)
    public void enhanced(Blackhole blackhole, TestState testState) {
        for (int value : testState.values) {
            blackhole.consume(value);
        }
    }


    @State(Scope.Benchmark)
    public static class TestState {
        public int[] values;

        @Setup
        public void setupArray() {
            int count = 1000;
            values = new int[count];
            for(int i = 0; i < count; i++) {
                values[i] = i;
            }
        }
    }

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

        new Runner(opt).run();
    }

}

也许这个链接可以帮助你。 https://dev59.com/WnVC5IYBdhLWcg3wjx1d - Chirag Nimavat
2
在循环之前尝试使用 int[] array = testState.values; 并使用该本地变量,而不是每次迭代访问 testState 变量(就像已经使用 length 一样)。 - user16320675
@user16320675,那是正确的答案。不知何故我认为编译器会将其内联,我的错。在您的建议下,这两种方法基本相同,远低于任何可衡量的差异。谢谢!(您想将此重新提交为答案吗?) - Martin Mucha
@ChiragNimavat 不是这个问题;你提到的页面讨论了基于迭代器的循环、优化和未优化的索引循环之间的区别,我们已经超越了那个阶段。我认为这很可能是测试中的“错误”,但我没有能够发现它。@user16320675 是正确的,这就是问题所在。 - Martin Mucha
“等价”并不等同于“相等”。 - MC Emperor
1个回答

9
TL;DR: 当JIT编译器不能信任在循环内部values值未发生改变时,你将会看到发生了什么。 此外,在类似这样的微型基准测试中,Blackhole.consume的成本占主导地位,使结果模糊不清。
简化测试:
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(3)
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark)
public class JdkBenchmarks {

    public int[] values;

    @Setup
    public void setupArray() {
        int count = 1000;
        values = new int[count];
        for(int i = 0; i < count; i++) {
            values[i] = i;
        }
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public void indexed(Blackhole bh) {
        for(int i = 0; i < values.length; i++) {
            bh.consume(values[i]);
        }
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public void indexed_cached(Blackhole bh) {
        int[] vs = values;
        int length = vs.length;
        for(int i = 0; i < length; i++) {
            bh.consume(vs[i]);
        }
    }

    @Benchmark
    @CompilerControl(CompilerControl.Mode.DONT_INLINE)
    public void enhanced(Blackhole bh) {
        for (int value : values) {
            bh.consume(value);
        }
    }
}

在使用-prof perfasm的情况下,同时运行enhancedindexed_cached,可以发现此热点循环(我特意使用@CompilerControl(DONT_INLINE)来让@Benchmark方法单独编译,这使得perfasm输出更容易理解):

         ↗  0x...4240: mov  0x10(%r8,%rsi,4),%r10d  ; load values[i], blackhole it
 22.68%  │  0x...4245: mov  0x14(%r8,%rsi,4),%r11d  ; ... repeat 7 more times...
         │  0x...424a: mov  0x18(%r8,%rsi,4),%r10d  ;
 20.95%  │  0x...424f: mov  0x1c(%r8,%rsi,4),%r10d  ;
  0.02%  │  0x...4254: mov  0x20(%r8,%rsi,4),%r11d  ;
 24.73%  │  0x...4259: mov  0x24(%r8,%rsi,4),%r10d  ;
  0.24%  │  0x...425e: mov  0x28(%r8,%rsi,4),%r11d  ;
 20.04%  │  0x...4263: mov  0x2c(%r8,%rsi,4),%r10d  ; 
  0.22%  │  0x...4268: add  $0x8,%esi               ; i += 8
         │  0x...426b: cmp  %ebp,%esi               ; compare i with length (in %ebp)
  0.26%  ╰  0x...426d: jl   0x...4240               ; circle back if 8 more elements available

非常高效!

使用-prof perfasm运行indexed,可以看到:

         ↗  0x...4170: mov  0xc(%r12,%r8,8),%r9d    ; array bounds check, load values.length
  3.42%  │  0x...4175: cmp  %r9d,%r10d              ; array bounds check, compare i
 16.02%  │  0x...4178: jae  0x...41b1               ;  failed? jump to exception handling
         │  0x...417a: lea  (%r12,%r8,8),%r11       ; load values[i], part 1
  0.04%  │  0x...417e: mov  0x10(%r11,%r10,4),%r11d ; load values[i], part 2; %r11d is blackholed
 35.69%  │  0x...4183: mov  0xc(%rsi),%r8d          ; get "values"
  0.71%  │  0x...4187: mov  0x348(%r15),%r11        ; safepoint poll, part 1 (JVM infra)
  4.03%  │  0x...418e: inc  %r10d                   ; i++
  0.12%  │  0x...4191: test %eax,(%r11)             ; safepoint poll, part 2 (JVM infra)
 27.74%  │  0x...4194: mov  0xc(%r12,%r8,8),%r9d    ; load values.length
  8.53%  │  0x...4199: cmp  %r9d,%r10d              ; check i < values.length
  0.24%  ╰  0x...419c: jl   0x...4170               ; circle back if more 

这是因为Blackhole.consume方法对编译器来说是不透明的(像许多其他非内联调用一样),所以它必须保守地假定在循环中values可能会发生变化!
这意味着,编译器无法将values存储在寄存器中,无法确保数组边界检查始终成功,甚至无法保证循环终止(因此需要安全点轮询),而且此外,循环展开不想使每个元素的混乱状况更加复杂。
因此您会得到类似以下的惩罚(TR 3970X,JDK 17.0.2 EA,Linux x86_64):
Benchmark                     Mode  Cnt     Score   Error  Units
JdkBenchmarks.enhanced        avgt    5   144.962 ± 0.918  ns/op
JdkBenchmarks.indexed         avgt    5  1030.981 ± 3.775  ns/op ; + 880 ns/op!
JdkBenchmarks.indexed_cached  avgt    5   144.799 ± 0.643  ns/op ; same as enhanced

另一个有趣的部分:

在大多数JDK中,调用此测试中的Blackhole.consume的成本是主要成本。与数组访问的成本相比,Java-style 的Blackhole的成本要差得多。从JDK 17+和JMH 1.34开始,将使用编译器Blackholes,因此为测试提供更高的保真度。

如果没有编译器黑洞,该效果几乎完全隐藏在Blackhole开销中(超过25倍的开销意味着我们可以执行很多不良代码,然后才进行Blackhole调用!):

Benchmark                     Mode  Cnt     Score   Error  Units
JdkBenchmarks.enhanced        avgt    5  4062.866 ± 4.736  ns/op
JdkBenchmarks.indexed         avgt    5  4071.620 ± 1.057  ns/op ; + 10 ns/op [whoops]
JdkBenchmarks.indexed_cached  avgt    5  4061.390 ± 0.692  ns/op ; same as enhanced

如果我们删除 @CompilerControl(DONT_INLINE),它会重新显现,因为生成的代码会变得更加混乱。
Benchmark                     Mode  Cnt     Score    Error  Units
JdkBenchmarks.enhanced        avgt    5  4067.118 ± 40.699  ns/op
JdkBenchmarks.indexed         avgt    5  4601.370 ±  0.632  ns/op ; + 530 ns/op
JdkBenchmarks.indexed_cached  avgt    5  4064.455 ±  1.554  ns/op ; same as enhanced

1
非常好的答案,谢谢,我从中学到了很多。 - Martin Mucha

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