为啥一个 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 -&gt; B2 -&gt; B3B1 -&gt; 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 毫秒?的主要内容,如果未能解决你的问题,请参考以下文章

java循环一亿次耗时多久

Java中foreach为啥不能给数组赋值

python为啥for循环只查到一次数据

java 中 iterator 为啥翻译成迭代器呢

为啥我不能在 foreach 循环中设置迭代变量的属性?

为啥冒泡排序需要嵌套循环?