【问题标题】: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 优化,还能是什么?
  • 这个问题的答案基本都包含在stackoverflow.com/a/25323548/3182664中。它还包含 JIT 为此类情况生成的结果程序集(机器代码),表明 JIT 完全优化了循环。 (stackoverflow.com/q/25326377/3182664 的问题表明,如果循环不执行 40 亿次操作,而是执行 40 亿次减一 ;-),则可能需要更长的时间)。我几乎会将这个问题视为与其他问题的重复——有什么反对意见吗?
  • 您假设处理器将每赫兹执行一次迭代。这是一个影响深远的假设。正如@Rahul 所提到的,今天的处理器执行各种优化,除非您对 Core i7 的工作原理有更多了解,否则您不能假设。

标签: java for-loop jvm


【解决方案1】:

这里有两种可能性之一:

  1. 编译器意识到循环是多余的并且什么都不做,因此将其优化掉。

  2. 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 阶段我猜stackoverflow.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 纳秒。

      【讨论】:

      • 您的建议与我的观点无关。我想知道的是它花了多长时间,不管这个持续时间是以毫秒还是秒为单位打印/表示的。
      猜你喜欢
      • 2017-10-13
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2019-12-19
      • 2023-01-30
      • 1970-01-01
      • 1970-01-01
      • 2015-03-23
      相关资源
      最近更新 更多