Производительность: профилирование, JMH, настройка GC, типичные утечки
Разговор о производительности в Java почти всегда начинается не с того конца. Кто-то
заменяет ArrayList на массив, кто-то расставляет final, кто-то переписывает стримы
в циклы — и всё это до того, как хоть раз посмотрел, куда уходит время. Между тем в
типичном сервисе на Spring Boot 90% latency — это ожидание базы, сериализация JSON,
неудачно настроенный пул соединений и паузы сборщика мусора. Код в горячем цикле —
редкий и приятный случай.
У JVM есть особенность, которой нет у C и Go: производительность здесь динамическая.
Один и тот же байткод через 10 секунд работы быстрее в 20–50 раз, чем на первой итерации,
потому что JIT успел собрать профиль, заинлайнить, развернуть цикл и выкинуть проверки.
Это делает Java одной из самых быстрых платформ для долгоживущих серверов — и одной
из самых коварных для измерения. Наивный замер System.nanoTime() вокруг цикла в Java
не просто неточен, он систематически лжёт: измеряет либо интерпретатор, либо код, который
компилятор вырезал целиком как мёртвый.
Поэтому статья построена так: сначала — что вообще измерять, затем инструменты по уровням (прод → профиль → микробенчмарк), затем JIT и GC как две главные машины, определяющие поведение под нагрузкой, и в конце — утечки, которые в языке со сборкой мусора никуда не делись, просто выглядят иначе.
Что именно вы оптимизируете
Прежде чем открывать профайлер, ответьте на три вопроса. Без них оптимизация превращается в набор случайных микроправок.
- Метрика. Latency (время одного запроса) или throughput (запросов в секунду)? Это разные, часто конфликтующие цели: батчинг улучшает throughput и ухудшает latency, ZGC улучшает latency и отнимает 10–15% throughput.
- Перцентиль. Среднее время — бесполезная величина. Пользователь, попавший в p99, не утешится тем, что в среднем всё хорошо. Более того: страница, дёргающая 20 сервисов, попадает в чей-нибудь p99 с вероятностью около 18%. Смотрите p50/p95/p99/p999 и max.
- Цель. «Быстрее» — не цель. «p99 создания заказа ниже 200 мс при 500 rps на двух ядрах» — цель, у неё есть критерий остановки.
Отдельно про coordinated omission — систематическую ошибку, из-за которой почти все
самодельные нагрузочные скрипты врут в разы. Если клиент отправляет следующий запрос
только после ответа на предыдущий, то во время двухсекундной паузы GC он просто
не отправляет запросы — и эти секунды не попадают в гистограмму. Реальные пользователи
так себя не ведут: они продолжают приходить. Лечится генератором с фиксированным rate
(k6, Gatling, wrk2) и HdrHistogram, который умеет корректировать. Классический доклад
Гила Тене «How NOT to measure latency»
стоит посмотреть целиком; методика нагрузочного тестирования подробно разобрана в
треке производительности.
p99 < 200 мс при 500 rps"] B --> C["Измерить текущее состояние
прод-метрики, нагрузочный тест"] C --> D{"Узкое место видно
из метрик?"} D -- нет --> E["Профилировать: JFR / async-profiler
CPU, alloc, lock, wall"] D -- да --> F["Гипотеза о причине"] E --> F F --> G{"Гипотеза про алгоритм
или про мелкий код?"} G -- алгоритм / IO / запрос --> H["Чинить дизайн: индекс, кэш,
батч, убрать N+1"] G -- мелкий код --> I["JMH: доказать разницу
на изолированном бенчмарке"] H --> J["Перемерить на той же нагрузке"] I --> J J --> K{"Цель достигнута?"} K -- нет --> F K -- да --> L["Зафиксировать: тест на регрессию,
алерт на метрику"]
Главное в этой схеме — стрелка J → F. Оптимизация без повторного замера на той же
нагрузке не считается сделанной: в половине случаев «улучшение» ничего не меняет,
а в четверти — ухудшает. Общая методика цикла оптимизации разобрана в
отдельной статье;
здесь мы фокусируемся на инструментах JVM.
Четыре уровня наблюдения
Инструменты не взаимозаменяемы. Каждый отвечает на свой вопрос, и попытка ответить на вопрос верхнего уровня инструментом нижнего — самая частая методическая ошибка.
| Уровень | Вопрос | Инструмент | Оверхед |
|---|---|---|---|
| Прод-метрики | «Стало ли хуже и когда?» | Micrometer + Prometheus, RED/USE | ~0 |
| Непрерывный профиль | «Где в этом сервисе время?» | JDK Flight Recorder, jcmd |
1–2% |
| Точечный профиль | «Какой стек греет CPU / аллоцирует?» | async-profiler, JFR settings=profile |
2–10% |
| Микробенчмарк | «Какой из двух вариантов кода быстрее?» | JMH | не важен |
Правило: двигайтесь сверху вниз и никогда не начинайте с JMH. Микробенчмарк отвечает на вопрос «что быстрее», но не на вопрос «что важно». Оптимизировать метод, который занимает 0.3% времени, бессмысленно даже если ускорить его в сто раз — это закон Амдала, и он не подлежит обжалованию. Метрики и трейсинг — тема следующей статьи, здесь начнём с JFR.
JDK Flight Recorder: профайлер, который не жалко держать включённым
JFR — встроенный в JVM механизм записи событий: аллокации, паузы GC, блокировки монитора,
компиляции, сокеты, файловый ввод-вывод, исключения, парковка виртуальных потоков. Раньше
это была коммерческая функция Oracle, с JDK 11 она в OpenJDK и бесплатна. Ключевое свойство —
оверхед около 1% на профиле default, поэтому JFR включают постоянно на проде и снимают
дамп в момент инцидента, а не «когда-нибудь потом воспроизведём».
# Вариант 1: писать всегда, кольцевым буфером, дамп по требованию
java -XX:StartFlightRecording=name=cont,maxsize=256m,maxage=6h,settings=default \
-XX:FlightRecorderOptions=stackdepth=128 \
-jar app.jar
# Вариант 2: подключиться к живому процессу и снять минуту подробного профиля
jcmd <pid> JFR.start name=deep settings=profile duration=60s filename=/tmp/deep.jfr
jcmd <pid> JFR.dump name=cont filename=/tmp/cont.jfr # выгрузить непрерывную запись
jcmd <pid> JFR.check # что вообще пишется
Дефолтный stackdepth=64 часто обрезает стеки в Spring до неузнаваемости — во фреймворке
легко 80+ кадров прокси и фильтров. Ставьте 128, это дешевле, чем бесполезный профиль.
Разбирать файл можно в JDK Mission Control,
а можно из терминала — утилита jfr идёт в составе JDK:
jfr summary /tmp/deep.jfr | head -20
# Event Type Count Size (bytes)
# jdk.ExecutionSample 5981 142918 <- сэмплы стеков, основа CPU-профиля
# jdk.ObjectAllocationSample 2410 71226 <- аллокации, сэмплированные
# jdk.JavaMonitorEnter 883 31544 <- кто на чём блокировался
# jdk.GCPhasePause 214 6420
# jdk.SocketRead 190 9310
# Топ методов по аллокациям — без выгрузки в GUI
jfr print --events ObjectAllocationSample --stack-depth 3 /tmp/deep.jfr | head -40
Что смотреть в JMC в первую очередь: вкладку Automated Analysis (она сама укажет
на очевидное — слишком долгие паузы, голодание пула, TLAB-давление), затем Method
Profiling, TLAB Allocations, Lock Instances, GC Configuration. Событие
jdk.ObjectAllocationSample (JDK 16+) заменило старую пару ObjectAllocationInNewTLAB /
OutsideTLAB и стоит дёшево — используйте его, чтобы найти виновника мусорного шторма.
Историческая слабость JFR — safepoint bias: сэмплер стеков работал через механизм,
требующий остановки потока в safepoint, а safepoint’ы стоят не в произвольных точках кода.
В результате горячий счётный цикл мог быть недопредставлен. В JDK 25 появился профайлер
по CPU-времени (JEP 509, событие jdk.CPUTimeSample,
пока Linux и experimental), который эту предвзятость снимает. До JDK 25 честный ответ
даёт async-profiler.
async-profiler: когда нужен настоящий стек
async-profiler сэмплирует стеки через
AsyncGetCallTrace (внутренний API HotSpot, не требующий safepoint) плюс perf_events
ядра — поэтому видит и Java-кадры, и нативные, включая JIT-код, GC-потоки и системные
вызовы. Это де-факто стандарт профилирования Java на Linux.
# 30 секунд CPU-профиля живого процесса в интерактивный флеймграф
asprof -d 30 -e cpu -f /tmp/cpu.html <pid>
# Профиль аллокаций: где рождается мусор (событие TLAB, а не каждый new)
asprof -d 30 -e alloc -f /tmp/alloc.html <pid>
# Wall-clock: где висим, включая ожидание. Для «висит, но CPU 3%»
asprof -d 30 -e wall -t -f /tmp/wall.html <pid>
# Блокировки мониторов и ReentrantLock
asprof -d 30 -e lock -f /tmp/lock.html <pid>
Разница между режимами принципиальна и её постоянно путают. -e cpu показывает, на что
тратится процессор; если сервис ждёт базу, там будет почти пусто. -e wall показывает,
где проходит календарное время, включая ожидание — именно он находит «запрос идёт
800 мс, из них 780 мс ждём JDBC». Начинайте с wall, если жалоба про latency, и с cpu,
если жалоба про загрузку ядер или счёт за облако.
Читать флеймграф надо снизу вверх, и главное — понимать, что горизонталь не является временем. Кадры отсортированы по алфавиту и склеены по одинаковым стекам; ширина кадра означает «в скольких сэмплах он присутствовал». Оптимизировать нужно плато — широкие кадры в верхушке, у которых мало или нет потомков: это и есть self time. Узкая высокая башня — глубокий, но редкий путь, трогать бессмысленно. Подробнее о технике — flame graphs Брендана Грегга и CPU-профилирование в профильном треке.
JMH: единственный корректный микробенчмарк
Допустим, профиль показал горячий метод и вы хотите сравнить две реализации. Здесь начинается зона, где интуиция не работает вообще. Вот «замер», который написал бы каждый:
// НЕПРАВИЛЬНО. Этот код измеряет что угодно, кроме того, что вы думаете.
long t0 = System.nanoTime();
for (int i = 0; i < 1_000_000; i++) {
Math.log(i); // результат не используется
}
System.out.println((System.nanoTime() - t0) / 1_000_000 + " мс");
// Вывод: "0 мс" — и это честный ответ JVM
Что здесь сломано: (1) dead code elimination — результат не используется, C2 выкидывает вызов целиком; (2) отсутствие прогрева — первые тысячи итераций идут в интерпретаторе; (3) constant folding — если аргумент константа, вычисление уедет в compile time; (4) один процесс на все варианты — профиль первого варианта портит компиляцию второго (profile pollution); (5) нет статистики — одно число без разброса ничего не значит.
JMH решает все пять проблем. Это не библиотека, а генератор кода: он строит вокруг вашего метода обвязку, которая обманывает оптимизатор ровно настолько, чтобы измерение стало осмысленным.
// build: mvn archetype:generate -DarchetypeGroupId=org.openjdk.jmh \
// -DarchetypeArtifactId=jmh-java-benchmark-archetype -DarchetypeVersion=1.37
@BenchmarkMode(Mode.AverageTime) // среднее время одного вызова
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@State(Scope.Benchmark) // состояние общее на весь бенчмарк
@Fork(value = 2, jvmArgs = {"-Xms1g", "-Xmx1g"}) // 2 отдельные JVM: изоляция профиля JIT
@Warmup(iterations = 5, time = 1) // 5 секунд прогрева — минимум для C2
@Measurement(iterations = 10, time = 1)
public class JoinBenchmark {
@Param({"10", "1000"}) // бенчмарк прогонится для каждого значения
private int size;
private List<String> data;
@Setup(Level.Trial)
public void setup() {
data = IntStream.range(0, size).mapToObj(i -> "item-" + i).toList();
}
@Benchmark
public String concat() { // возвращаем результат — JMH его «потребит»
String result = "";
for (String s : data) result += s; // O(n^2): каждая итерация копирует строку
return result;
}
@Benchmark
public String builder() {
StringBuilder sb = new StringBuilder(size * 8); // предразмер: без перекопирований
for (String s : data) sb.append(s);
return sb.toString();
}
@Benchmark
public void sideEffect(Blackhole bh) { // если результатов несколько — Blackhole
for (String s : data) bh.consume(s.length());
}
}
Запуск и типичный вывод:
mvn clean package && java -jar target/benchmarks.jar JoinBenchmark -prof gc
# Benchmark (size) Mode Cnt Score Error Units
# JoinBenchmark.builder 10 avgt 20 118,4 ± 3,1 ns/op
# JoinBenchmark.builder 1000 avgt 20 9 842,7 ± 210,5 ns/op
# JoinBenchmark.concat 10 avgt 20 243,9 ± 6,8 ns/op
# JoinBenchmark.concat 1000 avgt 20 1 984 331,2 ± 61 402,0 ns/op
#
# JoinBenchmark.concat:·gc.alloc.rate.norm 1000 avgt 20 4 015 992,0 B/op
# JoinBenchmark.builder:·gc.alloc.rate.norm 1000 avgt 20 32 168,0 B/op
Смотрите не только на Score. Колонка Error — доверительный интервал: если он
перекрывается у двух вариантов, разницы вы не доказали. А -prof gc даёт
gc.alloc.rate.norm — сколько байт аллоцирует один вызов; это самая стабильная метрика
в бенчмарках, она не зависит от загрузки машины и шумит куда меньше времени. Здесь видно
главное: разница не «в 200 раз быстрее», а «в 125 раз меньше мусора» — на 1000 элементов
конкатенация в цикле создаёт 4 МБ временных строк.
Ловушки JMH, на которых спотыкаются все:
- Запуск из IDE без форка.
@Fork(0)удобно для отладки и полностью недостоверно. - Слишком короткий прогрев. C2 включается после ~10 000 вызовов; для методов
со сложным профилем нужно больше. Смотрите, выходит ли
Scoreна плато между итерациями. - Цикл внутри бенчмарка. JMH меряет вызов метода. Внутренний цикл по константе даёт
простор для развёртки и векторизации, которых в реальном коде не будет. Если цикл нужен —
используйте
@OperationsPerInvocation. - Мономорфизм. Бенчмарк вызывает одну реализацию интерфейса, JIT инлайнит её напрямую. В проде на том же месте пять реализаций, call site мегаморфный, и весь выигрыш испаряется.
- Шумная машина. Ноутбук с троттлингом, включённый turbo boost, соседний контейнер. Для сравнений в пределах 5% нужна изолированная машина с фиксированной частотой.
- Общее изменяемое состояние между итерациями
@State(Scope.Benchmark)в многопоточном режиме: получите бенчмарк contention, а не логики.
Начинайте не с документации, а с официальных сэмплов — там 38 пронумерованных примеров, каждый показывает конкретную ловушку. Плюс канонический текст Алексея Шипилёва «Nanotrusting the Nanotime» о том, почему замер времени сам по себе стоит десятки наносекунд. Общая теория микробенчмаркинга — в треке производительности.
JIT: почему первые секунды не считаются
HotSpot исполняет метод в четырёх режимах, переключаясь по мере накопления статистики. Понимание этой лестницы объясняет и warmup, и «оно быстрое в бенчмарке и медленное в проде».
новый подтип, редкая ветка, null Деоптимизация --> C1_профилирующий : пересобрать профиль C2 --> [*] : стабильный пиковый режим note right of C2 Инлайнинг до 35 байт всегда, до 325 байт для горячих методов, escape analysis, развёртка циклов, векторизация, снятие проверок границ end note
Практические следствия:
- Прогрев реален. Первые запросы после старта в 20–50 раз медленнее. Отсюда всплеск
p99 на деплое и необходимость warmup-трафика перед включением инстанса в балансировку.
В JDK 24+ это частично лечится AOT-кэшем (Project Leyden):
-XX:AOTCacheOutput/-XX:AOTCacheсохраняют загруженные классы и профили между запусками. - Инлайнинг — мать всех оптимизаций. Без него не работают ни escape analysis,
ни свёртка констант. Метод длиннее 325 байткодов не заинлайнится никогда: гигантские
методы медленнее не из-за «длины», а из-за этого порога. Диагностика:
-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining. - Мегаморфные вызовы. До двух реализаций на call site JIT ставит быструю проверку типа и инлайнит. Три и больше — виртуальный вызов через таблицу, инлайнинг невозможен. Это цена чрезмерной абстракции: не «интерфейсы медленные», а «пять реализаций в одной горячей точке ломают инлайнинг».
- Escape analysis может вообще не аллоцировать объект, разложив его на локальные
переменные (scalar replacement) — но только если он не «убежал» за пределы метода
и весь путь заинлайнен. Проверить вклад: прогнать с
-XX:-DoEscapeAnalysisи сравнить.
Устройство JIT-компиляторов подробно разобрано в треке компиляторов, а байткод и модель исполнения — в JVM изнутри.
Аллокации: дёшево, но не бесплатно
Аллокация в HotSpot — это инкремент указателя в TLAB (thread-local allocation buffer), примерно 10 наносекунд, без синхронизации. Отсюда популярный тезис «в Java аллокации бесплатны». Он неверен: платите вы не за аллокацию, а за последующую сборку.
Арифметика простая и её стоит уметь считать в уме. Пусть сервис аллоцирует 500 МБ/с, а eden-регион — 2 ГБ. Значит, молодая сборка происходит каждые 4 секунды. Если из 500 МБ выживает 2% — это 10 МБ копирования на каждую сборку, дёшево. Если выживает 40% (типично для сервиса, складывающего всё в кэш или буферизующего большие ответы), это 200 МБ копирования, survivor-пространства переполняются, объекты преждевременно уезжают в old generation — и через полчаса вы получаете полную сборку с многосекундной паузой.
Отсюда два практических правила. Первое: снижение allocation rate почти всегда полезнее микрооптимизации CPU — оно сокращает и работу GC, и промахи кэша. Второе: опаснее не объём мусора, а доля выживших. Короткоживущий мусор в Java действительно почти бесплатен; долгоживущий «полумусор» — главный источник пауз.
Где в реальном коде рождаются мегабайты:
// 1. Автобоксинг в горячем цикле: Integer вместо int — объект на каждое значение
Map<Integer, Long> counts = new HashMap<>(); // 16 байт заголовка на каждый ключ
long[] fast = new long[BUCKETS]; // альтернатива для плотных ключей
// 2. Логирование, которое не пишется: строка склеивается ДО проверки уровня
log.debug("order " + order + " for user " + user); // всегда аллоцирует
log.debug("order {} for user {}", order, user); // аллоцирует только при DEBUG
// 3. Regex и форматтеры, создаваемые заново
for (String s : lines) if (s.matches("\\d+-\\w+")) ... // компиляция Pattern на каждой строке
private static final Pattern P = Pattern.compile("\\d+-\\w+"); // один раз, потокобезопасно
// 4. Стримы там, где ответ известен заранее — три объекта ради одного сравнения
boolean has = list.stream().anyMatch(x -> x.id() == id); // Stream + лямбда + Spliterator
Ни одно из этих правил не стоит применять вслепую: сначала профиль аллокаций
(asprof -e alloc или JFR ObjectAllocationSample), потом правка. Про стоимость стримов
и когда она реальна — в Stream API,
про раскладку объектов и выбор структуры — в
коллекциях.
Сборщики мусора: выбор и настройка
Сборщик — не «настройка производительности», а стратегическое решение о том, чем вы платите. Отдать паузы, throughput или память — выбор из трёх, и все три занять нельзя.
| Сборщик | Флаг | Паузы | Цена | Когда |
|---|---|---|---|---|
| Serial | -XX:+UseSerialGC |
десятки–сотни мс | однопоточный | куча < 100 МБ, 1 vCPU, CLI, короткие джобы |
| Parallel | -XX:+UseParallelGC |
сотни мс | паузы растут с кучей | батчи, ETL, максимальный throughput |
| G1 (по умолчанию) | -XX:+UseG1GC |
50–200 мс | ~10% throughput | универсальный выбор для сервисов |
| ZGC | -XX:+UseZGC |
< 1 мс | 10–15% throughput, больше RSS | низкая latency, куча от 8 ГБ до терабайт |
| Shenandoah | -XX:+UseShenandoahGC |
< 10 мс | похоже на ZGC | низкая latency на средних кучах |
| Epsilon | -XX:+UseEpsilonGC |
нет сборки вообще | падает при исчерпании | только тесты аллокаций |
ZGC стал поколенческим по умолчанию в JDK 23 (JEP 474),
непоколенческий вариант удалён в JDK 24 — если читаете старые статьи про
-XX:+ZGenerational, флаг больше не нужен. Shenandoah получил поколения в JDK 24.
Три ручки, которые действительно нужны
Соблазн крутить десятки -XX: флагов надо гасить: почти все они в современной JVM либо
устарели, либо адаптивны. Рабочий минимум:
java -XX:MaxRAMPercentage=75 # доля памяти контейнера под кучу, вместо -Xmx
-XX:+UseG1GC # или ZGC, если цель — p99 по latency
-XX:MaxGCPauseMillis=100 # цель по паузе, G1 подстроит размеры регионов
-Xlog:gc*,gc+heap=info,safepoint:file=/var/log/gc.log:utctime,level,tags:filecount=5,filesize=20M
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/dumps
-jar app.jar
Установка -Xms равным -Xmx — не суеверие: она снимает шум от роста кучи и делает
поведение предсказуемым, ценой честного удержания памяти. MaxGCPauseMillis ниже 50 мс
для G1 обычно контрпродуктивен: сборщик начнёт дробить работу и проиграет по throughput —
если нужны такие паузы, это сигнал переходить на ZGC, а не выкручивать G1.
Читаем GC-лог
Unified logging (JDK 9+) даёт человекочитаемые строки. Вот что важно уметь распознать:
[2026-07-16T09:14:22.118+0000][info][gc] GC(412) Pause Young (Normal) (G1 Evacuation Pause)
3712M->812M(6144M) 41.208ms
[2026-07-16T09:15:03.774+0000][info][gc] GC(418) Pause Young (Concurrent Start)
(G1 Humongous Allocation) 5900M->5310M(6144M) 88.014ms
[2026-07-16T09:15:09.221+0000][info][gc] GC(423) Pause Full (G1 Compaction Pause)
6010M->5804M(6144M) 2418.663ms
Первая строка — здоровье: после сборки занято 812 МБ из 6 ГБ, пауза 41 мс. Третья —
катастрофа: Full GC, пауза 2.4 секунды, и главное — занятость почти не упала
(6010 → 5804 МБ). Это классическая картина утечки или недостаточной кучи: сборщик работает,
но освобождать нечего. Вторая строка — про humongous allocation: объект больше половины
G1-региона (регион 1–32 МБ) выделяется прямо в старое поколение и собирается только
в старших фазах. Массив на 20 МБ, большой byte[] от загрузки файла, гигантский JSON-ответ —
типичные виновники. Лечится не флагами, а стримингом вместо загрузки целиком.
Три метрики, которые стоит вывести в мониторинг: доля времени в GC (тревога > 5%),
длительность пауз p99, занятость old generation после полной сборки — растущая
пила по последней означает утечку. Для разового разбора лога удобны онлайн-анализаторы
и jfr summary; официальный справочник —
HotSpot Garbage Collection Tuning Guide.
Отдельная ловушка ZGC и Shenandoah: у них нет длинных пауз, но есть allocation stall —
поток, попросивший память быстрее, чем сборщик успевает освобождать, встаёт. В логах это
Allocation Stall и Page Allocation Stall. Симптом в приложении выглядит как случайные
задержки без всяких пауз GC на графике. Лечение — больше кучи или меньше allocation rate.
Утечки памяти в языке со сборкой мусора
Сборщик освобождает объекты, на которые нет ссылок из корней. Утечка в Java — это не «забыли free», а «случайно продолжаем ссылаться». Результат тот же: память растёт, Full GC учащается, сервис умирает через двое суток.
Ключевое различие в MAT: shallow heap — размер самого объекта, retained heap —
сколько освободится, если его удалить. Утечку ищут по retained в dominator tree;
byte[] в гистограмме почти всегда лишь следствие, а виноват тот, кто их держит.
Восемь классических источников
- Статическая коллекция без границ.
static Map<String, X> CACHE = new HashMap<>();Живёт столько же, сколько класс, то есть вечно. Кэш обязан иметь предел размера и TTL: Caffeine сmaximumSizeиexpireAfterWrite. ThreadLocalв пуле потоков. Значение живёт, пока жив поток, а потоки в пуле не умирают. Классика:ThreadLocal<SimpleDateFormat>, забытый MDC, контекст запроса. Лечитсяtry/finally { tl.remove(); }или переходом наScopedValue(см. конкурентность).- Слушатели и колбэки без отписки. Подписались на event bus, объект удалили —
шина держит ссылку. Симметричность
subscribe/unsubscribeобязательна. - Утечка classloader’а. Актуальна для приложений с hot redeploy: любая ссылка из
долгоживущего объекта (JDBC-драйвер,
ThreadLocal, shutdown hook) на класс приложения удерживает весь classloader со всеми классами. Проявляется как рост Metaspace. - Незакрытые ресурсы.
InputStream,Connection,HttpClient-ответ. Утечка не только памяти, но и файловых дескрипторов. Всегда try-with-resources. - Растущая очередь.
new LinkedBlockingQueue<>()без ёмкости: продюсер быстрее консьюмера — очередь растёт до OOM. Ограниченная очередь превращает утечку в понятный backpressure. - Ключи с неправильным
equals/hashCode. Объект-ключ мутировали после вставки — он больше не находится, но и не удаляется.HashMapрастёт вечно (см. коллекции). - Off-heap. Прямые
ByteBufferосвобождаются только когда GC доберётся до обёртки; Netty-буферы — вручную через reference counting. ЗдесьOutOfMemoryError: Direct buffer memoryпри почти пустой куче.
Показательный пример типовой ошибки с ThreadLocal:
// ПЛОХО: контекст остаётся в потоке пула после завершения запроса
public class RequestContext {
private static final ThreadLocal<Ctx> CTX = new ThreadLocal<>();
public static void set(Ctx c) { CTX.set(c); } // remove() никто не вызывает
public static Ctx get() { return CTX.get(); }
}
// 200 потоков × Ctx с телом запроса на 2 МБ = 400 МБ, которые никогда не освободятся
// ХОРОШО: жизненный цикл привязан к области видимости
public static <T> T withContext(Ctx c, Supplier<T> body) {
CTX.set(c);
try { return body.get(); }
finally { CTX.remove(); } // обязательно, даже при исключении
}
Для поиска утечки, которую не видно в дампе (объекты создаются и умирают, но чуть-чуть
переживают), полезно событие JFR jdk.OldObjectSample: оно сэмплирует объекты, дожившие
до старого поколения, вместе со стеком аллокации. Включается профилем settings=profile.
Память вне кучи и жизнь в контейнере
Самый частый инцидент современного Java-прода — не OutOfMemoryError, а OOMKilled:
процесс убивает ядро, JVM не успевает ничего сказать, в логах пусто. Причина почти всегда
одна — -Xmx выставили близко к лимиту контейнера, забыв, что куча далеко не весь процесс.
Рабочее эмпирическое правило: RSS ≈ heap × 1.5…2. Поэтому вместо -Xmx задают
-XX:MaxRAMPercentage=70…75 — JVM сама прочитает лимит cgroup (поддержка контейнеров
включена по умолчанию с JDK 10). Если хочется знать точную раскладку, а не эмпирику,
есть Native Memory Tracking:
java -XX:NativeMemoryTracking=summary -jar app.jar
jcmd <pid> VM.native_memory summary scale=MB
# Total: reserved=3820MB, committed=1904MB
# - Java Heap (reserved=1024MB, committed=1024MB)
# - Class (reserved=1088MB, committed=163MB) <- Metaspace
# - Thread (reserved=186MB, committed=186MB) <- 180 × 1MB стеки
# - Code (reserved=250MB, committed=131MB) <- JIT
# - GC (reserved=121MB, committed=121MB)
# - Internal (reserved=91MB, committed=91MB)
Что ещё стоит знать про контейнеры:
- Metaspace растёт от динамических прокси, CGLIB, Groovy-скриптов, повторной загрузки
классов. Ограничивайте
-XX:MaxMetaspaceSize, иначе он съест память тихо; диагностика —jcmd VM.metaspace. ActiveProcessorCount. JVM считает ядра по cgroup-квоте. Приcpu: 500mона увидит одно ядро и создаст крошечные пулы ForkJoin и GC — часто именно это, а не «Java медленная», объясняет провал throughput в Kubernetes.- Арены glibc. Нативный аллокатор создаёт до 8 арен на ядро, RSS раздувается без
реального потребления.
MALLOC_ARENA_MAX=2или образ на musl/jemalloc снимают вопрос. - Компактные заголовки объектов. С JDK 25
-XX:+UseCompactObjectHeaders(JEP 519) сокращает заголовок с 16 до 8 байт — на графах из миллионов мелких объектов это 10–20% кучи бесплатно.
Симптом → причина → проверка
| Симптом | Вероятная причина | Чем проверить |
|---|---|---|
| p99 в 10 раз выше p50, ровными зубцами | паузы GC | -Xlog:gc*, JFR GCPhasePause |
| CPU 100%, throughput не растёт | contention на блокировке | asprof -e lock, JFR JavaMonitorEnter |
| Latency растёт, CPU почти простаивает | ожидание IO или пула соединений | asprof -e wall, метрики HikariCP |
| Память растёт, Full GC не помогает | утечка | heap dump → MAT dominator tree |
| RSS растёт, куча стабильна | Metaspace, direct buffers, native | NMT, jcmd VM.metaspace |
| Медленно первые 30 секунд после старта | прогрев JIT | JFR Compilation, AOT-кэш |
| Резкие провалы раз в N минут | Full GC, rehash, обновление кэша | GC-лог, JFR-таймлайн |
| «Иногда» +2 секунды без пауз GC | ZGC allocation stall или своп | Allocation Stall в логе, vmstat |
| Один эндпоинт медленный, остальные нет | N+1 запросов, отсутствие индекса | лог SQL, EXPLAIN |
Последняя строка — самая частая в реальности: подавляющее большинство «проблем производительности Java» находится в слое данных, а не в JVM. Разбор N+1, ленивых загрузок и транзакций — в работе с данными, планы запросов — в базах данных.
Мифы, которые дорого стоят
- «
finalускоряет код». Для локальных переменных и полей — нет, JIT и так это знает.final— про читаемость и корректность публикации в многопоточности. - «
synchronizedмедленный». Без конкуренции — единицы наносекунд. Biased locking отключён с JDK 15 и удалён в JDK 18, но uncontended-путь остался дешёвым. Медленной делает не ключевое слово, а очередь из потоков. - «Примитивы всегда быстрее». Быстрее в массивах и горячих циклах, где решают локальность и отсутствие разыменований. В коде, ограниченном сетью, разница неизмерима.
- «Стримы медленные». На простых операциях над коллекциями разница с циклом — единицы процентов; на боксинге и коротких коллекциях — заметна. Мерьте, а не верьте.
- «GC — это фризы, как в 2010». Современный ZGC даёт субмиллисекундные паузы на терабайтных кучах. Фризы сегодня — почти всегда следствие ошибки в коде или конфиге.
- «Прогрел 10 000 итераций — можно мерить». Порог зависит от метода; проверяйте выход на плато, а не «магическое число».
- «Отключим GC-логи, они тормозят». Оверхед unified logging — доли процента, а без них расследование инцидента невозможно. Логи GC на проде включены всегда.
Где Java выигрывает по производительности, а где нет
Честная картина, без болельщицкого тона. Выигрывает Java на долгоживущих серверных
нагрузках: JIT со сбором рантайм-профиля делает оптимизации, недоступные статическому
компилятору (спекулятивный инлайнинг по фактическим типам, девиртуализация, деоптимизация
при смене профиля). На таких задачах Java уверенно находится в одной лиге с C++ и Go,
а по инструментарию наблюдения — впереди почти всех: JFR, JMC, async-profiler, heap dump
с полным графом объектов, jcmd, JMH. Ни одна другая массовая платформа не даёт такого
уровня интроспекции почти бесплатно.
Проигрывает Java в трёх местах. Первое — старт: десятки-сотни миллисекунд на
загрузку классов плюс прогрев JIT. Для CLI-утилит и коротких serverless-функций это
дисквалифицирующий фактор; лечится GraalVM Native Image, CDS/AOT-кэшем — но native image
отнимает пиковую производительность и часть динамики. Второе — плотность памяти: любой
объект несёт заголовок, List<Integer> — это массив ссылок на разбросанные по куче объекты,
и обход такой структуры бьёт по кэш-линиям. У C# здесь структурное преимущество —
struct, Span<T> и настоящие обобщённые типы над значениями; в Java это Project Valhalla,
и до его выхода лечится вручную (примитивные массивы, специализированные коллекции,
локальность данных). Третье —
жёсткий реальный времени: GC остаётся источником недетерминизма, и место Java там,
где p99.9 в миллисекундах, а не где промах гарантированно ломает систему.
Полезно сравнить с соседом по нише: платформа .NET решает похожие задачи похожими средствами (JIT, поколенческий GC, tiered compilation), но акценты разные. В .NET сильнее средства избегания аллокаций на уровне языка; в Java — средства наблюдения и выбор низкопаузных сборщиков. Подробнее о соседнем стеке — в треке C#.
Мини-итог
- Оптимизация начинается с метрики и цели, а не с профайлера, и уж точно не с JMH.
- Держите JFR включённым постоянно; для точных стеков и wall-clock берите async-profiler.
- Флеймграф читается снизу вверх; оптимизируют плато, а не высокие узкие башни.
- Любое утверждение «X быстрее Y» без JMH с непересекающимися доверительными интервалами —
не факт, а мнение.
-prof gcчасто информативнее времени. - Аллокации дёшевы, сборка — нет. Главная величина не «сколько мусора», а «сколько выжило».
- Сборщик выбирают по требованию к паузам: G1 по умолчанию, ZGC при жёстком SLO по latency. Настраивать нужно три вещи: размер кучи, цель по паузе, логирование.
- Утечка в Java — это лишняя ссылка. Ищите по retained heap в dominator tree.
- В контейнере считайте RSS, а не heap:
MaxRAMPercentage, NMT,MALLOC_ARENA_MAX. - Большинство «проблем JVM» на поверку оказываются проблемами запросов к базе.
Источники
- JDK Flight Recorder и диагностические инструменты — официальное руководство.
- JDK Mission Control — разбор JFR-записей.
- async-profiler — профайлер без safepoint bias.
- JMH и сэмплы.
- HotSpot GC Tuning Guide — коллекторы и флаги.
- Eclipse Memory Analyzer — анализ heap dump.
- JOL — точная раскладка объектов в памяти.
- JVM Anatomy Quarks, Алексей Шипилёв — короткие разборы внутренностей JVM.
- Scott Oaks, «Java Performance», 2nd ed. — справочник по флагам и подсистемам.
- Benjamin Evans и др., «Optimizing Java» — методика и JIT.
- Brendan Gregg, «Systems Performance» — уровень ОС и железа.
- HdrHistogram — корректные перцентили без coordinated omission.
Что дальше
Измеренное и настроенное приложение нужно довезти до прода и держать под наблюдением: сборка образа, запуск в контейнере, метрики, логи и распределённый трейсинг — всё то, что превращает разовое расследование в постоянную видимость.
Деплой и наблюдаемость: сборка, контейнеры, метрики, трейсинг