Новаком
JAVA

GC-паузы > 200 ms в Java: как мы снимали хвостовые задержки переходом с G1 на ZGC

Диагностика и фикс GC-пауз в production: чтение GC-логов, тюнинг G1, миграция на ZGC. Реальные метрики из банковского проекта: p99 380→78 ms. Гайд от профилирования до выкатки.

Н
Новаком
2026-05-23 · 13 минут чтения

Симптом: 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.ioCloud, drag-n-drop. Отчёт с pause distribution, throughput, recommendations
GCViewerDesktop, точные графики
GCToolKitProgrammatic API от Microsoft, удобно для CI

Что вы должны увидеть в отчёте:

  1. GC throughput — % времени приложения, проведённого вне GC. Хорошо: > 99%. Плохо: < 95%.
  2. p99 pause time — длительность 99-го перцентиля паузы. Цель: < 50 ms (для финтеха), < 100 ms (для большинства).
  3. Allocation rate — MB/s аллокаций. Если > 1 GB/s — у вас агрессивная аллокация, фокус смещается с GC на «кто столько мусора генерит».
  4. 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 pause8 ms0.2 ms−97%
p99 pause280 ms0.8 ms−99.7%
p99.9 pause450 ms1.2 ms−99.7%
GC throughput96-98%97-99%сопоставимо
CPU overhead (приложения)base+5-15%хуже
Heap footprintbase+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

GCG1 (default config)
p50 latency32 ms
p99 latency380 ms
p99.9 latency1200 ms
GC pause p99287 ms
Tail-latency инциденты в месяц4-6

После tuned G1

GCG1 + tuned flags выше
p50 latency28 ms
p99 latency165 ms
p99.9 latency480 ms
GC pause p9995 ms
Tail-latency инциденты1-2

Улучшение, но p99.9 = 480 ms всё ещё слишком много для платежей.

После миграции на ZGC (Generational, JDK 21)

GCZGC Generational
p50 latency28 ms
p99 latency78 ms
p99.9 latency112 ms
GC pause p990.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-флагам.

РАЗРАБОТКА

Нужна похожая задача?

Обсудим вашу задачу и предложим решение за 30 минут.

Обсудить проект