Почему 2 * (i * i) выполняется быстрее, чем 2 * i * i в Java?
*Перевод популярного вопроса со 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 — это поможет понять, связана ли разница именно с оптимизацией квадрата или с переполнением.
Ответов пока нет.
Войдите чтобы оставить ответ