Java Производительность: профилирование, JMH, настройка GC, типичные утечки
0%

Производительность: профилирование, JMH, настройка GC, типичные утечки

Производительность: профилирование, JMH, настройка GC, типичные утечки

Разговор о производительности в Java почти всегда начинается не с того конца. Кто-то заменяет ArrayList на массив, кто-то расставляет final, кто-то переписывает стримы в циклы — и всё это до того, как хоть раз посмотрел, куда уходит время. Между тем в типичном сервисе на Spring Boot 90% latency — это ожидание базы, сериализация JSON, неудачно настроенный пул соединений и паузы сборщика мусора. Код в горячем цикле — редкий и приятный случай.

У JVM есть особенность, которой нет у C и Go: производительность здесь динамическая. Один и тот же байткод через 10 секунд работы быстрее в 20–50 раз, чем на первой итерации, потому что JIT успел собрать профиль, заинлайнить, развернуть цикл и выкинуть проверки. Это делает Java одной из самых быстрых платформ для долгоживущих серверов — и одной из самых коварных для измерения. Наивный замер System.nanoTime() вокруг цикла в Java не просто неточен, он систематически лжёт: измеряет либо интерпретатор, либо код, который компилятор вырезал целиком как мёртвый.

Поэтому статья построена так: сначала — что вообще измерять, затем инструменты по уровням (прод → профиль → микробенчмарк), затем JIT и GC как две главные машины, определяющие поведение под нагрузкой, и в конце — утечки, которые в языке со сборкой мусора никуда не делись, просто выглядят иначе.

Что именно вы оптимизируете

Прежде чем открывать профайлер, ответьте на три вопроса. Без них оптимизация превращается в набор случайных микроправок.

  1. Метрика. Latency (время одного запроса) или throughput (запросов в секунду)? Это разные, часто конфликтующие цели: батчинг улучшает throughput и ухудшает latency, ZGC улучшает latency и отнимает 10–15% throughput.
  2. Перцентиль. Среднее время — бесполезная величина. Пользователь, попавший в p99, не утешится тем, что в среднем всё хорошо. Более того: страница, дёргающая 20 сервисов, попадает в чей-нибудь p99 с вероятностью около 18%. Смотрите p50/p95/p99/p999 и max.
  3. Цель. «Быстрее» — не цель. «p99 создания заказа ниже 200 мс при 500 rps на двух ядрах» — цель, у неё есть критерий остановки.

Отдельно про coordinated omission — систематическую ошибку, из-за которой почти все самодельные нагрузочные скрипты врут в разы. Если клиент отправляет следующий запрос только после ответа на предыдущий, то во время двухсекундной паузы GC он просто не отправляет запросы — и эти секунды не попадают в гистограмму. Реальные пользователи так себя не ведут: они продолжают приходить. Лечится генератором с фиксированным rate (k6, Gatling, wrk2) и HdrHistogram, который умеет корректировать. Классический доклад Гила Тене «How NOT to measure latency» стоит посмотреть целиком; методика нагрузочного тестирования подробно разобрана в треке производительности.

Главное в этой схеме — стрелка 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, и «оно быстрое в бенчмарке и медленное в проде».

Практические следствия:

  • Прогрев реален. Первые запросы после старта в 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[] в гистограмме почти всегда лишь следствие, а виноват тот, кто их держит.

Восемь классических источников

  1. Статическая коллекция без границ. static Map<String, X> CACHE = new HashMap<>(); Живёт столько же, сколько класс, то есть вечно. Кэш обязан иметь предел размера и TTL: Caffeine с maximumSize и expireAfterWrite.
  2. ThreadLocal в пуле потоков. Значение живёт, пока жив поток, а потоки в пуле не умирают. Классика: ThreadLocal<SimpleDateFormat>, забытый MDC, контекст запроса. Лечится try/finally { tl.remove(); } или переходом на ScopedValue (см. конкурентность).
  3. Слушатели и колбэки без отписки. Подписались на event bus, объект удалили — шина держит ссылку. Симметричность subscribe/unsubscribe обязательна.
  4. Утечка classloader’а. Актуальна для приложений с hot redeploy: любая ссылка из долгоживущего объекта (JDBC-драйвер, ThreadLocal, shutdown hook) на класс приложения удерживает весь classloader со всеми классами. Проявляется как рост Metaspace.
  5. Незакрытые ресурсы. InputStream, Connection, HttpClient-ответ. Утечка не только памяти, но и файловых дескрипторов. Всегда try-with-resources.
  6. Растущая очередь. new LinkedBlockingQueue<>() без ёмкости: продюсер быстрее консьюмера — очередь растёт до OOM. Ограниченная очередь превращает утечку в понятный backpressure.
  7. Ключи с неправильным equals/hashCode. Объект-ключ мутировали после вставки — он больше не находится, но и не удаляется. HashMap растёт вечно (см. коллекции).
  8. 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 JVM-процесса в контейнере

Рабочее эмпирическое правило: 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» на поверку оказываются проблемами запросов к базе.

Источники

Что дальше

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

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

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

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

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

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