Java JVM изнутри: байткод, области памяти, сборщики мусора, JIT
0%

JVM изнутри: байткод, области памяти, сборщики мусора, JIT

JVM изнутри: байткод, области памяти, сборщики мусора, JIT

Есть простая проверка, отличающая Java-разработчика от человека, который «пишет на Java». Спросите, почему сервис с флагом -Xmx1g был убит в контейнере с лимитом 1 ГБ, хотя OutOfMemoryError в логе не появилось. Или почему один и тот же метод в первые пять секунд работает в десять раз медленнее, чем через минуту. Или почему профайлер показывает 40 % времени в методе, которого в скомпилированном коде вообще не существует.

Ни один из этих вопросов не относится к языку. Все три — про среду исполнения. Java — редкий случай, когда рантайм настолько богат, что его приходится изучать отдельно: он сам компилирует ваш код в машинный, сам управляет памятью, сам решает, какой виртуальный вызов встроить, и сам умеет откатывать свои решения. Мы пройдём четыре слоя: байткод (что реально исполняется), память (где что лежит и почему RSS больше -Xmx), сборка мусора (как объекты умирают и сколько это стоит) и JIT (как интерпретируемый код становится быстрее, чем C++ без PGO). В конце — диагностический инструментарий и честный разбор границ платформы.

Задача, которую решает JVM

JVM — это спецификация абстрактного процессора, а не программа. JVM Specification описывает формат файла, набор инструкций и семантику исполнения настолько точно, что два независимых рантайма обязаны выдавать одинаковый результат. Из этого контракта следует всё остальное: переносимость (один .class работает на x86-64, ARM, s390x), управляемая память (арифметика указателей запрещена, поэтому рантайм всегда знает, где ссылки, — а значит, может двигать объекты), безопасность через верификацию (испорченный класс даёт VerifyError, а не повреждение памяти), многоязычность (Kotlin, Scala, Clojure говорят на одном байткоде и вызывают друг друга напрямую) и оптимизация по факту — рантайм видит реальный профиль исполнения, чего статический компилятор не может в принципе. Плата: время прогрева, накладные расходы на метаданные и невозможность предсказать производительность по исходнику — её нужно мерить.

Общая теория виртуальных машин, сборки мусора и динамической компиляции разобрана в треке компиляторов — Виртуальные машины, Сборка мусора, JIT-компиляция. Здесь мы смотрим на конкретную реализацию — HotSpot — и на то, что с ней делать руками.

Часть 1. Байткод: что на самом деле исполняется

Анатомия .class-файла

Файл класса — это плотный бинарный формат без выравнивания и без padding. Начинается он магическим числом 0xCAFEBABE, дальше идут версия, пул констант и описания членов:

xxd -l 16 Counter.class
# 00000000: cafe babe 0000 0041 0022 0a00 0200 0300  .......A."......
#                     ^^^^ ^^^^
#                     minor=0, major=0x41=65 → скомпилировано под Java 21

Номер версии — та самая причина ошибки UnsupportedClassVersionError: 52 = Java 8, 55 = 11, 61 = 17, 65 = 21, 69 = 25. Старая JVM новый класс не запустит никогда; новая старый — почти всегда запустит.

Главная структура внутри — constant pool: таблица всех строк, имён классов, сигнатур методов и ссылок, на которые указывают инструкции. Инструкция не содержит имени метода, она содержит индекс в пуле:

javap -v Counter.class | head -20
Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #7 = Fieldref           #8.#9          // Counter.value:I

(Конкретные номера у вас будут другими — они зависят от порядка компиляции.) Ссылки в пуле символические: Counter.value:I — это имя, а не адрес. Прямым адресом оно становится при первом обращении, в момент резолвинга. Отсюда две практические вещи: ленивая загрузка классов и возможность подменить реализацию в рантайме — на этом стоят Spring, Hibernate и агенты профилирования.

Стековая машина в действии

У JVM нет регистров. Есть массив локальных переменных и стек операндов — на кадр метода. Инструкции кладут значения на стек, снимают их и кладут результат обратно.

Устройство кадра метода JVM: локальные переменные, стек операндов, ссылка на constant pool и пошаговое исполнение сложения

Возьмём тривиальный класс:

public class Counter {
    private int value;

    public void inc() { value++; }   // выглядит как одна операция
    public int  get() { return value; }
}
javac Counter.java && javap -c -p Counter.class
  public void inc();
    Code:
       0: aload_0          // на стек: this
       1: dup              // продублировать this (нужен и для чтения, и для записи)
       2: getfield      #7 // снять this, положить this.value
       5: iconst_1         // положить константу 1
       6: iadd             // снять два int, положить сумму
       7: putfield      #7 // снять значение и this, записать поле
      10: return

Здесь наглядно видно, почему value++ не атомарен: это чтение, сложение и запись — три шага, между которыми может вклиниться другой поток. Вся глава про конкурентность в Конкурентности вырастает из этих семи инструкций.

Цикл for-each по массиву разворачивается компилятором в обычный индексный цикл:

static int sum(int[] a) {
    int s = 0;
    for (int x : a) s += x;
    return s;
}
       3: astore_2          // скрытая копия ссылки на массив
       5: arraylength
       6: istore_3          // скрытая длина
       8: istore        4   // скрытый индекс i
      13: if_icmpge     33  // i >= len → выход из цикла
      19: iaload            // a[i] — проверка границ встроена в инструкцию
      25: iadd
      27: iinc          4, 1
      30: goto          10
      34: ireturn

Три переменные, которых вы не писали, — компилятор их выдумал. И обратите внимание на iaload: проверка границ массива входит в семантику самой инструкции. Убрать её из исходника нельзя; убрать её может только JIT, доказав, что индекс заведомо в диапазоне (см. часть 4).

Пять инструкций вызова — вся объектная модель

Инструкция Что вызывает Как ищет цель
invokestatic статический метод напрямую, по ссылке из пула
invokespecial конструктор, private, super.method() напрямую, без виртуальной диспетчеризации
invokevirtual обычный метод класса по таблице виртуальных методов объекта
invokeinterface метод интерфейса по таблице интерфейса — исторически дороже
invokedynamic «спроси у bootstrap-метода» цель определяется при первом вызове и кэшируется

invokedynamic (Java 7) — самая интересная. Она не знает, что вызывать: JVM обращается к bootstrap-методу, тот возвращает CallSite, и дальше вызов идёт напрямую. На ней построены лямбды (LambdaMetafactory), конкатенация строк (StringConcatFactory), record-овые equals/hashCode/toString (ObjectMethods) и сопоставление с образцом в switch.

Runnable r = () -> System.out.println("hi");
       0: invokedynamic #7,  0   // run:()Ljava/lang/Runnable;
       5: astore_1

Анонимного класса нет. Класс-реализация будет сгенерирован в рантайме при первом исполнении этой строки. Практическое следствие: лямбды удорожают первый вызов (и старт приложения), но дальше не отличаются от обычного вызова — и позволяют JVM менять стратегию без перекомпиляции вашего кода.

Что ещё javac разворачивает молча

  • Автобоксинг: list.add(1)Integer.valueOf(1). Значения −128..127 кэшируются, всё остальное — аллокация в куче.
  • Строковая конкатенация в цикле: s += x внутри цикла — это invokedynamic на каждой итерации, то есть квадратичная сложность по памяти. Отсюда правило «в цикле — StringBuilder».
  • Стирание дженериков: List<String> в байткоде — просто List, плюс checkcast в местах чтения. Подробно — в Коллекциях и дженериках.
  • try-with-resources: разворачивается в try/finally с подавлением вторичных исключений.
  • Внутренние классы: получают синтетическую ссылку на внешний объект — типовой источник утечек, когда «маленький слушатель» держит живым весь контроллер.

Жизненный цикл класса

Классы в Java загружаются лениво и проходят строго определённые фазы. Понимание этой диаграммы закрывает целый класс ошибок вида «почему у меня null в статическом поле».

Три ловушки живут именно здесь:

  1. Prepared наступает раньше Initialized. Между ними статическое поле уже существует, но равно null. Круговая зависимость двух классов в статических инициализаторах даёт стабильно воспроизводимый null — и никакого исключения.
  2. ExceptionInInitializerError необратим. Если clinit упал, класс помечается как Erroneous навсегда; повторные обращения дадут NoClassDefFoundError без исходной причины. Настоящая ошибка — только в самой первой строчке лога.
  3. Класс выгружается вместе со своим загрузчиком, и никак иначе. Отсюда «утечки classloader-ов» при hot redeploy и при активной генерации прокси: растёт Metaspace, а не куча.

Часть 2. Где живёт память

Куча — и всё остальное

Первое, что нужно принять: -Xmx ограничивает только кучу, а процесс потребляет заметно больше. Карта областей разобрана в обзоре трека; здесь — цифры и следствия.

Область Кто владеет Ограничение Собирается GC
Куча (heap) все потоки -Xmx / -XX:MaxRAMPercentage да, это её единственный смысл
Metaspace все потоки -XX:MaxMetaspaceSize, по умолчанию не ограничен только при выгрузке загрузчиков
Code Cache JIT -XX:ReservedCodeCacheSize, по умолчанию 240 МБ своя эвикция скомпилированных методов
Стеки потоков по потоку -Xss × число потоков нет
Direct / mapped буферы NIO, Netty -XX:MaxDirectMemorySize косвенно, через Cleaner
GC-структуры сборщик пропорционально куче нет
Нативные библиотеки JNI, glibc-арены ничем нет

Формула, которую стоит держать в голове при выставлении лимита пода:

RSS ≈ Xmx + Metaspace + CodeCache + (Xss × threads) + DirectMemory + GC-overhead + malloc-арены

Для типичного Spring-сервиса «всё остальное» — это 300–600 МБ. Поэтому -Xmx при лимите контейнера в 1 ГБ разумно ставить около 600–700 МБ, а не 1 ГБ.

Аллокация: почему new почти бесплатен

Наивное представление — «аллокация зовёт malloc» — здесь неверно. У каждого потока есть TLAB (Thread-Local Allocation Buffer): персональный кусок Eden. Аллокация внутри TLAB — это инкремент указателя и запись нулей, без единой блокировки:

new Order(...)  →  ptr = tlab.top;  tlab.top += size;  if (tlab.top > tlab.end) медленный путь

Медленный путь (новый TLAB или прямая аллокация в куче) случается редко. Именно поэтому в Java дёшево создавать короткоживущие объекты и дорого их сохранять.

Второй механизм — escape analysis в компиляторе C2. Если доказано, что объект не покидает метод, он может быть вообще не создан: поля живут в регистрах (скалярная замена).

record Point(double x, double y) {
    double dist(Point o) { return Math.hypot(x - o.x, y - o.y); }
}

static double total(double[] xs, double[] ys) {
    double s = 0;
    for (int i = 1; i < xs.length; i++) {
        // оба объекта не «убегают» из итерации — C2 разберёт их на скаляры
        Point a = new Point(xs[i - 1], ys[i - 1]);
        Point b = new Point(xs[i], ys[i]);
        s += a.dist(b);
    }
    return s;
}

Проверить это можно без профайлера, просто посмотрев, растёт ли куча:

java -Xlog:gc Bench
# [0.010s][info][gc] Using G1
# ...и ни одной строки о сборке: аллокаций не было вовсе

java -XX:-DoEscapeAnalysis -Xlog:gc Bench
# [0.31s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 51M->1M(514M) 1.842ms
# [0.48s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 51M->1M(514M) 1.671ms
# ...десятки таких строк

Важная оговорка: escape analysis в C2 работает по всему методу целиком и ломается от одной ветки, где объект «убегает» (сохраняется в поле, кладётся в коллекцию, передаётся неинлайненному методу). Частичный escape analysis есть в Graal, но не в стандартном C2. Полагаться на него как на гарантию нельзя — можно только радоваться, когда сработал.

Metaspace, Code Cache и стеки

Metaspace (с Java 8 вместо PermGen) живёт в нативной памяти и хранит метаданные классов. По умолчанию не ограничен — то есть утечка классов съест всю память ноды молча. В проде стоит ставить -XX:MaxMetaspaceSize=256m просто чтобы получить внятную ошибку вместо OOM-killer. С JDK 16 действует Elastic Metaspace — память возвращается ОС после выгрузки загрузчиков, чего раньше практически не происходило.

Code Cache хранит скомпилированный JIT-ом машинный код; с JDK 9 он сегментирован на три части. Если он переполнится, вы увидите:

CodeCache is full. Compiler has been disabled.
Try increasing the code cache size using -XX:ReservedCodeCacheSize=

После этого приложение продолжает работать — в интерпретаторе, то есть в десять раз медленнее. Симптом: сервис «просто стал тормозить» через часы работы, GC при этом спокоен. Типичная причина — тысячи сгенерированных классов (Spring-прокси, скриптовые движки).

Стеки потоков — по -Xss на поток, обычно 512 КБ–1 МБ. Тысяча платформенных потоков — это гигабайт виртуальной памяти (резервируется адресное пространство, коммитится по мере роста). Виртуальные потоки решают эту проблему радикально: их кадры живут в куче как объекты StackChunk и растут по необходимости — см. Конкурентность.

Контейнер: главный источник продовых сюрпризов

С JDK 10 JVM умеет читать cgroup-лимиты (-XX:+UseContainerSupport, включён по умолчанию). Но настройка по умолчанию удивляет:

docker run --memory=2g eclipse-temurin:21 java -XX:+PrintFlagsFinal -version | grep -E 'MaxHeapSize|MaxRAMPercentage'
#    size_t MaxHeapSize     = 536870912    {product} {ergonomic}   ← всего 512 МБ из 2 ГБ
#    double MaxRAMPercentage = 25.000000   {product} {default}

По умолчанию куча берёт 25 % лимита контейнера. Полтора гигабайта простаивают. Правильная настройка для сервиса в контейнере:

java -XX:MaxRAMPercentage=70 \
     -XX:MaxMetaspaceSize=256m \
     -XX:+AlwaysPreTouch \
     -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/dumps \
     -XX:+ExitOnOutOfMemoryError \
     -Xlog:gc*:file=/logs/gc.log:time,uptime,level,tags:filecount=5,filesize=20M \
     -jar app.jar

Что здесь важно:

  • MaxRAMPercentage вместо -Xmx — образ переезжает между окружениями без правки команды.
  • AlwaysPreTouch заставляет коммитить всю кучу при старте: старт медленнее, зато нет «ступенек» латентности при первом росте кучи и нет OOM-killer через час работы.
  • ExitOnOutOfMemoryError — критично. По умолчанию OutOfMemoryError убивает только поток, а приложение продолжает работать в полуживом состоянии, отвечая ошибками. Лучше умереть и перезапуститься.

Проверить, куда ушла нативная память, можно через Native Memory Tracking:

java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary
# Total: reserved=3208MB, committed=1043MB
# -                 Java Heap (reserved=1024MB, committed=700MB)
# -                     Class (reserved=1050MB, committed=63MB)   ← Metaspace
# -                    Thread (reserved=210MB,  committed=210MB)  ← 200 потоков × 1 МБ
# -                      Code (reserved=247MB,  committed=32MB)
# -                        GC (reserved=64MB,   committed=42MB)

Это первый инструмент, когда под убивают по памяти, а OutOfMemoryError в логе нет.

Часть 3. Сборка мусора

Что такое «мусор»

Определение точное и контринтуитивное: мусор — это объект, недостижимый от корней. Не «ненужный», а именно недостижимый. Корни (GC roots) — это:

  • локальные переменные и операнды всех живых кадров всех потоков;
  • статические поля загруженных классов;
  • ссылки из JNI;
  • объекты мониторов, на которых кто-то синхронизирован.

Отсюда определение утечки в Java: объект достижим, но больше не нужен. Сборщик не может отличить кэш от утечки — это ваша работа.

// №1: статический кэш без вытеснения — растёт вечно, remove никто не зовёт
private static final Map<String, Session> ALL = new HashMap<>();

// №2: ThreadLocal в пуле потоков — поток живёт вечно и держит Context до следующего set()
private static final ThreadLocal<Context> CTX = new ThreadLocal<>();
void handle(Request r) {
    CTX.set(new Context(r));
    process();
    // CTX.remove(); ← вот этой строки не хватает
}

// №3: подписка без отписки — лямбда захватила this, весь контроллер бессмертен
bus.subscribe(this::onEvent);

Поколенческая гипотеза и цена барьеров

Практически все сборщики HotSpot опираются на эмпирику: подавляющее большинство объектов умирает молодыми. Значит, дёшево обходить только молодую область. Но тогда возникает проблема: ссылка из старого объекта в молодой не будет найдена при обходе молодых корней.

Решение — барьер записи. Каждое присваивание ссылочного поля компилируется не в одну инструкцию, а в две-три: собственно запись плюс отметка «в этой области памяти что-то изменилось». Классическая структура — card table: массив байт, где один байт покрывает 512 байт кучи. Отсюда два следствия: запись ссылки всегда чуть дороже записи примитива, а сборщик с более сильными гарантиями (ZGC, Shenandoah) требует более дорогих барьеров — и платит за короткие паузы пропускной способностью.

G1: сборщик по умолчанию

G1 (Garbage-First) — с JDK 9 сборщик по умолчанию и правильный выбор для 90 % серверных приложений. Его главная идея: куча не делится на большие поколения, она нарезана на равные регионы, а поколение — это роль, приписанная региону.

Куча G1: сетка регионов Eden, Survivor, Old, Humongous и свободных; эвакуация Collection Set

Полный цикл выглядит так:

Ключевые параметры, которые действительно имеет смысл трогать:

  • -XX:MaxGCPauseMillis=200 — целевая пауза (по умолчанию 200 мс). Это цель, а не гарантия: G1 подбирает размер молодого поколения так, чтобы в неё укладываться. Ставить 10 мс на куче в 8 ГБ бессмысленно — G1 сожмёт Eden, сборки станут частыми, пропускная способность упадёт.
  • -XX:G1HeapRegionSize — по умолчанию heap/2048, округлённое до степени двойки в диапазоне 1–32 МБ. Трогать стоит только при проблемах с humongous-объектами.
  • -XX:G1ReservePercent=10 — запас свободных регионов на случай всплеска аллокаций.

Главная патология G1 — evacuation failure (to-space exhausted): при эвакуации не нашлось свободных регионов под копирование живых объектов, и G1 срывается в full GC. Лечится увеличением кучи, повышением G1ReservePercent и уменьшением потока долгоживущих аллокаций.

Вторая — humongous-объекты: всё, что занимает от половины региона, выделяется цепочкой смежных регионов сразу в Old. Массив на 20 МБ при регионе 8 МБ — это три региона, которые трудно освободить и которые фрагментируют кучу. Практический вывод: крупные буферы и батчи режьте на куски или выносите off-heap.

ZGC и Shenandoah: когда пауза важнее всего

ZGC (JEP 439) держит паузы меньше миллисекунды независимо от размера кучи — на 16 ГБ и на 16 ТБ они одинаковы. Механика: метаданные хранятся прямо в неиспользуемых битах 64-битного указателя (цветные указатели), а на каждое чтение ссылки компилятор вставляет load barrier, который при необходимости чинит указатель на лету. Разметка, релокация и ремаппинг идут конкурентно с приложением. Цена — 10–20 % пропускной способности и заметный расход CPU.

java -XX:+UseZGC -Xmx32g -jar app.jar     # с JDK 23 поколенческий режим включён по умолчанию
java -XX:+UseZGC -XX:+ZGenerational ...    # в JDK 21 поколенческий режим включается явно

Shenandoah решает ту же задачу другим способом (барьеры на чтение ссылок, Brooks-подобная косвенность в ранних версиях, forwarding в mark word в современных). Практически выбирается по тому, что доступно в вашем дистрибутиве: Shenandoah есть в сборках Red Hat и Temurin, ZGC — везде.

Остальные варианты закрывают края спектра: Serial для контейнеров с одним vCPU и коротких джоб (у него минимальные накладные расходы и лучший старт), Parallel для пакетной обработки, где важна только пропускная способность, Epsilon — «не собирать вообще», для бенчмарков и заведомо короткоживущих процессов.

Читаем GC-лог

Единое логирование (-Xlog, JEP 158) с JDK 9 заменило зоопарк старых флагов. Вот типичная молодая сборка G1:

[2.417s][info][gc,start ] GC(12) Pause Young (Normal) (G1 Evacuation Pause)
[2.417s][info][gc,task  ] GC(12) Using 8 workers of 8 for evacuation
[2.431s][info][gc,phases] GC(12)   Pre Evacuate Collection Set: 0.1ms
[2.431s][info][gc,phases] GC(12)   Evacuate Collection Set: 12.4ms
[2.431s][info][gc,phases] GC(12)   Post Evacuate Collection Set: 1.0ms
[2.431s][info][gc,heap  ] GC(12) Eden regions: 96->0(90)
[2.431s][info][gc,heap  ] GC(12) Survivor regions: 6->12(13)
[2.431s][info][gc,heap  ] GC(12) Old regions: 41->41
[2.431s][info][gc,heap  ] GC(12) Humongous regions: 2->2
[2.431s][info][gc       ] GC(12) Pause Young (Normal) (G1 Evacuation Pause) 580M->228M(1024M) 14.102ms

Как это читать:

  • 580M->228M(1024M) — было занято, стало занято, размер кучи. Освободили 352 МБ за 14 мс.
  • Eden regions: 96->0(90) — Eden опустошён целиком, на следующий цикл G1 выделит 90 регионов (адаптивно ужал, чтобы попасть в целевую паузу).
  • Old regions: 41->41 — вот на что смотреть. Если это число растёт от сборки к сборке и никогда не падает после mixed GC, у вас утечка, а не «мало памяти».
  • Evacuate Collection Set: 12.4ms — почти вся пауза ушла на копирование живого. Значит, выживает слишком много: объекты не умирают внутри запроса.

Хорошие эвристики для тревоги: доля времени в GC больше 5–10 %; растущий floor кучи после full GC; появление to-space exhausted; Pause Full в логе вообще. Визуально логи удобно смотреть в GCeasy или GCViewer.

Слабые ссылки и финализация

Иногда достижимость нужно ослабить намеренно:

Тип Когда очищается Для чего
SoftReference перед OutOfMemoryError, по LRU-политике кэш «пока есть память» — на практике почти всегда плохая идея
WeakReference на первой же сборке, если нет сильных ссылок канонические таблицы, WeakHashMap, метаданные по классу
PhantomReference после того как объект стал недостижим освобождение нативных ресурсов через ReferenceQueue

SoftReference заслуживает отдельного предупреждения: политика их очистки непредсказуема, очищаются они слишком поздно (уже под давлением, когда GC и так страдает), а измерить эффективность такого кэша невозможно. Ограниченный кэш с явной политикой вытеснения (Caffeine) лучше во всех отношениях.

finalize() объявлен deprecated for removal (JEP 421) и использоваться не должен никогда: он недетерминирован, замедляет сборку, может воскресить объект и глотает исключения. Замены две: try-with-resources для всего, что закрывается, и java.lang.ref.Cleaner как страховка на случай, если закрыть забыли.

public class NativeBuffer implements AutoCloseable {
    private static final Cleaner CLEANER = Cleaner.create();
    private final Cleaner.Cleanable cleanable;

    public NativeBuffer(long size) {
        long addr = allocateNative(size);   // локальная переменная, а не поле — принципиально:
        this.cleanable = CLEANER.register(this, () -> freeNative(addr));  // лямбда не должна
    }                                       // захватывать this, иначе объект бессмертен

    @Override public void close() { cleanable.clean(); }   // детерминированный основной путь
}

Что настраивать, а что не трогать

Самая частая ошибка в тюнинге GC — копирование набора флагов из блога 2011 года. Половина из них относится к CMS, удалённому в JDK 14, и JVM просто откажется стартовать.

Разумный минимум для прода: размер кучи (MaxRAMPercentage), логирование GC, дамп при OOM, ExitOnOutOfMemoryError. Всё. Дальше — только по результатам измерения, по одному флагу за раз, с проверкой на реальном профиле нагрузки. Детальный процесс настройки — в Производительности и в официальном HotSpot GC Tuning Guide.

Часть 4. JIT: как байткод становится машинным кодом

Многоуровневая компиляция

HotSpot содержит два компилятора: C1 (быстро компилирует, слабо оптимизирует, умеет вставлять профилировочные счётчики) и C2 (компилирует медленно, оптимизирует агрессивно, опирается на собранный профиль). Между ними — пять уровней:

Уровень Кто Что делает
0 интерпретатор исполняет байткод, считает вызовы и обратные переходы
1 C1 простой метод, профиль не нужен — компилируется и забывается
2 C1 базовые счётчики (запасной путь, когда очередь C2 забита)
3 C1 полное профилирование: типы приёмника, вероятности ветвлений
4 C2 финальный агрессивный код по собранному профилю

Обычный путь горячего метода — 0 → 3 → 4:

Отсюда прямое следствие: первые секунды работы приложение медленнее в 5–20 раз. Это не «Java тормозит», это ещё не собранный профиль. И отсюда же — правило, что любые замеры без прогрева бессмысленны:

public class Warmup {
    static long work(int n) {
        long s = 0;
        for (int i = 0; i < n; i++) s += i % 7;
        return s;
    }

    public static void main(String[] args) {
        for (int round = 0; round < 6; round++) {
            long t0 = System.nanoTime();
            long r = work(20_000_000);
            System.out.printf("round %d: %d мс (r=%d)%n",
                              round, (System.nanoTime() - t0) / 1_000_000, r);
        }
    }
}
// round 0: 121 мс  ← интерпретатор, tier 0
// round 1:  34 мс  ← C1 с профилем, tier 3
// round 2:  13 мс  ← C2, tier 4
// round 3:  12 мс     дальше плато

Десятикратная разница между первым и третьим замером — это ровно путь 0 → 3 → 4. И даже этот код меряет неправильно: JIT может выкинуть вычисление целиком, если результат никуда не идёт. Корректно мерить — только через JMH, см. Тестирование и Производительность.

Что C2 умеет такого, чего не умеет статический компилятор

Инлайнинг — главная оптимизация, потому что она открывает все остальные. Пороги: методы до 35 байт байткода инлайнятся почти всегда, горячие — до 325 байт, глубина до 15 уровней (-XX:MaxInlineSize, -XX:FreqInlineSize, -XX:MaxInlineLevel). Практический вывод, обратный интуиции: короткие методы быстрее длинных, потому что их инлайнят. «Слишком большой метод» — реальная причина потери производительности.

Девиртуализация по профилю. C2 видит, что за интерфейсом PaymentGateway в этом процессе всегда стоит StripeGateway, и превращает виртуальный вызов в прямой — а затем инлайнит его. Три состояния call site:

  • мономорфный (один тип) — прямой вызов и инлайнинг, идеал;
  • биморфный (два типа) — проверка типа и два инлайна, всё ещё хорошо;
  • мегаморфный (три и больше) — честный виртуальный вызов через таблицу, инлайнинга нет.

Отсюда цена «архитектурно красивого» кода с десятью реализациями одного интерфейса на горячем пути: он мегаморфен, и JIT ничем помочь не может. Об этом же — в ООП.

Снятие проверок границ. Увидев for (int i = 0; i < a.length; i++), C2 доказывает, что i всегда в диапазоне, и выкидывает проверку из тела цикла. Именно поэтому канонический цикл по массиву быстрее «умных» вариантов с кэшированной длиной и обратным ходом.

Прочее: развёртка циклов, векторизация простых циклов в SIMD, вынос инвариантов, устранение блокировок на объектах, не покидающих метод (lock elision), объединение соседних блокировок (lock coarsening), спекулятивное отбрасывание никогда не исполнявшихся веток.

Деоптимизация: почему код может внезапно стать медленным

Все спекулятивные оптимизации сопровождаются проверками. Когда предположение нарушается — загрузился новый класс, сработала ветка, которой не было в профиле, — срабатывает uncommon trap: исполнение переносится обратно в интерпретатор прямо посреди метода, а скомпилированный код помечается made not entrant.

java -XX:+PrintCompilation Demo | head
    112   35       3       com.example.Order::total (23 bytes)
    128   41       4       com.example.Order::total (23 bytes)
    129   35       3       com.example.Order::total (23 bytes)   made not entrant
   3011   77       4       com.example.Order::total (23 bytes)   made not entrant
   3014   81       4       com.example.Order::total (23 bytes)

Колонки: время от старта в мс, номер компиляции, уровень, метод, размер, флаги. Строка made not entrant на четвёртом уровне через три секунды после старта — это и есть деоптимизация: приложение перешло на «второй сценарий», профиль оказался неверным, C2 перекомпилировал метод заново.

Практический эффект стоит помнить: прогретый на синтетическом трафике сервис деоптимизируется, когда приходит реальный. Прогрев должен повторять продовый профиль, иначе он вреден.

Посмотреть, что именно было заинлайнено:

java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:+PrintCompilation Demo
  @ 12  com.example.Order::total (23 bytes)              inline (hot)
  @ 8   java.util.ArrayList::get (11 bytes)              inline (hot)
  @ 21  com.example.Discount::apply (412 bytes)          too big
  @ 33  com.example.Rule::matches (18 bytes)             failed to inline: virtual call

too big и failed to inline: virtual call — два самых частых диагноза при разборе горячего пути. Удобная надстройка над этими логами — JITWatch.

Прогрев как продовая проблема и способы его убрать

Модель «интерпретировать, потом скомпилировать» плохо совместима с serverless и с сервисами, которые часто перезапускаются. Отсюда четыре разных ответа платформы:

  1. CDS / AppCDS — предварительно разобранные метаданные классов в общем архиве. Стартовое время падает на 20–40 %, работает из коробки:
    java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar      # запись архива
    java -XX:SharedArchiveFile=app.jsa -jar app.jar          # использование
    
  2. AOT-кэш проекта Leyden (JEP 483, JDK 24+) — сохраняет не только метаданные, но и результаты загрузки и связывания, а в JDK 25 ещё и профили методов. Старт ускоряется в разы без отказа от JIT.
  3. GraalVM Native Image — компиляция в нативный бинарник заранее. Старт за миллисекунды и втрое меньше памяти, ценой закрытого мира (рефлексия и динамическая генерация классов требуют конфигурации) и потери пиковой производительности на длинной дистанции.
  4. CRaC / SnapStart — снимок уже прогретого процесса, восстанавливаемый за десятки миллисекунд. См. проект CRaC.

Правило выбора простое: длинноживущий сервер — обычная JVM с CDS; функция или CLI-утилита — native image или CRaC. Подробности сборки — в Деплое и наблюдаемости.

Часть 5. Диагностика: инструменты вместо догадок

Всё, что описано выше, наблюдаемо снаружи. Именно наблюдаемость — сильнейшая сторона JVM.

Задача Инструмент Команда
Что сейчас с кучей jcmd jcmd <pid> GC.heap_info
Кто занимает кучу, грубо jcmd jcmd <pid> GC.class_histogram
Полный снимок кучи jcmd jcmd <pid> GC.heap_dump /tmp/heap.hprof
Анализ снимка Eclipse MAT Leak Suspects, Dominator Tree
Нативная память NMT jcmd <pid> VM.native_memory summary
Дедлок, состояние потоков jcmd jcmd <pid> Thread.print
Действующие флаги jcmd jcmd <pid> VM.flags -all
Динамика GC в реальном времени jstat jstat -gcutil <pid> 1s
Профиль CPU, аллокаций, блокировок async-profiler asprof -d 30 -e cpu -f fg.html <pid>
Всё сразу, с низким оверхедом JFR jcmd <pid> JFR.start settings=profile duration=60s filename=r.jfr
Точная раскладка объекта JOL java -jar jol-cli.jar internals java.util.HashMap

Дерево решений, когда что-то сломалось:

Safepoints — источник пауз, которые не GC

Чтобы остановить мир, JVM не может прервать поток где угодно: ей нужны точки, в которых известна раскладка стека — safepoints. Поток доходит до ближайшей такой точки сам, и время этого пути (time to safepoint, TTSP) в паузу тоже входит.

Классическая ловушка — длинный счётный цикл с int-счётчиком: внутри него компилятор исторически не вставлял опрос safepoint, потому что цикл «конечный и короткий». Один поток, считающий такой цикл секунду, задерживает GC на всю эту секунду — и в GC-логе вы увидите паузу в секунду при абсолютно здоровой куче. Современные сборщики (G1, ZGC, Shenandoah) применяют loop strip mining и разбивают такие циклы, но диагностировать это нужно уметь:

java -Xlog:safepoint -jar app.jar
# [12.404s][info][safepoint] Safepoint "G1CollectForAllocation", Time since last: 812 ms,
#                            Reaching safepoint: 640 ms, At safepoint: 12 ms, Total: 652 ms
#                                                ^^^^^^ 640 мс из 652 — это TTSP, а не сборка

Типичные грабли

  1. -Xmx равен лимиту контейнера. Под убивают по памяти, OutOfMemoryError в логе нет. Считайте все области или используйте MaxRAMPercentage в районе 70 %.
  2. Настройки по умолчанию в контейнере. MaxRAMPercentage = 25 %: три четверти памяти пода просто не используются.
  3. Куча больше 32 ГБ. Отключаются compressed oops, ссылки становятся 8-байтовыми, объекты пухнут примерно в полтора раза. Куча в 33 ГБ вмещает меньше данных, чем куча в 31 ГБ. Либо оставайтесь ниже границы, либо прыгайте сразу за 40 ГБ.
  4. System.gc() в коде. Полная остановка мира по требованию. Либо -XX:+DisableExplicitGC, либо -XX:+ExplicitGCInvokesConcurrent — но помните, что освобождение direct-буферов исторически на него опирается.
  5. OutOfMemoryError не убивает приложение. Умирает только поток; сервис продолжает отвечать ошибками. Всегда ставьте -XX:+ExitOnOutOfMemoryError.
  6. GC overhead limit exceeded читают как «мало памяти». Это диагноз «98 % времени в GC при возврате менее 2 % кучи» — почти всегда утечка, а не размер -Xmx.
  7. Тюнинг флагами из старого блога. Флаги CMS удалены в JDK 14, JVM не стартует. Начинайте с дефолтов плюс логи.
  8. Замеры без прогрева. Меряется интерпретатор. Только JMH, только с @Warmup.
  9. Микробенчмарк без Blackhole. JIT видит, что результат не используется, и удаляет вычисление целиком. Получается «оптимизация в тысячу раз».
  10. ThreadLocal в пуле потоков без remove(). Утечка, растущая ровно со скоростью трафика. С виртуальными потоками паттерн ломается вдвойне — используйте ScopedValue.
  11. SoftReference как кэш. Непредсказуемо, срабатывает поздно, не измеряется. Берите ограниченный кэш с явной политикой вытеснения.
  12. Крупные byte[] на горячем пути. Humongous-регионы фрагментируют Old и провоцируют срывы в full GC. Режьте буферы.
  13. Снятие дампа на живом проде. Это полная остановка мира, пропорциональная размеру кучи: на 20 ГБ — десятки секунд, балансировщик успеет вывести ноду. А HeapDumpOnOutOfMemoryError без ротации после нескольких перезапусков забьёт диск дампами по 8 ГБ.
  14. Метрики JVM не собираются. Куча, паузы GC, загрузка Code Cache и Metaspace должны быть в дашборде до первой аварии, а не после. См. Деплой и наблюдаемость.

Честно о платформе

Где JVM объективно сильна. Длинноживущие серверные процессы под ровной нагрузкой: через минуту работы JIT знает про ваш код больше, чем любой статический компилятор, и горячий путь сопоставим с C++. Очень большие кучи: субмиллисекундные паузы ZGC на терабайтах — уникальная для управляемых сред возможность. Наблюдаемость: JFR, JVMTI-агенты, heap dump с полным графом объектов, инструментирование байткода на лету — диагностика продового инцидента без перезапуска и перекомпиляции здесь норма, а не подвиг. Плюс экосистема, проверенная миллионами часов аптайма.

Где платформа проигрывает. Старт и базовое потребление памяти — приговор для CLI-утилит, коротких джоб и функций с холодным стартом (native image и CRaC помогают, но добавляют сложность). Плотность данных: своих типов-значений в языке пока нет (проект Valhalla в работе), List<Integer> на миллион элементов — это ~20 МБ и миллион промахов кэша против 4 МБ подряд у int[], см. Производительность: память. Хвост латентности: даже субмиллисекундные паузы — это паузы, для hard real-time платформа не годится. Предсказуемость: один и тот же код на одной машине работает по-разному в зависимости от профиля прогрева — это цена адаптивности.

Сравнение с .NET. Ниша близкая, и на портале есть трек C#/.NET, поэтому отметим честно: CLR устроена по тем же принципам — байткод (IL), многоуровневый JIT, поколенческий сборщик. Три существенных различия. Первое: в .NET есть настоящие типы-значения (struct) и реифицированные дженерики, поэтому List<int> не боксирует, а раскладка данных плотнее — здесь Java объективно позади. Второе: у HotSpot гораздо шире меню сборщиков; конкурентного копирующего сборщика класса ZGC в .NET нет, и на кучах в десятки гигабайт это решает. Третье: Native AOT в .NET — более зрелая и штатная часть платформы, чем GraalVM Native Image в мире Java. Наблюдаемость сопоставима (JFR против EventPipe), но экосистема агентов и инструментирования байткода на JVM богаче.

Куда JVM не стоит тащить: короткие CLI-утилиты, функции с требованием холодного старта ниже 100 мс, встраиваемые системы с единицами мегабайт памяти, hard real-time, плотный численный код с ручным управлением раскладкой. Всё остальное серверное — её территория.

Мини-итог

  • Байткод — стековые инструкции плюс символические ссылки в constant pool. Читать javap -c полезно ровно там, где вас удивляет поведение кода: value++, конкатенация строк, лямбды, for-each.
  • Классы загружаются лениво и проходят фазы Loaded → Verified → Prepared → Resolved → Initialized. Половина «мистических null» и NoClassDefFoundError объясняется этой цепочкой.
  • -Xmx — это не вся память процесса. RSS = куча + Metaspace + Code Cache + стеки + off-heap + структуры GC. В контейнере считайте всё, иначе познакомитесь с OOM-killer.
  • Аллокация дёшева, выживание дорого. Стоимость молодой сборки пропорциональна объёму выживших, а не объёму мусора. Оптимизировать нужно время жизни, а не количество new.
  • Утечка в Java — это достижимость. Статические коллекции, ThreadLocal в пулах, неотписанные слушатели, кэши без вытеснения.
  • G1 — разумный дефолт, ZGC — когда паузы важнее пропускной способности, Serial — для однопроцессорных контейнеров и коротких джоб. Начинайте с дефолтов плюс логи.
  • JIT спекулирует и умеет отступать. Инлайнинг открывает остальные оптимизации; мегаморфные вызовы его блокируют; деоптимизация — нормальная часть жизни, а не сбой.
  • Ничего не угадывайте. jcmd, JFR, NMT, async-profiler, MAT и JMH дают ответ за минуты; рассуждения о производительности по исходнику не дают его никогда.

Источники

Что дальше

Мы разобрали машину, на которой всё исполняется: как выглядит байткод, где живут объекты, как они умирают и как горячий код превращается в машинный. Этого знания достаточно, чтобы читать GC-логи, находить утечки и не пугаться профайлера.

Следующий шаг — научиться доказывать, что код делает то, что должен, до того как он попадёт в прод: модульные тесты на JUnit 5, изоляция зависимостей через Mockito, честные интеграционные тесты с настоящей базой в Testcontainers.

Тестирование: JUnit 5, Mockito, Testcontainers, интеграционные тесты

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов