сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · JVM и сборка мусора

JIT и исполнение байткода

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, остальные разбираются в тренажёре.

  1. #jvm_execution1 / 5
    Почему у JVM-приложения есть «прогрев» (warmup)?
    A)Потому что при старте JVM загружает в память всю кучу, и это занимает первые секунды
    B)Потому что сборщик мусора в начале работает в самом агрессивном режиме и тормозит приложение
    C)Пока JIT не скомпилировал горячий код, он исполняется медленнее (интерпретация/C1)
    D)Потому что все классы приложения инициализируются строго последовательно перед первым запросом
    показать ответ и разбор
    +C)Пока JIT не скомпилировал горячий код, он исполняется медленнее (интерпретация/C1)

    // разбор: После старта код сначала интерпретируется и лишь постепенно, по мере накопления профиля, компилируется JIT-ом (C1→C2) в оптимальный машинный код. Поэтому первые вызовы/запросы медленнее, а пиковая производительность наступает после «прогрева». Это важно для бенчмарков (нужен warmup, иначе меряешь интерпретацию — JMH это учитывает) и для latency в первые секунды после деплоя. AOT/GraalVM native image убирают прогрев ценой пиковой оптимизации.

  2. #jvm_execution2 / 5
    Что даёт компиляция в нативный образ (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). Выбор — по профилю запуска.

  3. #jvm_execution3 / 5
    Что такое инлайнинг (inlining) как оптимизация JIT?
    A)Подстановка тела вызываемого метода на место вызова — убирает накладные и открывает другие оптимизации
    B)Встраивание всего класса целиком в другой класс, чтобы уменьшить число загружаемых.class-файлов
    C)Сжатие машинного кода метода для экономии места в кэше инструкций процессора без изменения логики
    D)Размещение локальных переменных метода в регистрах процессора вместо стека для ускорения доступа
    показать ответ и разбор
    +A)Подстановка тела вызываемого метода на место вызова — убирает накладные и открывает другие оптимизации

    // разбор: Инлайнинг — ключевая оптимизация JIT: тело часто вызываемого небольшого метода ВСТАВЛЯЕТСЯ прямо в точку вызова. Это убирает накладные вызова (переход, кадр стека) и, что важнее, ОТКРЫВАЕТ другие оптимизации через границу метода: escape analysis, свёртку констант, устранение проверок. Поэтому мелкие геттеры/методы в Java практически бесплатны после прогрева. JIT инлайнит по эвристикам (размер, частота, мономорфность вызова); слишком большие/мегаморфные вызовы не инлайнятся.

  4. #jvm_execution4 / 5
    Что такое деоптимизация (deopt) в JIT?
    A)Отключение JIT целиком после первой ошибки, из-за чего приложение навсегда переходит в интерпретацию
    B)Откат скомпилированного кода обратно к интерпретации, когда спекулятивное предположение нарушилось
    C)Удаление из кэша самого редко используемого скомпилированного метода ради экономии памяти под код
    D)Перевод машинного кода обратно в байткод и сохранение его на диск для следующего запуска приложения
    показать ответ и разбор
    +B)Откат скомпилированного кода обратно к интерпретации, когда спекулятивное предположение нарушилось

    // разбор: JIT оптимизирует СПЕКУЛЯТИВНО, опираясь на собранный профиль: например, считает вызов мономорфным (всегда один тип) и инлайнит его. Если в рантайме предположение нарушается (пришёл другой тип, сработала редкая ветка), скомпилированный код становится неверным — происходит деоптимизация: исполнение откатывается к интерпретатору, а метод при необходимости перекомпилируется с учётом новых данных. Это цена адаптивности: JIT рискует ради скорости, но умеет безопасно откатиться.

  5. #jvm_execution5 / 5
    Почему один и тот же .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) превращает байткод в нативные инструкции конкретного процессора уже во время работы.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.