遍历数组,索引和增强 for 循环之间的性能差异
looping over array, performance difference between indexed and enhanced for loop
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
意味着通常增强的 for 循环速度更快,并且它的测量比索引循环更准确,因此我们无法解决测量不确定性的差异。对于用随机整数初始化的数组或更大的数组,结果基本相同。
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();
}
}
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
下的 enhanced
和 indexed_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 [=11=]x8,%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
效率很高!
运行 indexed
和 -prof perfasm
显示:
↗ 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,将使用 compiler 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
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
意味着通常增强的 for 循环速度更快,并且它的测量比索引循环更准确,因此我们无法解决测量不确定性的差异。对于用随机整数初始化的数组或更大的数组,结果基本相同。
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();
}
}
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
下的 enhanced
和 indexed_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 [=11=]x8,%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
效率很高!
运行 indexed
和 -prof perfasm
显示:
↗ 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,将使用 compiler 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