Avatar ·

Почему 2 * (i * i) выполняется быстрее, чем 2 * i * i в Java?

Всем привет! Я пишу простой бенчмарк для сравнения производительности умножения в Java. Заметил странное поведение: код с выражением `2 * (i * i)` работает стабильно быстрее, чем `2 * i * i`. Вот пример: ```java public class Test { public static void main(String[] args) { long start = System.nanoTime(); long sum = 0; for (int i = 0; i < 1_000_000_000; i++) { sum += 2 * i * i; // медленно } System.out.println(sum); System.out.println("Time: " + (System.nanoTime() - start) / 1_000_000 + " ms"); } } ``` Если заменить `2 * i * i` на `2 * (i * i)`, время выполнения уменьшается почти вдвое! Я ожидал, что разницы не будет, ведь математически это одно и то же. Может, это связано с порядком вычислений или с тем, как JIT-компилятор оптимизирует код? Или это баг JVM? Объясните, пожалуйста, что происходит.
*Перевод популярного вопроса со Stack Overflow. Оригинал: "Why is 2 * (i * i) faster than 2 * i * i in Java?"* <<>>

Причина: разная оптимизация JIT-компилятором

Всё дело в том, как JIT-компилятор (Just-In-Time) оптимизирует байт-код во время выполнения. Хотя математически оба выражения эквивалентны, компилятор обрабатывает их по-разному из-за особенностей формата байт-кода и правил оптимизации.

Разбор байт-кода

Рассмотрим байт-код для обоих вариантов. Для выражения 2 * i * i:

// 2 * i * i
ILOAD i      // загружаем i
ICONST_2     // константа 2
IMUL         // 2 * i
ILOAD i      // загружаем i снова
IMUL         // (2 * i) * i

Для 2 * (i * i):

// 2 * (i * i)
ILOAD i      // загружаем i
ILOAD i      // загружаем i снова
IMUL         // i * i
ICONST_2     // константа 2
IMUL         // 2 * (i * i)

Казалось бы, разница минимальна. Но ключевой момент — JIT-компилятор распознаёт шаблон i * i как умножение квадрата и применяет специальную оптимизацию.

Оптимизация: раскрытие цикла и векторные инструкции

Когда JIT видит 2 * (i * i), он может:

  • Раскрыть цикл (loop unrolling) — выполнять несколько итераций за раз.
  • Использовать SIMD-инструкции (Single Instruction Multiple Data) — современные процессоры умеют выполнять умножение нескольких чисел одной командой (например, AVX-инструкции).
  • Применить авто-векторизацию — компилятор группирует операции i * i в векторные вычисления.

В случае 2 * i * i JIT не может применить эту оптимизацию, потому что первое умножение 2 * i не является квадратом, и компилятор не видит паттерна для векторизации. В результате генерируется последовательный код без раскрытия цикла.

Экспериментальное подтверждение

Попробуйте запустить этот тест с флагом -XX:+PrintCompilation, чтобы увидеть, какие методы компилируются. Вы заметите, что для 2 * (i * i) JIT генерирует более агрессивный оптимизированный код.

java -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining Test

Важное замечание

Эта оптимизация работает только для int (32-битных целых). Для long или double поведение может отличаться. Также эффект заметен только при большом количестве итераций (миллиарды), когда JIT успевает сработать.

Заключение

Разница в скорости — не баг, а особенность JIT-компиляции. Компилятор лучше оптимизирует выражение, где квадрат вычисляется отдельно. На практике это не значит, что нужно всегда писать скобки — для обычных приложений разница незаметна. Но для высокопроизводительных вычислений (числодробилок) стоит учитывать такие нюансы.

<<>>

Дополнение: роль переполнения и типов

Есть ещё один аспект — возможное переполнение при умножении. В Java int — 32-битное знаковое число. При больших значениях i (например, > 46340) квадрат i * i переполняется, и результат становится отрицательным.

Выражение 2 * i * i вычисляется слева направо: сначала 2 * i (что редко переполняется), затем результат умножается на i. В выражении 2 * (i * i) сначала вычисляется i * i, что может переполниться раньше. JIT-компилятор может учитывать это и генерировать разные проверки на переполнение, что влияет на скорость.

Если заменить int на long, разница может исчезнуть, так как оптимизация квадрата для 64-битных чисел работает иначе. Рекомендую протестировать с long — это поможет понять, связана ли разница именно с оптимизацией квадрата или с переполнением.