JIT и исполнение байткода
Гонял один и тот же счётный цикл десять заходов подряд. Первый - 11729 мкс. Второй - 6277. С третьего около 5600 и дальше ровно. Код не менялся, менялось то, во что JVM его скомпилировала.
Тема проверяет зрелость: понимаешь ли ты, что между .class и процессором стоит JIT (just-in-time) - компилятор, который прямо во время работы программы переписывает горячие места в машинный код процессора, опираясь на то, как код повёл себя на реальных данных. Практический выход - прогрев в проде и честные бенчмарки.
// Формулировки: «как исполняется Java-код?», «что такое tiered compilation?», «почему сервис тормозит после деплоя?», «почему твой микробенчмарк врёт?».
Путь кода: интерпретатор, C1, C2
Разница между первым заходом и десятым в том замере - 2,1 раза: 11729 мкс против 5601. Причём ускорение пришло ступенькой - резкий скачок после первого захода, потом небольшая доводка.
javac переводит исходник в байткод - инструкции не для процессора, а для виртуальной машины, одинаковые на любой платформе (в тот же байткод компилируются Kotlin и Scala). Исполнение начинается интерпретацией: JVM читает инструкции по одной и сразу выполняет, попутно считая вызовы метода и обороты циклов. Набрал порог - метод уходит на компиляцию. Работают два компилятора конвейером, это и есть tiered compilation: C1 компилирует быстро и грубо, чтобы дать ускорение сразу, C2 берёт по-настоящему горячее и оптимизирует агрессивно, опираясь на собранный профиль.
// Отсюда деоптимизация. C2 делает предположения: эта ветка не исполняется, в этом месте всегда один тип. Предположение сломалось - скомпилированный код выбрасывается, исполнение возвращается в интерпретатор, метод компилируется заново. Оптимизация в JVM - процесс, а не единичное событие.
- tiered compilation
- конвейер компиляторов: C1 быстро и грубо, C2 агрессивно по профилю
- деоптимизация
- откат в интерпретатор, когда предположение сломалось
Почему самописный бенчмарк врёт
Сто миллионов вызовов Math.sqrt, результат никуда не кладу: 4 мс, 10 мс, 0 мс. Те же сто миллионов, но результат складываю в переменную и печатаю: 265, 271, 266 мс.
Разница в шестьдесят с лишним раз - отсутствие работы, а никакая не оптимизация. JIT увидел, что результат никому не нужен, и выкинул цикл целиком; называется dead code elimination. Замер «nanoTime вокруг цикла» показал скорость пустого места.
Добавь сюда прогрев из прошлой карточки - первый заход был вдвое медленнее установившегося. Честный микробенчмарк обязан прогреть код, помешать JIT выбросить результат и развести замеры так, чтобы профиль одного не влиял на другой. Ровно это делает JMH (Java Microbenchmark Harness). Самописные замеры годятся для разницы в разы, но не в процентах.
for (int i = 0; i < 100_000_000; i++) Math.sqrt(i); // 0-10 мс: цикла нет
for (int i = 0; i < 100_000_000; i++) s += Math.sqrt(i); // 265-271 мс- dead code elimination
- JIT выбрасывает вычисления, результат которых не используется
- JMH
- фреймворк микробенчмарков с прогревом и защитой от выбрасывания
Escape analysis: аллокация, которой не случилось
Триста миллионов раз создал маленький объект внутри цикла и сразу прочитал его поля: 97 мс и НОЛЬ сборок мусора. Запустил то же самое с -XX:-DoEscapeAnalysis: 1045 мс и 985 сборок.
Объект не покидает метод, значит его можно не создавать: JIT разбирает его на отдельные переменные, это называется скаляризация, и в куче не появляется ничего. Отсюда 985 сборок против нуля и разница в 10,8 раза. Короткоживущие объекты в горячем коде обходятся дешевле, чем подсказывает интуиция, а ручное переиспользование объектов ради экономии чаще портит читаемость, чем помогает.
Работает всё это благодаря инлайнингу - встраиванию тела маленького метода в вызывающий. Инлайнинг открывает встроенный код для остальных оптимизаций, поэтому геттеры бесплатны. Рядом живёт девиртуализация: если по профилю вызов всегда уходит в один класс, JIT зовёт метод напрямую. «final ускоряет вызовы» - карго-культ, мономорфность видна и без него.
record Point(int x, int y) {}
for (int i = 0; i < n; i++) {
Point p = new Point(i, i + 1); // не покидает метод
s += p.x() + p.y();
}
// по умолчанию: 97 мс, сборок 0
// -XX:-DoEscapeAnalysis: 1045 мс, сборок 985- escape analysis
- объект не убегает из метода - аллокация исчезает
- инлайнинг
- встраивание тела метода в вызывающий, база остальных оптимизаций
Прогрев в проде и обходные пути
Замерил старт JVM с общим архивом классов и без него, по пять прогонов: java -Xshare:on -version - в среднем 56 мс, -Xshare:off - 98 мс. Почти вдвое, и это на пустой программе.
CDS (class data sharing) отдаёт JVM уже разобранные метаданные классов вместо повторного чтения и линковки при каждом старте, AppCDS распространяет приём на классы приложения. Прогрев самого JIT так не ускорить - профиль всё равно собирается по живому трафику, - но время старта сокращается заметно.
Операционно делают так: раскатывают постепенно, чтобы новый под не поймал полный трафик холодным, и держат health-check, который не пускает нагрузку до готовности. Радикальный путь - AOT-компиляция (ahead-of-time): GraalVM Native Image собирает машинный код заранее, старт становится миллисекундным, а платишь закрытым миром - рефлексию приходится описывать конфигами, и пик может оказаться ниже, чем у прогретого JIT.
- CDS
- class data sharing: готовые метаданные классов вместо разбора на старте
- AOT
- ahead-of-time: компиляция в натив заранее, быстрый старт ценой закрытого мира
Как отвечать: «Почему сервис тормозит первые минуты после деплоя?»
Это прогрев. Java-код сначала интерпретируется, потом попадает к быстрому компилятору C1, и только по-настоящему горячие пути со временем компилируются вторым компилятором в агрессивно оптимизированный машинный код, по профилю реального трафика. У меня на простом счётном цикле первый заход был 11,7 мс против 5,6 мс на установившемся - разница вдвое, и это на замкнутом цикле, в сервисе разброс больше. Плюс холодные кэши, пулы соединений и ленивая инициализация классов. Что с этим делают: раскатывают постепенно, чтобы новый под не получал полный трафик холодным, и держат health-check, который не пускает нагрузку до готовности. Старт можно сократить архивом классов - у меня -Xshare:on дал 56 мс против 98. Если нужен мгновенный старт, есть AOT через GraalVM Native Image, ценой рефлексии по конфигам и возможного потолка ниже прогретого JIT.
Почему это сильный ответ: названа причина через устройство компиляции, приведён порядок величин, добавлены смежные факторы помимо JIT и даны операционные решения с их ценой.
На чём валят
- −Замер через nanoTime вокруг цикла. У меня такой цикл показал 0 мс: JIT его выбросил, потому что результат не использовался.
- −Судить о производительности по первой минуте после деплоя. Горячие пути ещё не скомпилированы, профиль не собран.
- −«final и короткие методы ускоряют вызовы». Инлайнинг и девиртуализация работают по профилю, без подсказок в коде.
- −«Native Image просто быстрее». Быстрее старт; пик может быть ниже, а рефлексию придётся описывать конфигами.
- −Микрооптимизировать байткод, когда профилировщик показывает девяносто процентов времени в запросах к базе.
Проверьте себя
Пять вопросов из банка по этой подтеме. Всего их 16, остальные разбираются в тренажёре.
- Почему у JVM-приложения есть «прогрев» (warmup)?A)Потому что при старте JVM загружает в память всю кучу, и это занимает первые секундыB)Потому что сборщик мусора в начале работает в самом агрессивном режиме и тормозит приложениеC)Пока JIT не скомпилировал горячий код, он исполняется медленнее (интерпретация/C1)D)Потому что все классы приложения инициализируются строго последовательно перед первым запросом
показать ответ и разбор
+C)Пока JIT не скомпилировал горячий код, он исполняется медленнее (интерпретация/C1)// разбор: После старта код сначала интерпретируется и лишь постепенно, по мере накопления профиля, компилируется JIT-ом (C1→C2) в оптимальный машинный код. Поэтому первые вызовы/запросы медленнее, а пиковая производительность наступает после «прогрева». Это важно для бенчмарков (нужен warmup, иначе меряешь интерпретацию — JMH это учитывает) и для latency в первые секунды после деплоя. AOT/GraalVM native image убирают прогрев ценой пиковой оптимизации.
- Что даёт компиляция в нативный образ (GraalVM native image, AOT)?A)Более высокую пиковую производительность, чем у прогретого JIT, в типичных сценарияхB)Возможность запускать Java-код вообще без JVM и без рантайма, прямо как скрипт командной строкиC)Автоматическое распараллеливание приложения по всем ядрам без изменения исходного кодаD)Мгновенный старт и меньшее потребление памяти ценой пиковой JIT-оптимизации и гибкости
показать ответ и разбор
+D)Мгновенный старт и меньшее потребление памяти ценой пиковой JIT-оптимизации и гибкости// разбор: GraalVM native image компилирует приложение ЗАРАНЕЕ (ahead-of-time) в самодостаточный исполняемый файл с лёгким рантаймом (Substrate VM). Плюсы: почти мгновенный старт и низкое потребление памяти — идеально для serverless/CLI/контейнеров. Минусы: нет рантайм-JIT (пиковая скорость под нагрузкой часто ниже прогретого HotSpot), а рефлексия/динамическая загрузка/прокси требуют явной конфигурации на этапе сборки (closed-world assumption). Выбор — по профилю запуска.
- Что такое инлайнинг (inlining) как оптимизация JIT?A)Подстановка тела вызываемого метода на место вызова — убирает накладные и открывает другие оптимизацииB)Встраивание всего класса целиком в другой класс, чтобы уменьшить число загружаемых.class-файловC)Сжатие машинного кода метода для экономии места в кэше инструкций процессора без изменения логикиD)Размещение локальных переменных метода в регистрах процессора вместо стека для ускорения доступа
показать ответ и разбор
+A)Подстановка тела вызываемого метода на место вызова — убирает накладные и открывает другие оптимизации// разбор: Инлайнинг — ключевая оптимизация JIT: тело часто вызываемого небольшого метода ВСТАВЛЯЕТСЯ прямо в точку вызова. Это убирает накладные вызова (переход, кадр стека) и, что важнее, ОТКРЫВАЕТ другие оптимизации через границу метода: escape analysis, свёртку констант, устранение проверок. Поэтому мелкие геттеры/методы в Java практически бесплатны после прогрева. JIT инлайнит по эвристикам (размер, частота, мономорфность вызова); слишком большие/мегаморфные вызовы не инлайнятся.
- Что такое деоптимизация (deopt) в JIT?A)Отключение JIT целиком после первой ошибки, из-за чего приложение навсегда переходит в интерпретациюB)Откат скомпилированного кода обратно к интерпретации, когда спекулятивное предположение нарушилосьC)Удаление из кэша самого редко используемого скомпилированного метода ради экономии памяти под кодD)Перевод машинного кода обратно в байткод и сохранение его на диск для следующего запуска приложения
показать ответ и разбор
+B)Откат скомпилированного кода обратно к интерпретации, когда спекулятивное предположение нарушилось// разбор: JIT оптимизирует СПЕКУЛЯТИВНО, опираясь на собранный профиль: например, считает вызов мономорфным (всегда один тип) и инлайнит его. Если в рантайме предположение нарушается (пришёл другой тип, сработала редкая ветка), скомпилированный код становится неверным — происходит деоптимизация: исполнение откатывается к интерпретатору, а метод при необходимости перекомпилируется с учётом новых данных. Это цена адаптивности: JIT рискует ради скорости, но умеет безопасно откатиться.
- Почему один и тот же .class запускается на разных ОС без перекомпиляции?A)Потому что компилятор javac генерирует отдельный машинный код сразу под все существующие процессорыB)Потому что JVM переводит.class в исходный код на языке целевой ОС и компилирует его там на местеC)Байткод исполняет JVM — она своя под каждую платформу, а байткод одинаковD)Потому что.class-файл содержит нативный код, совместимый со всеми процессорами по единому стандарту
показать ответ и разбор
+C)Байткод исполняет JVM — она своя под каждую платформу, а байткод одинаков// разбор: javac компилирует .java в ПЛАТФОРМО-НЕЗАВИСИМЫЙ байткод (.class), а исполняет его JVM, которая написана и установлена ОТДЕЛЬНО под каждую платформу (Windows/Linux/mac, x86/ARM). Байткод один и тот же, а различия ОС/процессора скрыты внутри JVM — отсюда «write once, run anywhere». Именно JVM (интерпретатор + JIT) превращает байткод в нативные инструкции конкретного процессора уже во время работы.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.