Почему цикл из 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
Здесь есть одна из двух возможностей:
-
Компилятор понял, что цикл избыточен и ничего не делает, поэтому он оптимизировал его.
-
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
наносекунд.