JIT-компиляция в Java
Как JIT-компилятор JVM оптимизирует «горячий» байткод Java в нативный машинный код во время выполнения программы.
Java известна принципом «скомпилируй один раз — запусти везде», но это лишь половина истории. Компилятор javac превращает исходный код в байткод, а не в нативный машинный код, и JVM поначалу интерпретирует этот байткод, выполняя по одной инструкции за раз. Именно JIT (Just-In-Time) компилятор делает Java быстрой: пока программа работает, JVM следит за тем, какие методы вызываются чаще всего, и компилирует эти «горячие» методы в оптимизированный нативный код на лету.
В этой главе объясняется, как работает двухэтапная модель компиляции, что делает многоуровневый компилятор HotSpot и почему Java-программа ускоряется по мере работы. Глава опирается на то, как JVM загружает и выполняет ваш код — см. Архитектура JVM и Компиляция и запуск Java-программы для общей картины.
Два компилятора — две задачи
В мире Java существуют два компилятора, и начинающие часто их путают.
| Компилятор | Когда работает | Входные данные | Результат |
|---|---|---|---|
javac (AOT) | Во время сборки | Исходный код .java | Переносимый байткод .class |
| JIT (HotSpot) | Во время выполнения, внутри JVM | Байткод | Нативный машинный код |
javac запускается один раз и создаёт платформонезависимый байткод. JIT живёт внутри работающей JVM и создаёт машинный код, специфичный для конкретного процессора. Именно поэтому один и тот же .jar запускается везде, но при этом может достигать скорости, близкой к нативной.
// Build time: javac Hello.java -> Hello.class (bytecode)
// Run time: java Hello -> JVM interprets, then JIT-compiles hot methods
public class Hello {
public static void main(String[] args) {
System.out.println("Bytecode now, native code soon.");
}
}Сначала интерпретатор, потом JIT
Когда метод запускается впервые, JVM интерпретирует его: затраты на компиляцию отсутствуют, поэтому старт быстрый, но выполнение каждого байткода медленное. JVM ведёт счётчик вызовов для каждого метода (и счётчик обратных переходов для циклов). Как только метод вызывается достаточно часто, чтобы пересечь пороговое значение, JVM передаёт его JIT для компиляции в нативный код, и последующие вызовы сразу переходят к этой быстрой версии.
Именно поэтому долго работающий сервер становится быстрее после прогрева: методы на горячем пути в конце концов компилируются, а редко используемый код остаётся интерпретируемым (и усилия на его компиляцию не тратятся).
// 'process' is on the hot path. After enough calls it gets JIT-compiled;
// 'logRareError' may stay interpreted forever because it almost never runs.
void handleRequest(Request r) {
process(r); // hot: many invocations -> compiled
if (r.isMalformed()) {
logRareError(r); // cold: rarely called -> stays interpreted
}
}Многоуровневая компиляция: C1 и C2
Современный HotSpot использует многоуровневую компиляцию, сочетая два JIT-компилятора для достижения быстрого старта и пиковой производительности:
- C1 (клиентский компилятор) компилирует быстро с минимальной оптимизацией. Он оперативно переводит горячие методы в нативный код и вставляет счётчики профилирования.
- C2 (серверный компилятор) компилирует медленнее, но оптимизирует агрессивно, используя профиль, собранный C1 (встраивание функций, развёртывание циклов, анализ ускользания, устранение мёртвого кода).
По мере роста «горячести» метод поднимается по уровням:
| Уровень | Что выполняет код | Компромисс |
|---|---|---|
| Уровень 0 | Интерпретатор | Нет затрат на компиляцию, самое медленное выполнение |
| Уровень 3 | C1 с профилированием | Быстрая генерация, умеренная скорость, сбор данных |
| Уровень 4 | C2 с полной оптимизацией | Медленная генерация, самое быстрое выполнение |
Поскольку C2 оптимизирует на основе наблюдаемого поведения, он может делать ставки, на которые статический компилятор javac никогда не решился бы — например, встраивать виртуальный вызов, потому что на практике встречается только одна реализация.
// C2 can speculatively inline this even though 'pay' is virtual,
// because profiling showed every call so far used CreditCard.
abstract class Payment { abstract void pay(int cents); }
class CreditCard extends Payment { void pay(int cents) { /* ... */ } }
void checkout(Payment p) {
p.pay(1999); // megamorphic in theory; monomorphic in practice -> inlined
}Деоптимизация: отказ от ошибочной ставки
Спекулятивные оптимизации могут оказаться неверными. Если C2 встроил CreditCard.pay, а затем появился объект PayPal, оптимизированный код становится недействительным. HotSpot справляется с этим с помощью деоптимизации: некорректный нативный код отбрасывается, метод возвращается к интерпретатору, и позднее может быть перекомпилирован с учётом новой информации. Эта страховочная сеть позволяет JIT оптимизировать агрессивно, никогда не давая неверных результатов.
// First 100000 calls: only CreditCard -> C2 inlines aggressively.
// Call 100001 passes a PayPal -> the assumption breaks ->
// HotSpot deoptimizes, reverts to interpreter, and recompiles later.
checkout(new CreditCard());
checkout(new PayPal()); // triggers deoptimization of the inlined versionНаблюдение за уровнями на запускаемом примере
Настоящий бенчмарк прогрева требует миллионов итераций цикла, что невозможно в песочнице. Вместо этого программа ниже моделирует решение о продвижении, которое принимает HotSpot, — классифицируя метод по числу его вызовов относительно пороговых значений уровней по умолчанию — и считывает реальные факты о JIT из работающей JVM через CompilationMXBean. Запустите её и наблюдайте, как метод перемещается от интерпретируемого к C1, а затем к C2 по мере роста счётчика вызовов.
Что следует вынести из запуска:
- JIT идентифицирует себя как HotSpot 64-Bit Tiered Compilers (через
CompilationMXBean.getName()), подтверждая, что при обычном запускеjavaна HotSpot JVM активны оба компилятора — C1 и C2. - Методы, вызванные лишь
1или500раз, остаются на уровне 0 (интерпретируемые) — JIT не тратит усилий на холодный код. - Пересечение порога
2000вызовов переводит метод на уровень 3 (C1-скомпилированный) — быстро генерируемую нативную версию, которая также профилируется. - Пересечение
10000(и100000) вызовов переводит метод на уровень 4 (C2) — полностью оптимизированный код с максимальной скоростью выполнения. CompilationMXBean.getTotalCompilationTime()показывает реальную активность JIT изнутри Java, доказывая, что компиляция происходит во время работы программы, а не заранее.
Наблюдение за JIT своими глазами
При реальном запуске java (вне песочницы) вы можете наблюдать компиляцию HotSpot в реальном времени с помощью флагов командной строки:
# Print each method as it is compiled, with its tier number in the second column.
java -XX:+PrintCompilation MyApp
# Dump a one-line summary of every compilation method HotSpot supports.
java -XX:+PrintFlagsFinal -version | grep -i tierНесколько практических выводов:
- Прогрейте перед замером производительности. Замер метода при первом запуске измеряет интерпретатор, а не оптимизированный код. Микробенчмарки должны выполнить тысячи итераций заранее (инструменты вроде JMH делают это за вас), чтобы C2 успел скомпилировать горячий путь.
- Время запуска против пиковой скорости — реальный компромисс. Короткоживущие программы (CLI-утилиты, serverless-функции) могут завершиться до того, как C2 вообще вступит в работу, и выполняться преимущественно интерпретируемыми или скомпилированными C1. Долго работающие серверы достигают пиковой пропускной способности после прогрева.
- Настройка пороговых значений редко нужна. Значения по умолчанию хорошо работают для большинства нагрузок. Приведённые выше флаги предназначены для понимания и диагностики, а не для повседневного кода.
JIT-компиляция и сборщик мусора — две системы времени выполнения, обеспечивающие производительность JVM; обе работают автоматически, пока ваша программа выполняется.