JIT-компиляция: что это, как работает и чем отличается от интерпретации и AOT

JIT-компиляция: что это, как работает и чем отличается от интерпретации и AOT Полезное

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

Ниже разберем три модели исполнения кода (интерпретация, AOT, JIT) и явно разведем их, как JIT находит горячий код и оптимизирует его, как это устроено в JVM HotSpot, V8, .NET и PyPy, и в чем честный компромисс: разогрев против пиковой скорости. Без тезиса «JIT всегда быстрее» — это неверно.

Три модели исполнения: интерпретация, AOT и JIT

Процессор понимает только машинный код своей архитектуры. Языки высокого уровня до него надо довести, и есть три разных способа это сделать. Их часто путают, поэтому развожу сразу.

  • Интерпретация. Отдельная программа-интерпретатор читает инструкции (исходник или байт-код) и выполняет их по одной, не выпуская машинного кода на диск или в память как самостоятельный артефакт. Быстрый старт, простая переносимость, но в установившемся режиме медленно: накладные расходы на разбор повторяются на каждой итерации.
  • AOT-компиляция (ahead-of-time, «заранее»). Весь код переводится в машинный до запуска, отдельным шагом сборки. Классика — C и C++ через gcc или clang, результат вроде .exe в Windows. Сюда же GraalVM native-image и Native AOT в .NET. Старт мгновенный, разогрева нет, но компилятор не знает, как программа поведет себя на реальных данных, и целевая архитектура фиксируется при сборке.
  • JIT-компиляция. Гибрид: программа стартует под интерпретатором, а горячие участки компилятор переводит в машинный код уже во время работы, опираясь на собранную статистику выполнения.

Важное уточнение, которое снимает частую путаницу: байт-код обычно готовится AOT (исходник компилируется в байт-код заранее, при сборке), а JIT переводит уже этот байт-код в машинный код во время выполнения. То есть в JVM или .NET работают оба этапа, просто на разных стадиях. При этом «JIT всегда компилирует байт-код» — упрощение: например, V8 строит машинный код из внутреннего представления JavaScript, а PyPy трассирует исполнение, а не транслирует байт-код метод за методом.

Модель Когда переводится в машинный код Разогрев Знает профиль выполнения Типичные примеры
Интерпретация не переводится, инструкции исполняются по одной нет — CPython (базовый), Ruby MRI
AOT до запуска, на этапе сборки нет нет (без отдельного профиля) C, C++, Rust, Go, native-image
JIT во время выполнения, для горячих участков есть да JVM HotSpot, V8, .NET (RyuJIT), PyPy

Как JIT находит горячий код

JIT не компилирует все подряд: это дорого и в основном бесполезно, ведь большая часть кода выполняется редко. Поэтому среда исполнения профилирует программу прямо на ходу.

Работает это так: у методов и циклов есть счетчики вызовов и итераций. Пока счетчик мал, код исполняется интерпретатором. Когда он переходит порог, участок считается «горячим» и отправляется компилятору. Часто применяют несколько уровней (ярусов): сначала быстрый компилятор дает средний по качеству код, а самые горячие места позже перекомпилируются агрессивным оптимизатором.

Ключевое преимущество — адаптивная оптимизация: компилятор видит, какие типы реально приходят, какие ветки берутся чаще, какие вызовы можно встроить (инлайнинг). На этом он делает спекулятивные допущения — например, «в этот метод всегда приходит один и тот же класс». Если допущение нарушится, срабатывает деоптимизация: метод откатывается к менее оптимизированному коду или в интерпретатор, и исполнение продолжается корректно. Это отличает JIT от AOT, где такой информации на этапе сборки нет.

Пример: один и тот же цикл под интерпретатором и JIT

Нагляднее всего разница видна на плотном числовом цикле, запущенном одним и тем же файлом на интерпретаторе (CPython) и на реализации с JIT (PyPy).

# hot_loop.py - один и тот же код для CPython и PyPy
def compute(n):
    total = 0
    for i in range(n):
        total += i * i
    return total

if __name__ == "__main__":
    print(compute(1000))  # 332833500 - сумма квадратов чисел 0..999

Результат детерминирован: compute(1000) дает 332833500 в обеих средах. Различается не ответ, а скорость. Запустите файл с большим n и замером времени:

python3 hot_loop.py   # базовый CPython - интерпретация байт-кода
pypy3 hot_loop.py     # PyPy - трассирующий JIT компилирует горячий цикл

На плотном арифметическом цикле PyPy обычно в разы быстрее CPython: его трассирующий JIT замечает горячий цикл и переводит его в машинный код, тогда как CPython разбирает байт-код на каждой итерации. Конкретные числа сильно зависят от версии, машины и характера цикла, поэтому привожу это как порядок эффекта, а не как фиксированную цифру. Обратная сторона видна на коротких скриптах: там разогрев JIT не окупается, и выигрыша может не быть вовсе.

Понаблюдать за компиляцией можно и в Java: запуск с флагом java -XX:+PrintCompilation печатает лог по мере того, как HotSpot компилирует разогревшиеся методы. Точные строки лога зависят от версии JDK и режима, поэтому их стоит смотреть на своей среде, а не заучивать.

JIT в реальных платформах

Каждая платформа реализует JIT по-своему, но идея горячего кода и ярусов повторяется.

Платформа Что исполняет Интерпретатор / базовый ярус Оптимизирующий JIT
JVM HotSpot байт-код Java (и других JVM-языков) интерпретатор + C1 C2
V8 (Chrome, Node.js) JavaScript / WebAssembly Ignition (интерпретатор) TurboFan
.NET (CoreCLR) CIL/MSIL байт-код — RyuJIT
PyPy Python интерпретатор трассирующий JIT

Несколько деталей по каждой.

  • JVM HotSpot использует многоуровневую компиляцию (tiered compilation). Уровень 0 — интерпретатор, уровни 1-3 — клиентский компилятор C1 с разной степенью профилирования, уровень 4 — серверный C2. C1 дает быстрый старт, C2 — максимальную пиковую производительность для долгоживущих участков.
  • V8 сочетает интерпретатор Ignition и оптимизирующий компилятор TurboFan; между ними добавлены промежуточные ярусы (Sparkplug, Maglev) для более плавного разгона. Названия и наличие ярусов зависят от версии движка.
  • .NET (CoreCLR) переводит байт-код CIL в машинный код через RyuJIT при первом обращении к методу. Параллельно у платформы есть AOT-режимы (ReadyToRun, Native AOT) для быстрого старта без разогрева.
  • PyPy — альтернативная реализация Python с трассирующим JIT (построена на RPython): он отслеживает горячие циклы и компилирует именно исполненные трассы. Базовый CPython исторически был чистым интерпретатором; в CPython 3.13 (2024) появился экспериментальный JIT, выключенный по умолчанию.

Плюсы и минусы: разогрев против пиковой скорости

JIT — это компромисс, а не бесплатное ускорение.

Сильные стороны. JIT видит поведение программы на реальных данных и оптимизирует под него: специализирует по фактическим типам, встраивает горячие вызовы, отбрасывает недостижимые ветки. Он подстраивается под конкретный процессор при запуске, поэтому один и тот же байт-код едет на разных машинах и везде компилируется под местную архитектуру.

Слабые стороны. Есть разогрев: пока горячий код не скомпилирован, программа работает медленнее (сначала интерпретация). Растет потребление памяти — в процессе живут и скомпилированный код, и данные профилирования, и сам компилятор. Задержки менее предсказуемы: компиляция и деоптимизация могут давать паузы, что критично для систем реального времени.

Отсюда честное сравнение с AOT, без абсолютов. Для долгоживущих серверных нагрузок JIT за счет профиля нередко выходит на уровень оптимизированного нативного кода или обгоняет наивную сборку. Для короткоживущих процессов (утилиты командной строки, serverless-функции, небольшие скрипты) AOT или нативная компиляция обычно выигрывают: там нет времени на разогрев. А AOT с профилем (PGO) способен приблизиться к JIT, используя статистику прошлых запусков. Вывод «JIT всегда быстрее» неверен — выбор зависит от времени жизни процесса и профиля нагрузки.

Выводы

  • JIT-компиляция переводит байт-код или промежуточное представление в машинный код во время выполнения, компилируя прежде всего горячие участки.
  • Три модели исполнения разные: интерпретация не выпускает машинный код, AOT готовит его до запуска, JIT — на ходу и с учетом профиля.
  • Главный козырь JIT — адаптивная оптимизация по данным времени выполнения; расплата за нее — разогрев, лишняя память и менее предсказуемые задержки.
  • В боевых платформах используется многоярусность: быстрый старт (интерпретатор или C1/Ignition) плюс агрессивный оптимизатор (C2/TurboFan/RyuJIT) для горячего кода.
  • JIT не всегда быстрее AOT: на долгих серверных нагрузках он силен, на коротких процессах чаще выигрывает нативная сборка.

Где применяется / связь с практикой

Понимание JIT нужно, когда вы разбираетесь, почему программа «разгоняется» не сразу, настраиваете производительность JVM или .NET, выбираете между JIT и AOT для serverless или CLI, читаете логи компиляции при профилировании. Это уровень, где язык встречается с процессором и памятью, — область системного программирования.

Освойте тему на практике

Разобраться с тем, как устроены компиляторы, среды исполнения и работа с памятью на низком уровне, помогает курс Системное программирование. Посмотреть формат и глубину занятий, не оплачивая курс, можно на открытых уроках Otus — там разбирают конкретные темы вживую с преподавателями.

Смежные темы: Введение в кодирование информации.

FAQ

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

Что такое деоптимизация? Это откат скомпилированного метода назад — в менее оптимизированный код или в интерпретатор, — когда спекулятивное допущение JIT нарушилось (например, в метод пришел неожиданный тип). Программа при этом продолжает работать корректно.

Можно ли запустить программу без JIT? Да. Есть AOT-режимы: GraalVM native-image для Java, Native AOT в .NET, а базовый CPython исполняет код интерпретатором. Это полезно там, где важен мгновенный старт и предсказуемость, а не пиковая пропускная способность.

OTUS Журнал