Как JVM решает, какие методы переводить из интерпретируемого режима в машинный код?
JVM не компилирует все методы сразу: она собирает профиль выполнения и выбирает часто исполняемые методы — «горячие» участки — для JIT-компиляции. Решение принимается по счётчикам вызовов и переходов в циклах, а пороги компиляции адаптируются во время работы. В режиме tiered compilation сначала может применяться быстрая компиляция C1, затем более оптимизирующая C2.
Интерпретация позволяет JVM запускать байткод переносимо и без длительной предварительной компиляции, но выполняет инструкции с дополнительными затратами. Полная компиляция всего приложения перед запуском увеличила бы время старта и потребление ресурсов.
JIT-компиляция решает компромисс: JVM сначала быстро запускает код, затем компилирует только наиболее важные участки, когда получает сведения об их реальном поведении.
Приложение обычно содержит много методов, которые вызываются редко или выполняются один раз. Компилировать их невыгодно: затраты на анализ, генерацию машинного кода и хранение результата могут превысить выигрыш от ускорения.
При этом горячий метод может вызываться миллионы раз. Если оставить его интерпретируемым, возрастут задержки и нагрузка на процессор; если компилировать всё без отбора, ухудшатся старт приложения и эффективность использования памяти.
JVM ведёт профилирование исполнения. Она учитывает, сколько раз вызывается метод и сколько раз выполняются обратные переходы в его циклах. Достижение порога не означает немедленную универсальную компиляцию: конкретные пороги и решения зависят от JVM, режима компиляции, размера метода, доступных ресурсов и накопленного профиля.
При включённой tiered compilation код может пройти несколько уровней. C1 быстро создаёт машинный код и может собрать профиль, а C2 тратит больше времени на глубокие оптимизации: встраивание методов, устранение лишних проверок, оптимизацию циклов и другие преобразования.
Оптимизации основаны на наблюдениях, а не только на статическом байткоде. Например, если в конкретной точке почти всегда встречается один тип, компилятор может использовать это наблюдение как предположение. Если позднее оно нарушается, JVM способна выполнить deoptimization: вернуть выполнение к менее оптимизированному представлению и продолжить профилирование.
Компиляция выполняется потоками компилятора параллельно с приложением, поэтому её стоимость влияет на CPU и иногда на задержки. Скомпилированный код занимает память в Code Cache, а профиль и служебные структуры также имеют собственную стоимость.
Для диагностики смотрят JFR, профили CPU, сведения о компиляции и деоптимизациях, а также динамику задержек после старта. Нельзя делать вывод о качестве JIT только по тому, что метод однажды оказался скомпилирован: важны выигрыш CPU, стабильность профиля и стоимость прогрева.
У HTTP-сервиса первые запросы после запуска заметно медленнее последующих, хотя бизнес-логика одинакова. Анализ показывает, что горячие методы ещё интерпретируются или находятся на раннем уровне компиляции.
Можно прогревать сервис тестовыми запросами до публикации трафика. Плюс — более предсказуемые задержки после старта; минус — дополнительное время запуска и необходимость прогревать действительно репрезентативные сценарии.
Можно заранее компилировать приложение в нативный образ. Это способно сократить время старта и снизить зависимость от JIT-прогрева, но усложняет сборку, ограничивает часть динамических возможностей и меняет профиль оптимизаций.
Для обычного долгоживущего сервиса разумно оставить tiered compilation, измерить прогрев и при необходимости добавить контролируемый прогрев критичных маршрутов. Такое решение сохраняет адаптацию JIT к реальной нагрузке и не требует преждевременной оптимизации всей системы.
1. Всегда ли скомпилированный метод быстрее интерпретируемого?
Нет. Небольшой или редко вызываемый метод может не окупить стоимость компиляции. Кроме того, компилятор может получить неудачные для конкретного сценария профильные данные, а накладные расходы на переходы, синхронизацию или отсутствие удачного встраивания способны свести выигрыш к нулю.
2. Почему результат микробенчмарка зависит от прогрева?
В начале измерений код может выполняться интерпретируемо или на менее оптимизированном уровне. Затем JIT-компилятор переводит горячие участки на более высокий уровень, поэтому измерения смешивают несколько режимов исполнения. Корректный бенчмарк должен отделять прогрев от измеряемой фазы и учитывать возможные изменения профиля во время теста.
3. Что такое компиляция с заменой работающего кода?
Это On-Stack Replacement, или OSR. Она позволяет заменить интерпретируемое выполнение долгого цикла с уже существующим стеком вызовов на выполнение скомпилированной версии, не дожидаясь выхода из метода. Без OSR метод с большим циклом мог бы оставаться медленным до завершения текущего вызова, даже если JVM уже признала его горячим.