为啥一个 40 亿次迭代的 Java 循环只需要 2 毫秒?
Posted
技术标签:
【中文标题】为啥一个 40 亿次迭代的 Java 循环只需要 2 毫秒?【英文标题】:Why does a 4 billion-iteration Java loop take only 2 ms?为什么一个 40 亿次迭代的 Java 循环只需要 2 毫秒? 【发布时间】:2018-06-06 01:00:27 【问题描述】:我在配备 2.7 GHz Intel Core i7 的笔记本电脑上运行以下 Java 代码。我打算让它测量完成 2^32 次迭代的循环需要多长时间,我预计大约需要 1.48 秒(4/2.7 = 1.48)。
但实际上只需要 2 毫秒,而不是 1.48 秒。我想知道这是否是任何 JVM 优化的结果?
public static void main(String[] args)
long start = System.nanoTime();
for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++)
long finish = System.nanoTime();
long d = (finish - start) / 1000000;
System.out.println("Used " + d);
【问题讨论】:
嗯,是的。因为循环体没有副作用,编译器很乐意消除它。使用javap -v
检查字节码以查看。
您不会在字节码中看到它。 javac
很少进行实际优化,大部分都留给了 JIT 编译器。
'我想知道这是否是任何 JVM 优化的结果?' - 你怎么看?如果不是 JVM 优化,还能是什么?
这个问题的答案基本都包含在***.com/a/25323548/3182664中。它还包含 JIT 为此类情况生成的结果程序集(机器代码),表明 JIT 完全优化了循环。 (***.com/q/25326377/3182664 的问题表明,如果循环不执行 40 亿次操作,而是执行 40 亿次减一 ;-),则可能需要更长的时间)。我几乎会将这个问题视为与其他问题的重复——有什么反对意见吗?
您假设处理器将每赫兹执行一次迭代。这是一个影响深远的假设。正如@Rahul 所提到的,今天的处理器执行各种优化,除非您对 Core i7 的工作原理有更多了解,否则您不能假设。
【参考方案1】:
这里有两种可能性之一:
编译器意识到循环是多余的并且什么都不做,因此将其优化掉。
JIT(即时编译器)意识到循环是多余的并且什么都不做,因此将其优化掉。
现代编译器非常聪明;他们可以看到代码何时无用。尝试将一个空循环放入GodBolt 并查看输出,然后打开-O2
优化,您会看到输出类似于
main():
xor eax, eax
ret
我想澄清一点,在 Java 中,大多数优化都是由 JIT 完成的。在其他一些语言(如 C/C++)中,大部分优化都是由第一个编译器完成的。
【讨论】:
允许编译器做这样的优化吗?我不确定 Java,但 .NET 编译器通常应该避免这种情况,以允许 JIT 对平台进行最佳优化。 @IllidanS4 通常,这取决于语言标准。如果编译器可以执行优化,这意味着由标准解释的代码具有相同的效果,那么可以。但是,有许多微妙之处需要考虑,例如浮点计算的一些转换可能会导致上溢/下溢的可能性,因此必须仔细进行任何优化。 @IllidanS4 运行时环境应该怎么做才能更好的优化?至少它必须分析代码,这不能比在编译期间删除代码更快。 @Gerhardh 我不是在谈论这种精确的情况,当运行时无法更好地删除代码的冗余部分时,但当然在某些情况下这个原因是正确的。并且由于可以有其他语言的 JRE 编译器,运行时应该也进行这些优化,因此运行时和编译器可能没有理由同时完成这些优化。 @IllidanS4 任何运行时优化都不能少于零时间。阻止编译器删除代码没有任何意义。【参考方案2】:看起来它已经被 JIT 编译器优化掉了。当我关闭它时(-Djava.compiler=NONE
),代码运行速度要慢得多:
$ javac MyClass.java
$ java MyClass
Used 4
$ java -Djava.compiler=NONE MyClass
Used 40409
我将 OP 的代码放在 class MyClass
中。
【讨论】:
很奇怪。当我以两种方式运行代码时,没有标志它更快,但只有 10 倍,并且在循环中的迭代次数中添加或删除零也会影响运行时间十个,有和没有国旗。所以(对我来说)循环似乎并没有被完全优化掉,只是以某种方式加快了 10 倍。 (甲骨文 Java 8-151) @tobias_k 这取决于循环正在经历的 JIT 阶段我猜***.com/a/47972226/1059372【参考方案3】:我只想说明一个显而易见的事实——这是发生的 JVM 优化,循环将被完全删除。这是一个小测试,它显示了JIT
在仅为C1 Compiler
启用/启用和完全禁用时的巨大 差异。
免责声明:不要写这样的测试 - 这只是为了证明实际的循环“删除”发生在C2 Compiler
:
@Benchmark
@Fork(1)
public void full()
long result = 0;
for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++)
++result;
@Benchmark
@Fork(1)
public void minusOne()
long result = 0;
for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++)
++result;
@Benchmark
@Fork(value = 1, jvmArgsAppend = "-XX:TieredStopAtLevel=1" )
public void withoutC2()
long result = 0;
for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++)
++result;
@Benchmark
@Fork(value = 1, jvmArgsAppend = "-Xint" )
public void withoutAll()
long result = 0;
for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE - 1; i++)
++result;
结果表明,根据启用 JIT
的哪个部分,方法变得更快(快得多,看起来好像在做“无” - 循环删除,这似乎发生在 C2 Compiler
中 -这是***别):
Benchmark Mode Cnt Score Error Units
Loop.full avgt 2 ≈ 10⁻⁷ ms/op
Loop.minusOne avgt 2 ≈ 10⁻⁶ ms/op
Loop.withoutAll avgt 2 51782.751 ms/op
Loop.withoutC2 avgt 2 1699.137 ms/op
【讨论】:
【参考方案4】:正如已经指出的,JIT(即时)编译器可以优化空循环以删除不必要的迭代。但是怎么做呢?
实际上,有两种 JIT 编译器:C1 & C2。首先,代码是用 C1 编译的。 C1 收集统计数据并帮助 JVM 发现在 100% 的情况下,我们的空循环不会改变任何东西并且是无用的。在这种情况下,C2 进入舞台。当代码被频繁调用时,可以使用 C2 使用收集的统计信息对其进行优化和编译。
作为例子,我将测试下一段代码sn -p(我的JDK设置为slowdebug build 9-internal):
public class Demo
private static void run()
for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++)
System.out.println("Done!");
使用以下命令行选项:
-XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=print,*Demo.run
我的 run 方法有不同的版本,用 C1 和 C2 适当地编译。对我来说,最终的变体(C2)看起来像这样:
...
; B1: # B3 B2 <- BLOCK HEAD IS JUNK Freq: 1
0x00000000125461b0: mov dword ptr [rsp+0ffffffffffff7000h], eax
0x00000000125461b7: push rbp
0x00000000125461b8: sub rsp, 40h
0x00000000125461bc: mov ebp, dword ptr [rdx]
0x00000000125461be: mov rcx, rdx
0x00000000125461c1: mov r10, 57fbc220h
0x00000000125461cb: call indirect r10 ; *iload_1
0x00000000125461ce: cmp ebp, 7fffffffh ; 7fffffff => 2147483647
0x00000000125461d4: jnl 125461dbh ; jump if not less
; B2: # B3 <- B1 Freq: 0.999999
0x00000000125461d6: mov ebp, 7fffffffh ; *if_icmpge
; B3: # N44 <- B1 B2 Freq: 1
0x00000000125461db: mov edx, 0ffffff5dh
0x0000000012837d60: nop
0x0000000012837d61: nop
0x0000000012837d62: nop
0x0000000012837d63: call 0ae86fa0h
...
有点乱,但如果仔细观察,你可能会注意到这里没有长时间运行的循环。有 3 个块:B1、B2 和 B3,执行步骤可以是B1 -> B2 -> B3
或B1 -> B3
。其中Freq: 1
- 块执行的标准化估计频率。
【讨论】:
【参考方案5】:您正在测量检测循环不执行任何操作所需的时间,在后台线程中编译代码并消除代码。
for (int t = 0; t < 5; t++)
long start = System.nanoTime();
for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++)
long time = System.nanoTime() - start;
String s = String.format("%d: Took %.6f ms", t, time / 1e6);
Thread.sleep(50);
System.out.println(s);
Thread.sleep(50);
如果您使用-XX:+PrintCompilation
运行此程序,您可以看到代码已在后台编译为 3 级或 C1 编译器,并在几次循环后编译为 C4 的 4 级。
129 34 % 3 A::main @ 15 (93 bytes)
130 35 3 A::main (93 bytes)
130 36 % 4 A::main @ 15 (93 bytes)
131 34 % 3 A::main @ -2 (93 bytes) made not entrant
131 36 % 4 A::main @ -2 (93 bytes) made not entrant
0: Took 2.510408 ms
268 75 % 3 A::main @ 15 (93 bytes)
271 76 % 4 A::main @ 15 (93 bytes)
274 75 % 3 A::main @ -2 (93 bytes) made not entrant
1: Took 5.629456 ms
2: Took 0.000000 ms
3: Took 0.000364 ms
4: Took 0.000365 ms
如果您将循环更改为使用 long
,则不会得到优化。
for (long i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++)
你得到了
0: Took 1579.267321 ms
1: Took 1674.148662 ms
2: Took 1885.692166 ms
3: Took 1709.870567 ms
4: Took 1754.005112 ms
【讨论】:
这很奇怪......为什么long
计数器会阻止相同的优化发生?【参考方案6】:
您以纳秒为单位考虑开始和结束时间,然后除以 10^6 以计算延迟
long d = (finish - start) / 1000000
应该是 10^9
,因为 1
秒 = 10^9
纳秒。
【讨论】:
您的建议与我的观点无关。我想知道的是它花了多长时间,不管这个持续时间是以毫秒还是秒为单位打印/表示的。以上是关于为啥一个 40 亿次迭代的 Java 循环只需要 2 毫秒?的主要内容,如果未能解决你的问题,请参考以下文章