Почему цикл из 4 миллиардов итераций Java занимает всего 2 мс?

Я использую следующий код Java на ноутбуке с 2,7 ГГц Intel Core i7. Я намеревался позволить ему измерить, сколько времени потребуется, чтобы закончить цикл с помощью 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);
}

Ответ 1

Здесь есть одна из двух возможностей:

  1. Компилятор понял, что цикл избыточен и ничего не делает, поэтому он оптимизировал его.

  2. JIT (компилятор "точно в момент времени") понял, что цикл избыточен и ничего не делает, поэтому он оптимизировал его.

Современные компиляторы очень умны; они могут видеть, когда код бесполезен. Попробуйте поместить пустую петлю в GodBolt и посмотрите на результат, затем включите оптимизацию -O2, вы увидите, что результат - это что-то вроде строк

main():
    xor eax, eax
    ret

Я хотел бы кое-что прояснить, в Java большая часть оптимизаций выполняется JIT. В некоторых других языках (например, C/C++) большинство оптимизаций выполняется первым компилятором.

Ответ 2

Похоже, он был оптимизирован компилятором JIT. Когда я -Djava.compiler=NONE его (-Djava.compiler=NONE), код работает намного медленнее:

$ javac MyClass.java
$ java MyClass
Used 4
$ java -Djava.compiler=NONE MyClass
Used 40409

Я помещаю код OP внутри class MyClass.

Ответ 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, используя собранную статистику.

В качестве примера я проведу следующий фрагмент кода (мой 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

И есть разные версии моего метода запуска, скомпилированные с 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 и после нескольких циклов до уровня 4 C4.

    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

Ответ 6

Вы считаете время начала и окончания в наносекунде, и вы делите на 10 ^ 6 для вычисления латентности

long d = (finish - start) / 1000000

он должен быть 10^9 потому что 1 секунда = 10^9 наносекунд.