Представьте, что после длительного прогрева первый вызов с новым типом Op внезапно выполняется медленнее. Объясните механизм JVM, который может вызвать такой всплеск задержки.
interface Op {
int apply(int x);
}
final class Add implements Op {
public int apply(int x) { return x + 1; }
}
final class Mul implements Op {
public int apply(int x) { return x * 2; }
}
public class Main {
static long run(Op op) {
long sum = 0;
for (int i = 0; i < 10_000_000; i++) sum += op.apply(i);
return sum;
}
public static void main(String[] args) {
Op add = new Add();
for (int i = 0; i < 20; i++) run(add);
System.out.println(run(new Mul()));
}
}
В HotSpot JVM JIT-компилятор мог оптимизировать вызов op.apply как мономорфный, предполагая, что фактический тип объекта — только Add. После появления Mul это предположение нарушается, поэтому JVM может выполнить деоптимизацию уже скомпилированного кода и временно перейти к менее оптимизированному варианту.
Из-за деоптимизации, восстановления состояния выполнения и возможной повторной компиляции первый вызов с новым типом может получить заметный всплеск задержки. Это не гарантированное поведение: решение зависит от профиля вызовов, версии JVM, флагов JIT и фактической нагрузки.
Java изначально сочетала переносимость байткода с возможностью выполнять программы на разных виртуальных машинах. Интерпретация байткода упрощает запуск, но недостаточно эффективна для горячих циклов.
JIT-компиляция появилась как способ динамически переводить часто выполняемый байткод в машинный код с учётом реального поведения программы. Такой подход позволяет применять оптимизации, которые невозможно надёжно выбрать только по статическому исходному коду.
В примере JVM видит, что горячий вызов apply долго получает объект типа Add. JIT может встроить тело Add.apply непосредственно в цикл и добавить проверку, что фактический тип остаётся ожидаемым.
Когда появляется Mul, прежняя оптимизация становится неприменимой. Если не обработать это изменение, скомпилированный код мог бы вычислять результат по неправильной логике. Следовательно, JVM должна сохранить корректность, даже если это временно ухудшит задержку.
Профилирующий интерпретатор и компиляторы C1/C2 собирают сведения о типах, частоте вызовов и ветвлениях. Для вызова с одним наблюдаемым типом JIT может применить моноформную оптимизацию: проверить класс объекта, встроить метод и выполнять тело без обычного виртуального разрешения вызова.
Скомпилированный машинный код содержит зависимость от сделанного предположения. При появлении нового типа JVM может пометить такой код как неподходящий и выполнить deoptimization. Она возвращает выполнение к безопасному состоянию, описанному метаданными JIT-кода, после чего продолжает работу интерпретатором или менее оптимизированной версией.
Позднее JVM может собрать новый профиль. Если вызовы стали полиморфными, она выберет другой вариант: например, встроит несколько наиболее частых реализаций с проверками типов или оставит виртуальный вызов без агрессивного встраивания.
Деоптимизация не означает ошибку или утечку памяти. Это штатный механизм сохранения корректности при изменении наблюдаемого поведения программы. Однако он может увеличить latency: требуется выход из оптимизированного кода, восстановление локальных переменных и стеков, а затем иногда компиляция нового варианта.
Важное ограничение: Java Language Specification не обещает конкретный момент JIT-компиляции или деоптимизации. Поэтому такой эффект нельзя надёжно воспроизвести простым числом итераций. Для анализа используют профилирование, JFR, логи компиляции и корректные микробенчмарки на JMH, а не одиночные замеры System.nanoTime.
Код из вопроса прогревает run только с Add. Вызов с Mul меняет профиль виртуального вызова:
Нельзя заключать по одному всплеску, что деоптимизация произошла наверняка. Нужно подтвердить это по диагностике, например по событиям JFR или сообщениям -XX:+PrintCompilation и -XX:+LogCompilation; конкретные диагностические флаги зависят от версии JVM.
В сервисе маршрутизации запросов горячий метод принимал интерфейс Handler. В обычной нагрузке использовался один обработчик, поэтому JIT хорошо встраивал его код. После включения экспериментального обработчика для небольшого процента запросов наблюдался рост p99 именно в момент появления нового типа.
Рассматривались три варианта. Первый — отключить агрессивные JIT-оптимизации: это могло убрать резкие переходы, но обычно ухудшало постоянную производительность. Второй — заранее прогреть все реализации: это уменьшало вероятность неожиданной деоптимизации после старта, но не решало проблему при динамическом появлении новых обработчиков. Третий — изменить границу вызова так, чтобы редкий обработчик не смешивался с горячим мономорфным маршрутом.
Выбрали третий вариант: частый путь оставили специализированным, а редкие реализации направили через отдельную ветку диспетчеризации. После этого основной профиль вызова стал стабильнее, а редкий путь получил предсказуемую, хотя и немного большую, стоимость. Решение было принято после проверки JFR и нагрузочных тестов, а не только по исходному коду.
Нет. JIT может изначально не строить мономорфную оптимизацию, если профиль уже полиморфный или вызов недостаточно горячий. Он также может использовать полиморфную inline-cache с несколькими типами. Деоптимизация возможна, когда ранее принятое предположение было встроено в скомпилированный код и затем перестало выполняться.
Повторная компиляция создаёт новую версию кода на основе накопленного профиля. Деоптимизация — это выход из уже выполняемого оптимизированного кода, потому что его предположение больше нельзя считать безопасным. Эти события могут идти последовательно: JVM сначала деоптимизирует выполнение, затем компилирует более подходящую версию.
Это может изменить профиль и сделать вызов полиморфным заранее, но не даёт универсальной гарантии. Набор типов может измениться позднее, а чрезмерное количество реализаций способно снизить эффективность встраивания. Прогрев полезен только как часть измеряемой стратегии запуска; сначала нужно подтвердить проблему диагностикой и оценить компромисс между стабильностью latency и пиковой пропускной способностью.