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 нет регистров. Есть массив локальных переменных и стек операндов — на кадр метода. Инструкции кладут значения на стек, снимают их и кладут результат обратно.
Возьмём тривиальный класс:
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 в статическом поле».
и создал объект Class Loaded --> Verified: верификация — типобезопасность,
границы стека, корректность переходов Verified --> Prepared: статические поля получают
значения по умолчанию (0, null, false) Prepared --> Resolved: символические ссылки пула
заменяются на прямые — лениво Resolved --> Initialized: выполняется clinit —
static-блоки и инициализаторы Initialized --> Working: обычная работа Working --> Working: вызовы методов, чтение полей Working --> Unloaded: недостижим и класс,
и его ClassLoader Unloaded --> [*] Loaded --> Failed: NoClassDefFoundError,
ClassFormatError Verified --> Failed: VerifyError Initialized --> Failed: ExceptionInInitializerError
Три ловушки живут именно здесь:
Preparedнаступает раньшеInitialized. Между ними статическое поле уже существует, но равноnull. Круговая зависимость двух классов в статических инициализаторах даёт стабильно воспроизводимыйnull— и никакого исключения.ExceptionInInitializerErrorнеобратим. Еслиclinitупал, класс помечается какErroneousнавсегда; повторные обращения дадутNoClassDefFoundErrorбез исходной причины. Настоящая ошибка — только в самой первой строчке лога.- Класс выгружается вместе со своим загрузчиком, и никак иначе. Отсюда «утечки 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 % серверных приложений. Его главная идея: куча не делится на большие поколения, она нарезана на равные регионы, а поколение — это роль, приписанная региону.
Полный цикл выглядит так:
эвакуация Eden и Survivor,
десятки миллисекунд"] --> T{"Old занял больше
InitiatingHeapOccupancyPercent,
по умолчанию 45%?"} T -->|нет| Y T -->|да| CS["Concurrent Start
прицеплен к молодой паузе:
помечает корни"] CS --> RRS["Root Region Scan
конкурентно, приложение работает"] RRS --> CM["Concurrent Mark
обход графа живых объектов,
SATB-барьеры ловят изменения"] CM --> RM["Remark — STW, короткая
дочистка SATB-буферов,
обработка слабых ссылок"] RM --> CU["Cleanup — STW
подсчёт живого по регионам,
полностью пустые сразу в Free"] CU --> MX["Mixed GC — серия сборок
young + самые мусорные Old-регионы"] MX --> Y MX -.->|"свободных регионов не хватило
под копирование"| FULL["Full GC — STW
полная разметка и уплотнение,
секунды на большой куче"] FULL --> Y
Ключевые параметры, которые действительно имеет смысл трогать:
-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 и с сервисами, которые часто перезапускаются. Отсюда четыре разных ответа платформы:
- CDS / AppCDS — предварительно разобранные метаданные классов в общем архиве. Стартовое
время падает на 20–40 %, работает из коробки:
java -XX:ArchiveClassesAtExit=app.jsa -jar app.jar # запись архива java -XX:SharedArchiveFile=app.jsa -jar app.jar # использование - AOT-кэш проекта Leyden (JEP 483, JDK 24+) — сохраняет не только метаданные, но и результаты загрузки и связывания, а в JDK 25 ещё и профили методов. Старт ускоряется в разы без отказа от JIT.
- GraalVM Native Image — компиляция в нативный бинарник заранее. Старт за миллисекунды и втрое меньше памяти, ценой закрытого мира (рефлексия и динамическая генерация классов требуют конфигурации) и потери пиковой производительности на длинной дистанции.
- 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 |
Дерево решений, когда что-то сломалось:
Dominator Tree и Leak Suspects.
Ищем самый большой удерживаемый граф"] Q1 -->|"OutOfMemoryError: Metaspace"| H2["Утечка загрузчиков классов:
прокси, скриптовые движки,
hot redeploy в контейнере приложений"] Q1 -->|"OutOfMemoryError: Direct buffer memory"| H3["Off-heap: Netty, NIO, mmap.
MaxDirectMemorySize,
детектор утечек ByteBuf"] Q1 -->|"unable to create native thread"| H4["Слишком много платформенных потоков
или лимит pids/nproc в контейнере"] Q1 -->|"OOMKilled от cgroup, в логе пусто"| H5["RSS больше лимита пода:
NMT summary и пересчёт всех областей,
не только кучи"] Q1 -->|"ничего, просто медленно"| Q2{"CPU близко к 100%?"} Q2 -->|да| P1["async-profiler: cpu, alloc, lock.
Флеймграф, затем JMH
на подозреваемом методе"] Q2 -->|нет| P2["Xlog:gc, Xlog:safepoint, JFR:
паузы, время до safepoint,
блокировки, ожидание IO"] H1 --> R["Исправить, добавить регрессионный тест
на потребление памяти"] P1 --> R P2 --> R
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, а не сборка
Типичные грабли
-Xmxравен лимиту контейнера. Под убивают по памяти,OutOfMemoryErrorв логе нет. Считайте все области или используйтеMaxRAMPercentageв районе 70 %.- Настройки по умолчанию в контейнере.
MaxRAMPercentage= 25 %: три четверти памяти пода просто не используются. - Куча больше 32 ГБ. Отключаются compressed oops, ссылки становятся 8-байтовыми, объекты пухнут примерно в полтора раза. Куча в 33 ГБ вмещает меньше данных, чем куча в 31 ГБ. Либо оставайтесь ниже границы, либо прыгайте сразу за 40 ГБ.
System.gc()в коде. Полная остановка мира по требованию. Либо-XX:+DisableExplicitGC, либо-XX:+ExplicitGCInvokesConcurrent— но помните, что освобождение direct-буферов исторически на него опирается.OutOfMemoryErrorне убивает приложение. Умирает только поток; сервис продолжает отвечать ошибками. Всегда ставьте-XX:+ExitOnOutOfMemoryError.GC overhead limit exceededчитают как «мало памяти». Это диагноз «98 % времени в GC при возврате менее 2 % кучи» — почти всегда утечка, а не размер-Xmx.- Тюнинг флагами из старого блога. Флаги CMS удалены в JDK 14, JVM не стартует. Начинайте с дефолтов плюс логи.
- Замеры без прогрева. Меряется интерпретатор. Только JMH, только с
@Warmup. - Микробенчмарк без
Blackhole. JIT видит, что результат не используется, и удаляет вычисление целиком. Получается «оптимизация в тысячу раз». ThreadLocalв пуле потоков безremove(). Утечка, растущая ровно со скоростью трафика. С виртуальными потоками паттерн ломается вдвойне — используйтеScopedValue.SoftReferenceкак кэш. Непредсказуемо, срабатывает поздно, не измеряется. Берите ограниченный кэш с явной политикой вытеснения.- Крупные
byte[]на горячем пути. Humongous-регионы фрагментируют Old и провоцируют срывы в full GC. Режьте буферы. - Снятие дампа на живом проде. Это полная остановка мира, пропорциональная размеру кучи:
на 20 ГБ — десятки секунд, балансировщик успеет вывести ноду. А
HeapDumpOnOutOfMemoryErrorбез ротации после нескольких перезапусков забьёт диск дампами по 8 ГБ. - Метрики 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 дают ответ за минуты; рассуждения о производительности по исходнику не дают его никогда.
Источники
- The Java Virtual Machine Specification, SE 21 — первоисточник; глава 2 (структура JVM), глава 4 (формат
.class), глава 6 (набор инструкций). - HotSpot Virtual Machine Garbage Collection Tuning Guide — единственное руководство по GC, которому стоит доверять по умолчанию.
- Java Platform Troubleshooting Guide — официальный разбор OOM, дампов и диагностических команд.
- JEP 158: Unified JVM Logging — как устроен современный
-Xlog. - JEP 439: Generational ZGC и JEP 387: Elastic Metaspace — мотивация и дизайн изменений последних лет.
- JEP 483: Ahead-of-Time Class Loading & Linking — старт проекта Leyden.
- Aleksey Shipilëv, JVM Anatomy Quarks — короткие точные разборы устройства HotSpot от инженера OpenJDK; лучшее чтение по теме на английском.
- Scott Oaks, Java Performance: The Definitive Guide, 2nd ed. — GC, JIT и настройка на практике.
- Benjamin Evans, James Gough, Chris Newland, Optimizing Java — методология измерений, JMH, разбор байткода.
- Richard Jones, Antony Hosking, Eliot Moss, The Garbage Collection Handbook — академическая база по всем алгоритмам сборки.
- Bill Venners, Inside the Java Virtual Machine — старая, но до сих пор лучшая книга именно про байткод; доступна бесплатно.
- OpenJDK: исходники HotSpot — когда документация закончилась, а вопрос остался.
- async-profiler, JMH, JOL, Eclipse MAT — рабочий набор инструментов.
Что дальше
Мы разобрали машину, на которой всё исполняется: как выглядит байткод, где живут объекты, как они умирают и как горячий код превращается в машинный. Этого знания достаточно, чтобы читать GC-логи, находить утечки и не пугаться профайлера.
Следующий шаг — научиться доказывать, что код делает то, что должен, до того как он попадёт в прод: модульные тесты на JUnit 5, изоляция зависимостей через Mockito, честные интеграционные тесты с настоящей базой в Testcontainers.
Тестирование: JUnit 5, Mockito, Testcontainers, интеграционные тесты