JIT и tiered compilation
Java исполняет байткод сначала интерпретатором, а горячие (часто вызываемые) методы компилирует в нативный код на лету — это JIT. Работает tiered compilation: сперва компилятор C1 (быстрая компиляция + сбор профиля исполнения), а самый горячий код перекомпилирует C2 — медленнее, но с агрессивными оптимизациями.
Ключевой момент: оптимизации опираются на ПРОФИЛЬ реального исполнения (какие ветки берутся, какие типы приходят), поэтому пиковая производительность достигается не сразу, а после прогрева.
Ключевые оптимизации
Inlining (встраивание тела вызываемого метода в место вызова) — фундамент: он убирает накладные расходы вызова и открывает остальные оптимизации, давая компилятору видеть код целиком. Escape analysis доказывает, что объект не «убегает» из метода, и тогда возможны scalar replacement (объект не аллоцируется в куче вовсе, раскладывается на локальные значения) и lock elision (снятие ненужной синхронизации на не разделяемом объекте).
Также работают dead code elimination (выкидывание кода без эффекта), constant folding (свёртка констант) и loop unrolling. Именно поэтому «маленький объект в цикле» может не стоить ничего, а наивный замер — показывать ноль.
Деоптимизация и прогрев
C2 делает спекулятивные оптимизации на основе профиля (например, «сюда всегда приходит один тип» → мономорфный inlining). Если предположение нарушится (пришёл новый тип, взялась редкая ветка), происходит деоптимизация — возврат к интерпретации с последующей перекомпиляцией.
Практический вывод: производительность «холодного» кода и кода после прогрева различается в разы. Любой замер без warmup измеряет интерпретацию и компиляцию, а не установившуюся скорость.
Микробенчмарки и JMH
Наивный микробенчмарк (засечь время в цикле) почти всегда врёт: JIT может полностью выбросить код, результат которого не используется (dead code elimination), свернуть константы, или замер поймает фазу прогрева.
Поэтому в Java микробенчмарки пишут на JMH: он делает прогрев (warmup), запускает в отдельных форк-процессах (forks) для изоляции профиля, и защищает от выбрасывания кода — результат нужно «потребить» возвратом или через Blackhole, а входные данные держать в @State, чтобы их не свернули в константу.
@Benchmark
public void parse(Blackhole bh) {
bh.consume(Integer.parseInt("42")); // иначе JIT выкинет вызов
}
Performance mindset
Сначала измеряйте, потом меняйте: смотрите перцентили задержек (p99, не среднее), allocation rate, CPU-профиль, время в БД — и только потом трогайте код, повторно замеряя эффект. Преждевременная оптимизация по догадке обычно усложняет код без выигрыша.
И главное про приоритеты: самые дорогие проблемы backend-а почти никогда не в синтаксисе Java, а в лишних round-trips к БД/сети, плохих индексах, lock contention и неограниченных очередях. JIT и так прекрасно оптимизирует горячий код — фокусируйтесь на алгоритмах и I/O.
Как отвечать на собеседовании
Объясните tiered compilation (интерпретация → C1 с профилем → C2) и зависимость пиковой скорости от прогрева, назовите ключевые оптимизации (inlining как основа, escape analysis → scalar replacement/lock elision, DCE). Расскажите про деоптимизацию (спекуляции по профилю) и почему наивный микробенчмарк врёт → JMH (warmup/forks/Blackhole/@State). Завершите mindset: измеряйте перцентили, узкие места backend-а — в I/O и алгоритмах, а не в синтаксисе.