Симптом: p50 ровный, p99 пилит вверх
Самый частый запрос, с которым мы заходим на performance-аудит банковского backend: «график p50 latency в Grafana — ровные 30 ms, бизнес жалуется, что иногда отвечает по 2 секунды. Кто прав?»
Прав бизнес. Просто в Grafana обычно строят avg или p50, а боль живёт в хвосте — p99, p99.9. И в 70% случаев виновник хвоста на Java-бэкенде — это GC-паузы.
Типичная картина в GC-логах G1:
[2026-04-15T14:23:11.234+0300][gc] GC(1247) Pause Young (Normal) (G1 Evacuation Pause) 4854M->2103M(8192M) 287.421ms
[2026-04-15T14:23:14.512+0300][gc] GC(1248) Pause Young (Concurrent Start) 4321M->2087M(8192M) 312.108ms
[2026-04-15T14:24:01.823+0300][gc] GC(1250) Pause Mixed (G1 Evacuation Pause) 6122M->3201M(8192M) 412.847ms
400 ms — это застывшее приложение на 0.4 секунды. Каждые 10-30 секунд. В банковском приёмнике платежей это значит: каждый сотый-двухсотый запрос отрабатывает заметно медленнее остальных. Telemetry агрегирует это как «всё нормально», бизнес чувствует как «иногда тупит».
В этом гайде:
- как диагностировать, что виноват именно GC (а не БД, не сеть, не lock contention);
- как выжать максимум из G1, прежде чем переходить на что-то другое;
- как мигрировать на ZGC (JDK 17+) и какие подводные камни;
- что показывают реальные метрики до/после на банковском проекте.
Шаг 1: включить GC-логи и собрать факты
В JDK 11+ синтаксис унифицированный:
-Xlog:gc*:file=/var/log/app/gc.log:time,uptime,level,tags:filecount=10,filesize=50M
Это даст ротируемые GC-логи с временной меткой и тегами. Запускайте с этим даже если сейчас всё нормально — потом не успеете включить, когда понадобится.
Дальше скармливайте лог в один из этих анализаторов:
| Инструмент | Что даёт |
|---|---|
| gceasy.io | Cloud, drag-n-drop. Отчёт с pause distribution, throughput, recommendations |
| GCViewer | Desktop, точные графики |
| GCToolKit | Programmatic API от Microsoft, удобно для CI |
Что вы должны увидеть в отчёте:
- GC throughput — % времени приложения, проведённого вне GC. Хорошо: > 99%. Плохо: < 95%.
- p99 pause time — длительность 99-го перцентиля паузы. Цель: < 50 ms (для финтеха), < 100 ms (для большинства).
- Allocation rate — MB/s аллокаций. Если > 1 GB/s — у вас агрессивная аллокация, фокус смещается с GC на «кто столько мусора генерит».
- Promotion rate — сколько данных уходит из Young в Old. Высокая promotion = частые Mixed/Old GC = длинные паузы.
Если p99 pause > 200 ms или throughput < 95% — переходите к шагу 2.
Шаг 2: понять, что именно жрёт память
Перед тюнингом GC надо понять, что генерирует мусор. Иначе тюнить будете симптом, а не причину.
# heap dump для анализа
jcmd <pid> GC.heap_dump /tmp/heap.hprof
# или live profiling
async-profiler -d 60 -e alloc -f /tmp/alloc.html <pid>
async-profiler с режимом -e alloc (allocation profiling) — наш любимый инструмент. Даёт flamegraph «кто аллоцирует». Часто в топ-3 видим:
- Логгер на DEBUG в проде генерирует
Stringчерез конкатенацию в каждом запросе (см. отдельный пост про логи). - ObjectMapper создаётся для каждой сериализации вместо переиспользования.
- Stream API в горячем пути —
.collect(Collectors.toList())в цикле на 10k элементов. String.format()в высокочастотных логах —formatсам по себе медленный и аллоцирует много.
На одном проекте 60% allocation pressure давал один (!) логгер, который сериализовал в JSON каждый incoming request на уровне INFO. Уменьшили до WARN — heap pressure упал на 40%, GC стал реже срабатывать.
💡 Сначала уберите явные источники мусора, и только потом тюньте GC. Иначе будете лечить симптом.
Шаг 3: тюнинг G1 — если ещё не переходим на ZGC
G1 — текущий дефолт в JDK 17+. Он хорош для большинства случаев, но из коробки настроен консервативно.
# базовый тюнинг G1 для backend под нагрузкой
-XX:+UseG1GC
-XX:MaxGCPauseMillis=100 # цель: pause < 100 ms (soft goal)
-XX:G1HeapRegionSize=16m # регионы покрупнее для большого heap
-XX:InitiatingHeapOccupancyPercent=35 # стартовать concurrent цикл раньше
-XX:G1ReservePercent=15 # резерв для пиковых аллокаций
-XX:G1MixedGCCountTarget=8 # делить Mixed GC на 8 мини-этапов
-XX:G1HeapWastePercent=10 # начинать Mixed GC, когда мусора > 10%
Что эти флаги делают и зачем:
MaxGCPauseMillis=100— soft target. G1 будет адаптивно регулировать размер региона collection set, чтобы попадать в эту цель. Если ставить меньше 50 — G1 начнёт страдать (не успевает собирать достаточно).G1HeapRegionSize=16m— дефолт ~2-32 MB зависит от heap. Для heap 8 GB+ ставим явно 16 MB — меньше регионов, проще учёт.InitiatingHeapOccupancyPercent=35(дефолт 45) — раньше стартовать concurrent marking. Дороже CPU, но позволяет не накапливать мусор до критической отметки.G1ReservePercent=15— резерв пространства под evacuation. Если у вас Out Of Memory во время GC — поднимайте до 20-25%.
После применения этих флагов и прогонки нагрузочного теста в течение 24+ часов — смотрите GC-лог. Если p99 pause всё ещё > 100 ms — G1 у вас на пределе. Пора смотреть в сторону ZGC.
Шаг 4: переход на ZGC — что это и зачем
ZGC (Z Garbage Collector) — concurrent low-latency collector, разработанный Oracle для случаев, когда pause time важнее throughput.
Главное отличие: ZGC выполняет почти всю работу параллельно с приложением. Stop-The-World паузы — sub-millisecond вне зависимости от размера heap.
Сравнение по нашим проектам:
| Метрика | G1 (tuned) | ZGC | Δ |
|---|---|---|---|
| p50 pause | 8 ms | 0.2 ms | −97% |
| p99 pause | 280 ms | 0.8 ms | −99.7% |
| p99.9 pause | 450 ms | 1.2 ms | −99.7% |
| GC throughput | 96-98% | 97-99% | сопоставимо |
| CPU overhead (приложения) | base | +5-15% | хуже |
| Heap footprint | base | +10-20% | хуже |
То есть ZGC даёт революционное снижение pause time за счёт умеренной потери в CPU и памяти. Для финтеха и любого latency-критичного backend это правильный размен.
Когда ZGC уместен
- Heap > 4 GB и важен p99 latency.
- JDK 17+ (на 11/15 ZGC ещё experimental).
- Можно позволить +10-20% памяти и +5-15% CPU.
Когда не уместен
- Маленькие heap (< 2 GB) — нет смысла, G1 справляется идеально.
- Throughput критичнее latency (batch jobs, ETL). Используйте Parallel GC.
- Тесные ограничения по RAM (контейнеры с фиксированным memory limit).
Шаг 5: миграция G1 → ZGC
Замена флагов в JVM:
- -XX:+UseG1GC
- -XX:MaxGCPauseMillis=100
- -XX:G1HeapRegionSize=16m
- -XX:InitiatingHeapOccupancyPercent=35
- -XX:G1ReservePercent=15
+ -XX:+UseZGC
+ -XX:+ZGenerational # generational ZGC (JDK 21+, в 1.5-2x быстрее)
+ -XX:SoftMaxHeapSize=6g # soft cap, ZGC старается не превышать
+ -Xmx8g # абсолютный максимум
Несколько моментов миграции:
Generational ZGC
В JDK 21 ZGC получил поколения (флаг -XX:+ZGenerational). Это в 1.5-2x быстрее не-generational варианта за счёт того, что молодые объекты собираются отдельно (как в G1). На JDK 17 этого флага нет — там используется только non-generational ZGC.
SoftMaxHeapSize
ZGC агрессивно использует доступную память — заполняет до -Xmx. Если вы хотите ограничить «нормальное» использование, оставив запас на пики — задавайте SoftMaxHeapSize. ZGC будет стараться держаться в этих рамках и пройдёт за пределы только под давлением.
Memory footprint
На графиках мониторинга вы увидите, что используемая память выросла на 10-20% по сравнению с G1. Это нормально — у ZGC есть metadata (forwarding tables) которые занимают место. Не пугайтесь: реальный working set остался прежним.
CPU footprint
ZGC делает больше параллельной работы — CPU usage вырастет на 5-15%. На большинстве серверов это незаметно, но если вы упёрлись в CPU — это аргумент против.
Шаг 6: проверка после миграции
Чек-лист после выкатки ZGC в production:
# 1. p99 pause time должен быть < 1 ms
grep "Pause Allocation" /var/log/app/gc.log | awk '{print $NF}' | sort -rn | head -10
# 2. ZGC concurrent phases — норма
grep "GC.*Mark" /var/log/app/gc.log | tail -5
# 3. heap commit размер
grep "Soft Max Capacity" /var/log/app/gc.log | tail -3
# 4. allocation rate — не должен меняться от смены GC
grep "Allocation Stall" /var/log/app/gc.log | wc -l
Allocation Stall — единственное, что в ZGC может приводить к stop-the-world задержке. Это случается, если приложение аллоцирует быстрее, чем ZGC успевает собирать. Если вы видите > 1 stall в час под нормальной нагрузкой — поднимайте -Xmx или SoftMaxHeapSize.
Реальные метрики из банковского проекта
Платёжный шлюз на Spring Boot, 4 GB heap, 200 RPS sustained, peak до 800 RPS.
До тюнинга G1
| GC | G1 (default config) |
| p50 latency | 32 ms |
| p99 latency | 380 ms |
| p99.9 latency | 1200 ms |
| GC pause p99 | 287 ms |
| Tail-latency инциденты в месяц | 4-6 |
После tuned G1
| GC | G1 + tuned flags выше |
| p50 latency | 28 ms |
| p99 latency | 165 ms |
| p99.9 latency | 480 ms |
| GC pause p99 | 95 ms |
| Tail-latency инциденты | 1-2 |
Улучшение, но p99.9 = 480 ms всё ещё слишком много для платежей.
После миграции на ZGC (Generational, JDK 21)
| GC | ZGC Generational |
| p50 latency | 28 ms |
| p99 latency | 78 ms |
| p99.9 latency | 112 ms |
| GC pause p99 | 0.8 ms |
| Tail-latency инциденты | 0 за квартал |
| CPU overhead | +8% |
| Heap commit | +12% |
p99 в 4.9 раза меньше, чем при дефолтном G1. p99.9 — в 10.7 раз меньше. Tail-latency-инциденты, на которые тратилось 20+ часов postmortem-ов в квартал, исчезли.
Микро-CTA
Если вы смотрите на свои Grafana-дашборды и видите похожую картину «p50 ровный, p99 пилит» — мы это диагностируем за 2-3 дня в рамках Backend Health-Check. Senior-инженер собирает GC-логи, анализирует allocation profile, даёт оценку миграции на ZGC в часах с целевыми метриками. Отчёт остаётся у вас в любом случае.
Ловушки, на которые мы наступили
Ловушка 1: ZGC на JDK 17 без +ZGenerational медленнее, чем tuned G1
На JDK 17 ZGC ещё не поколенческий — он сканирует весь heap каждый раз. Это даёт малые паузы, но throughput хуже G1. На JDK 21 с +ZGenerational ситуация меняется радикально.
Совет: не делайте миграцию на ZGC, если стоите на JDK 17 — либо переходите на 21, либо тюньте G1.
Ловушка 2: Xmx слишком близок к контейнерному лимиту
ZGC активно использует память. Если у вас контейнерный лимит = 8 GB и -Xmx=8g — OOM-killer пристрелит pod при первом всплеске.
Правило: ZGC -Xmx ставьте на 60-70% от контейнерного лимита.
Ловушка 3: «GC включён, но Grafana не показывает паузы»
Micrometer считает JVM-метрики через gc.pause. Это работает для G1, но для ZGC gc.pause показывает обычно ~0 (потому что пауз и нет). Нужны новые метрики: jvm.gc.concurrent.phase.time, jvm.gc.live.data.size.
Совет: обновите Grafana-дашборды под ZGC, иначе не увидите проблем.
Ловушка 4: thread dump во время GC показывает странности
ZGC использует load barriers (специальные JIT-инструкции на каждом access к объекту). В thread dump это может выглядеть как зависание в случайных местах. На самом деле это нормально.
Ловушка 5: CMS заменён, но кодовая база всё ещё привязана к нему
-XX:+UseConcMarkSweepGC удалён в JDK 14. Если у вас в скриптах деплоя ещё есть — JVM не стартует. Чистите.
Финальный чек-лист миграции
- JDK 21+ установлена в проде
- GC-логи включены (
-Xlog:gc*:file=...) - Snapshot текущих p99/p99.9 latency сохранён для сравнения
- Allocation-профиль снят через
async-profiler -e alloc, source-of-garbage устранены - Tuned G1 испробован, если pause всё ещё > 100 ms → переход на ZGC оправдан
- ZGC флаги добавлены (
+UseZGC +ZGenerational SoftMaxHeapSize) -
Xmxна 60-70% от контейнерного лимита - Grafana-дашборды адаптированы под
jvm.gc.concurrent.*метрики - 24h нагрузочный тест на staging до выкатки в prod
- План rollback подготовлен (вернуть G1 одной правкой ENV)
Видите похожую картину у себя? Это самая частая проблема Java-бэкендов под нагрузкой. Если хочется внешнего ревью и плана миграции с конкретными метриками — Backend Health-Check за 3 дня включает в себя именно это: измерение, профилирование, рекомендации по версиям JDK и GC-флагам.