Свежесобранный сервис на демонстрации отвечает за две сотни миллисекунд, и все делают вид, что так и планировалось. Через месяц тот же сервис на живой нагрузке укладывается в пять миллисекунд, и ничего необъяснимого тут нет: между первым запуском и стабильной работой стоит многоуровневый компилятор виртуальной машины HotSpot. Ожидания разработчик строит по первому запуску, а живёт код на пике после прогрева, и разрыв между этими состояниями регулярно ломает планы: бенчмарк в обед показывает одно, ночной отчёт совсем другое. Ниже разбор пути горячего метода от интерпретатора к тяжёлому оптимизатору, причин, по которым компилятор передумывает, и способов наблюдать его решения на живом приложении.
Пять уровней компиляции и точные пороги переходов между ними
Многоуровневая схема, включённая по умолчанию начиная с восьмого выпуска Java, делит жизнь метода на пять ступеней. Нулевой уровень это чистый интерпретатор: байткод разбирается команда за командой, оптимизаций нет, зато старт мгновенный. Уровни с первого по третий обслуживает быстрый компилятор C1 с разной глубиной профилирования, четвёртый уровень достаётся тяжёлому оптимизатору C2. Переходами управляют счётчики вызовов и обратных рёбер циклов, пороги прописаны в исходных текстах виртуальной машины без всякой скрытности:
-XX:Tier3InvocationThreshold=200 порог вызовов для входа на уровень C1
-XX:Tier4InvocationThreshold=5000 порог вызовов для входа на уровень C2
-XX:Tier3BackEdgeThreshold=60000 обратные рёбра циклов для уровня C1
-XX:Tier4BackEdgeThreshold=40000 обратные рёбра циклов для уровня C2
-XX:TieredStopAtLevel=4 верхняя граница схемы
Метод попадает к C1 с профилированием примерно после двухсот вызовов, к C2 после пяти тысяч, а горячий цикл внутри метода добирается до компиляции и раньше, через обратные рёбра, с порогами в шестьдесят и сорок тысяч срабатываний. Вне многоуровневой схемы порог единый, десять тысяч вызовов флагом CompileThreshold, и именно этим упрощением заканчиваются эксперименты тех, кому многоступенчатость показалась лишней. Промежуточные уровни нужны не для скорости, а для статистики: C1 с полным профилированием записывает типы получателей на вызовах и частоты ветвлений, и без этого материала тяжёлому компилятору пришлось бы выбирать вслепую. Есть и защита от перегрузки: когда очередь компиляции растёт, пороги автоматически удваиваются, и на перегруженной машине путь метода до C2 заметно удлиняется.
Отдельного упоминания заслуживает подмена на стеке, механизм OSR. Горячий цикл длинного метода не ждёт, пока метод наберёт порог вызовов: счётчик обратных рёбер срабатывает прямо внутри цикла, и виртуальная машина подменяет исполняемый код посреди итераций, не прерывая работу. Именно поэтому цикл обработки в долгой задаче однажды ускоряется сам собой, без единого возврата из метода. Обратная сторона той же монеты: код подмены компилируется без полного контекста метода и нередко уступает обычной сборке, и это ещё одна причина, по которой маленькие методы пишут охотнее больших.
Прогрев приложения на реальной нагрузке и сроки выхода на пик
Компиляция идёт параллельно с работой приложения в фоновых потоках, поэтому сам вызов метода ничего не ждёт. Зато вся картина целиком складывается небыстро. Первые сотни обращений к любой функции исполняет интерпретатор, через секунды после старта её заменяет быстрый код C1, а код C2 для полного набора горячих методов появляется через минуты реальной нагрузки. Для плотных вычислительных циклов разница между интерпретацией и кодом C2 достигает десятков раз, между C1 и C2 обычно ещё два-три раза. Сервис, выведенный из балансировщика на минуту и вернувшийся под живой трафик, честно проходит часть пути заново: признанный устаревшим код выгружен, и полка горячих методов собирается с нуля.
Практический ориентир для веб-сервиса средней руки выглядит так: заметный рост пропускной способности в первые пару минут после выката, плато в пределах получаса живого трафика. Точные сроки зависят от смеси вызовов, поэтому их измеряют, а не постулируют: по счётчикам очередей компиляции видно, когда машина перестала двигаться, а событие jdk.CompilerStatistics в записях рекордера подтверждает это графиком.
С пакетными заданиями схема играет иначе. Обработка, укладывающаяся в десять минут, может потратить первые две на интерпретатор и код C1, собрать код C2 к шестой минуте и завершиться, так ни разу им не воспользовавшись. Здесь уместно не ждать бесплатного ускорения от компилятора, а либо растягивать прогрев стендовым прогоном, либо честно ограничивать уровень компиляции флагом TieredStopAtLevel=1 и планировать мощность по холодным цифрам.
Отдельная история с тихими методами. Служебный код, который вызывается раз в минуту, будет набирать пороги часами, и рабочий день сервиса может закончиться где-то на уровне C1. Обратная крайность тоже вредна: полностью отключать прогрев ради равномерности метрик значит всю жизнь ехать на интерпретаторе или на C1, теряя пик ради аккуратных графиков. Скомпилированный код хранится в отдельной области памяти, кодовом кеше, стандартный резерв которой в многоуровневой схеме составляет 240 мегабайт, и теснота в этом кеше сама по себе способна замедлить сервис, вынуждая машину выбрасывать старый код.
Оптимизации C2 от инлайнинга до убирания блокировок
Сердце тяжёлого оптимизатора это подстановка тел методов, инлайнинг. Короткий метод до тридцати пяти байткодов подставляется почти безусловно, горячий метод до трёхсот двадцати пяти байткодов подставляется по частотной эвристике, а дальше решает выигрыш: каждая подстановка стирает стоимость вызова и открывает соседние оптимизации, например распространение констант и упрощение проверок. Точка вызова с тремя и более типами получателей обычно лишается инлайнинга целиком, и один неудачный универсальный интерфейс легко обесценивает работу компилятора на целом горячем пути.
Второй пласт это анализ убегания объектов. Объект, не покидающий метод, вообще не создаётся: поля заменяются регистрами, монитор на нём стирается, и синхронизация по локальному объекту исчезает из кода без следа. Третий пласт работает с циклами по примитивным массивам: разворот итераций и векторные инструкции процессора обрабатывают за такт сразу несколько элементов. Классический пример, где всё работает одновременно:
int dot(int[] a, int[] b) {
int s = 0;
for (int i = 0; i < a.length; i++) {
s += a[i] * b[i];
}
return s;
}
Проверка границ уходит из тела цикла, умножения и сложения сворачиваются в векторные операции, и одна итерация машинного кода делает работу нескольких итераций исходной. Важно понимать, что все эти решения опираются на профиль, собранный на уровнях C1: обеднённая статистика означает бедные оптимизации, и сервис, которого слишком рано лишили нагрузки, не увидит пика, даже когда нагрузка вернётся.
Рядом с тремя пластами стоит четвёртый, невидимый в исходниках: интринсики. Криптографические хеши, копирование массивов, вычисления по стандартным математическим библиотекам виртуальная машина подменяет готовыми машинными процедурами, минуя даже инлайнинг. Отсюда неожиданные результаты бенчмарков: рукописный цикл копирования уступает системной процедуре в разы, и это не заслуга алгоритма, а заранее подготовленный машинный код.
Деоптимизация и обесценивание кода при смене фактического поведения
Оптимизатор часто работает на предположениях, а предположения иногда рушатся. Метод, у которого в профиле один тип получателя, инлайнят со спекулятивной проверкой. Появление второго типа на живой нагрузке ломает предположение, код объявляется негодным, помечается маркером made not entrant, и выполнение откатывается в интерпретатор с последующей перекомпиляцией по новым сведениям. Для сервиса это микроскопические паузы, для лога компилятора рутина, а для команды, у которой на ровном месте подпрыгнул девяносто девятый перцентиль, готовая ниточка к разгадке.
Часть проверок выносится в редко исполняемые ветки, их называют uncommon traps. Компилятор выбрасывает явную проверку на нуль или на редкое условие и ставит на этом месте ловушку: пока ветка не исполняется, код остаётся плотным, первое же попадание откатывает выполнение и отправляет метод на перекомпиляцию уже с честной проверкой. Механизм изящен и одновременно коварен: журнал, набитый откатами, означает, что фактический вход сильно отличается от собранного профиля.
Накопительный эффект страшнее единичного отката. Интерфейс с единственной реализацией раскладывается в прямой вызов, тот же интерфейс с тремя реализациями отправляется по таблице виртуальных вызовов, и разница на горячем пути измеряется не процентами. Классическая ловушка это прогон тестов внутри рабочего процесса: тестовые обращения добавляют в профиль редкие типы и ветвления, и рабочий код после них компилируется заметно хуже. По этой же причине честные замеры требуют изоляции, каждый прогон в собственном процессе, как это делает каркас JMH по умолчанию.
Наблюдение за решениями компилятора на живом приложении
Виртуальная машина сама рассказывает о своей работе, спрашивать надо в нужных местах. Команда jcmd с подкомандами Compiler.code и Compiler.queue показывает текущий набор скомпилированных методов и глубину очередей компиляции. Единый журнал по тегам включается ключом -Xlog:jit+compilation, классический флаг -XX:+PrintCompilation печатает строку на каждое решение: уровень компиляции, размер кода, имя метода и пометку о признании кода негодным. Деоптимизации с причинами раскрывает флаг -XX:+TraceDeoptimization. Для графиков и истории удобнее встроенный рекордер, его событие jdk.CompilerStatistics регулярно отдаёт счётчики скомпилированных методов и загрузку очередей.
jcmd 4321 Compiler.queue
jcmd 4321 Compiler.code
Строка журнала читается просто: номер решения, уровень компиляции, имя метода и размер сгенерированного кода в байткодах. Пометка процента у номера означает подмену на стеке, маркер made not entrant признание кода устаревшим, буква s говорит о синхронной компиляции, когда метод сочли слишком горячим для очереди.
Чтение такого журнала сродни рентгену: видно, как после всплеска трафика очередь C2 растёт, как пороги удваиваются, как редко вызываемый код так и остаётся на уровне C1. Для команды, которая управляет производительностью всерьёз, эти наблюдения стоят дороже общих рассуждений о скорости, потому что говорят о конкретном методе в конкретном окружении.
Что делать с холодным стартом и как удерживать сервис горячим
Для холодного старта арсенал инженера складывается из пяти приёмов:
- прогрев перед приёмом трафика: балансировщик не пускает запросы, пока стендовые обращения не отработают несколько тысяч циклов по горячим путям;
- общие архивные слои загруженных классов через AppCDS с флагами вида -XX:SharedArchiveFile, экономящие секунды старта;
- ограничение уровня компиляции для коротких задач через -XX:TieredStopAtLevel=1, когда пик не успевает окупиться до завершения процесса;
- снимки состояния процесса с восстановлением из образа за десятки миллисекунд, применяемые облачными провайдерами для мгновенного масштабирования;
- сопровождение регрессионных бенчмарков JMH с фиксированными итерациями прогрева, чтобы правки не портили пик незаметно.
Выбор приёма зависит от длины жизни процесса. Долгоживущий сервис, работающий сутками, прогревается один раз и дальше живёт на пике, а первые минуты остаются ценой билета. Короткие пакетные задания и функции по запросу живут противоположной жизнью: порог в пять тысяч вызовов им не набрать, и каждый запуск навсегда остаётся первым. Вечной таблетки нет, есть обмен: быстрее старт означает скуднее оптимизации, дороже пик означает долгий прогрев.
Ожидания от первого запуска поэтому стоит держать отдельно от ожиданий от зрелого сервиса. Честный замер называет оба состояния своими именами: холодная метрика для планирования мощности, горячая для регрессионных порогов. Смешение двух чисел в одну цифру обесценивает обе, и вот тут многоуровневый компилятор бессилен помочь: он честно отрабатывает то, что ему задали нагрузкой.