
Всем привет, меня зовут Антон Рыбочкин, я старший разработчик бэкенда в команде Yandex Monium. Monium — это платформа для сбора, хранения и анализа телеметрии: метрик, логов и трейсов. Она позволяет дать оценку того, как себя чувствует сервис, находить причины сбоев, оперативно уведомлять об аномалиях.
Изначально эта платформа развивалась как внутренняя система для мониторинга сервисов в масштабах всего Яндекса. Отсюда высокие требования к надёжности — телеметрия должна быть доступна, даже когда другие сервисы лежат. Так что высокие нагрузки нам привычны: на Хабре мы уже рассказывали про опыт сбора 1 000 000 метрик в секунду с сетевых устройств. И с точки зрения бэкенда в таких кейсах есть свои вызовы, один из них — сборка мусора, или сокращённо GC.
Сервисы Monium Metrics для сбора и визуализации метрик — важная часть платформы. По большей части они написаны на Java и занимаются парсингом временных рядов во внутреннее представление, их агрегацией и хранением в YDB.
В этой статье я расскажу про наш опыт с разными сборщиками мусора: с какими проблемами Java GC мы столкнулись в разных сервисах, как их можно диагностировать и как решить. Эксперименты проводились в конце 2024 — начале 2025 года. На большинстве сервисов мы используем свежую версию Java (23 на момент экспериментов), за исключением некоторых с отличающимся графиком релизов — они используют LTS‑версию Java (в данном случае 21).
История первая: про ZGC и распухшие объекты
Итак, у нас были:
Java 23
Несколько тысяч нод в кластере
Хип 75 ГБ
Allocation rate ~15 Гб/с на ноде
ParallelGC
FullGC паузы до 30 с
Хиккапы 1 с в p99.99
Когда мы внедряли покомпонентные SLO, то решили измерить, насколько паузы GC приводят к задержкам ответов на читающие запросы. Для кросс‑шардовых запросов (например, агрегации по разным сервисам), это оказывалось особенно чувствительно. В этом случае результат не будет собран, пока не получен ответ со всех запрашиваемых хостов, а GC на одном хосте просаживал тайминги или вовсе таймаутил запрос. Паузы влияют как на читающие запросы, так и на пишущие, и ошибка таймаута возвращается клиенту, который должен знать, можно ли безопасно ретраить запрос (что в большинстве случаев не так, если применяется агрегация на записи).
Мы решили протестировать разные сборщики мусора с целью исправить проблему. Для экспериментов взяли G1, Shenandoah и Generational ZGC.
Shenandoah постоянно потреблял выше 35% CPU.
G1 стабильно работал с таргетом в 1 с, но с меньшим — часто уходил в FullGC и тратил значительно больше CPU.
Лучше всех себя показал ZGC — минимальные паузы, но потребление CPU выше (~7%) чем в G1 с таргетом в 1 с.

Однако при переходе на ZGC мы столкнулись с неожиданным поведением. Приборы показывали, что потребляемая память возросла в 17 раз, а батчер внутри приложения перестал работать и стал грузить базу маленькими запросами, так как размер каждого элемента в батче мнимо увеличился на 256 КБ.

Смена сборщика мусора сломала код, полагающийся на адресную арифметику: мы используем библиотеку JOL, чтобы вычислять размеры объектов и динамически рассчитывать, сколько памяти используется структурами данных в конкретных шардах. JOL применяется для анализа внутренней структуры объектов Java, включая вычисление размера, вывод порядка полей объекта в памяти, определение используемых оптимизаций (Compressed Pointers, Lilliput).
Балансировщик с этой информацией может корректно селить шарды по хостам и укладываться в лимиты.
В Java сборщик мусора отвечает не только за аллокацию памяти и сборку мусора, но и за разметку объектов (по каким адресам может располагаться начало объекта, что хранится в заголовке, как кодируются ссылки). Многие знают, за счёт чего работают сжатые указатели и как из них получить нормальный адрес, однако в Generational ZGC появился принципиально новый подход к кодированию адреса, который пока не все инструменты понимают.
Какие были варианты это исправить:
Вернуться к G1.
Переписать расчёт размера объектов.
Починить JOL.
Наше решение
Мы решили заимплементить полноценную поддержку адресов ZGC в апстрим JOL.
Подробнее про JOL и представление адресов в ZGC
Если запускать JOL под Linux или MacOS без прав суперпользователя и с включённым Generational ZGC, то вычисляемый размер объектов будет значительно выше ожидаемого из‑за некорректного значения выравнивания. Это вызвано двумя багами:
При некоторых условиях (без прав суперпользователя под Linux или MacOS) JOL не читает значение выравнивания объектов из VMOptions, несмотря на то, что такая информация предоставляется виртуальной машиной.
JOL не имеет логики для нормализации адресов ZGC наподобие той, что реализована для сжатых указателей (
‑XX:+UseCompressedOops).
При инициализации JOL вычисляет выравнивание эвристически, аллоцируя множество объектов, читает то, что виртуальная машина хранит в качестве адресов, нормализует (в случае необходимости домножает сжатые указатели), вычисляет дельты, и возвращает в качестве результата наибольший общий делитель.
В старой версии ZGC метаданные («цвета») хранятся в старших битах и три виртуальных адреса маппятся на один физический, заставляя нервничать тулы диагностики в Linux. В отличие от этого в Generational ZGC используется 12 бит «цвета» (и 4 неиспользуемых бита), они хранятся в младших битах адреса, и трансляция адреса происходит путём побитового сдвига на каждом чтении. Из‑за нестандартного кодирования адреса и включения дополнительных метаданных в Generational ZGC невозможна реализация сжатых указателей.
Цвета кодируются следующим образом в виде 12 бит: RRRRMMmmFFrr — используемые биты чередуются в зависимости от чётности цикла сборки мусора.
Remapped (RRRR) определяет, был ли объект перемещён. Если маска не равна одной из валидных, нужно сходить в forwarding table и обновить ссылку, так как объект был перемещён.
Marked (MM, mm) — ставятся, когда объект достижим в текущем цикле young gen‑ или oldgen‑сборки.
Finalizable (FF) означает, что объект достижим только финализатором.
Remembered (rr) помечает объект, который должен быть добавлен в RemSet для отслеживания ссылок из объекта старого поколения в объект нового поколения.

После патчинга JOL мы смогли запустить наш сервис уже с ZGC. Метрики потребляемой памяти нормализовались. Хиккапы упали до 15 мс p99.99.

Тайминги ответов сервера сократились вдвое:

Тайминги кросс‑шардовых запросов также упали на 20–50%

Немного дёгтя про сложности мониторинга

Generational ZGC работает конкурентно с потоками приложения, за свой цикл совершая три STW‑паузы длительностью десятки микросекунд. Если потоку приложения не удаётся аллоцировать память под новый объект, ZGC приостанавливает аллоцирующий поток, пока не освободится место. Это создаёт сложности для мониторинга реальных пауз: ZGC не репортит по JMX события пейсинга (приостановки) потоков.
Ориентир может дать jHiccup, однако реальное время пауз GC может быть:
ниже, если хиккапы вызваны перегрузкой по CPU в системе,
выше, если jHiccup работает в отдельном потоке, который может избежать приостановки из‑за невысокого рейта аллокаций.
Пейсинг сработает, если нужно аллоцировать новый TLAB. Но jHiccup аллоцирует один объект за цикл, и этого может не хватить.
Более точные метрики даёт JFR. Однако если вы используете OpenTelemetry для сбора метрик, необходимо реализовать логику сбора самостоятельно, так как библиотека opentelemetry‑java‑instrumentation поддерживает только метрики JFR для G1 и Parallel.
Пример графика длительности пейсинга для разных потоков:

Мы добавили линию allocation stalls (доступно только на ZGC) на график таймингов GC. Это позволяет переиспользовать один дашборд для разных сборщиков мусора. Паузы ZGC мы впоследствии побороли оптимизацией аллокаций в тяжёлых операциях, выполняющихся с заданной периодичностью.
Итоги эксперимента
ZGC может существенно снизить задержку, если вы готовы смириться с более высоким потреблением памяти (отказ от сжатых указателей, резерв под всплески аллокаций). Добавим сюда повышенное потребление CPU, отличающийся подход к диагностике и возможную несовместимость с низкоуровневыми библиотеками.
История вторая: про FullGC limbo в Parallel
Итак, у нас были:
Java 21
Большой хип (150 ГБ всего, 110 ГБ в oldgen)
ParallelGC
Allocation rate 300 Мб/с
Редкие FullGC сборки длительностью до 30 с
Сервис обслуживает запросы в формате Graphite и имеет низкую нагрузку циклического характера, хранит много данных в кеше. В какой‑то момент график GC поменял цвет — вместо минорных сборок каждый раз стала выполняться FullGC. При этом запросы на хост и соответственно нагрузка упали, свободного места в хипе было ещё свыше 30 ГБ, но 80% CPU уходили на циклические сборки мусора.

В версиях Java до 23 у ParallelGC есть особенность. По умолчанию в p99 потребление CPU на GC не должно превышать 1%. Если из‑за сборки мусора пропускная способность приложения падает ниже параметра GCTimeRatio, используется эвристика «если ребёнок плачет, надо дать ему ремня» — если FullGC‑сборки слишком долгие, запускается ещё одна FullGC‑сборка, чтобы уменьшить размер молодого поколения (Parallel не умеет ресайзить без oldgen‑сборки). Это было видно по логам:
[2025-04-10T21:18:11.653+0300] GC(2187) Pause Full (Ergonomics) [2025-04-10T21:18:30.897+0300] GC(2187) Marking Phase 19243.162ms [2025-04-10T21:18:30.900+0300] GC(2187) Summary Phase 3.364ms [2025-04-10T21:18:30.919+0300] GC(2187) Adjust Roots 19.211ms [2025-04-10T21:18:35.609+0300] GC(2187) Compaction Phase 4689.771ms [2025-04-10T21:18:36.659+0300] GC(2187) Post Compact 1050.232ms [2025-04-10T21:18:36.660+0300] GC(2187) PSYoungGen: 19933184K(36180992K)->0K(36180992K) Eden: 19933184K(19933184K)->0K(19933184K) From: 0K(16247808K)->0K(16247808K) [2025-04-10T21:18:36.660+0300] GC(2187) ParOldGen: 104171525K(104857600K)->104228125K(104857600K) [2025-04-10T21:18:36.660+0300] GC(2187) Metaspace: 79632K(84736K)->79632K(84736K) NonClass: 70639K(73792K)->70639K(73792K) Class: 8992K(10944K)->8992K(10944K) [2025-04-10T21:18:36.660+0300] GC(2187) Pause Full (Ergonomics) 121196M->101785M(137733M) 25006.171ms [2025-04-10T21:18:36.660+0300] GC(2187) User=730.17s Sys=5.42s Real=25.00s [2025-04-10T21:19:10.045+0300] GC(2188) Pause Full (Ergonomics) [2025-04-10T21:19:28.744+0300] GC(2188) Marking Phase 18699.365ms [2025-04-10T21:19:28.747+0300] GC(2188) Summary Phase 2.980ms [2025-04-10T21:19:28.764+0300] GC(2188) Adjust Roots 16.858ms [2025-04-10T21:19:34.807+0300] GC(2188) Compaction Phase 6043.200ms [2025-04-10T21:19:35.821+0300] GC(2188) Post Compact 1013.695ms [2025-04-10T21:19:35.821+0300] GC(2188) PSYoungGen: 19933184K(36180992K)->0K(36180992K) Eden: 19933184K(19933184K)->0K(19933184K) From: 0K(16247808K)->0K(16247808K) [2025-04-10T21:19:35.821+0300] GC(2188) ParOldGen: 104228125K(104857600K)->104239415K(104857600K) [2025-04-10T21:19:35.821+0300] GC(2188) Metaspace: 79632K(84736K)->79632K(84736K) NonClass: 70639K(73792K)->70639K(73792K) Class: 8992K(10944K)->8992K(10944K) [2025-04-10T21:19:35.821+0300] GC(2188) Pause Full (Ergonomics) 121251M->101796M(137733M) 25776.528ms
Какие были варианты это исправить:
отключить ресайз поколений через VM option
‑XX:‑UseAdaptiveSizePolicy;уменьшить таргет пропускной способности:
‑XX:GCTimePercentage=5;перейти на Java 23, где эвристику удалили;
выбрать другой сборщик мусора.
Наше решение
Конкурентные сборки G1 потребляли значительно больше CPU, поэтому мы выбрали уже знакомый Generational ZGC.

Паузы пропали, удалось сэкономить CPU, даже всплески активности GC мало заметны на общей картине.
Итоги эксперимента
Пересматривать параметры GC после обновления JDK бывает полезно. Чистка мусора в Java‑приложениях с большими хипами больше не является проблемой.
История третья: про G1 и крупные объекты
Итак, у нас были:
Java 23
G1 с таргетом 250 мс
хип 30 ГБ
Allocation rate 50 Мб/с
Сервис отвечает за создание метаданных для новых метрик, а также поиск метаданных метрик для читающих запросов. Обычно в системах мониторинга количество запросов на запись значительно превышает количество запросов на чтение, а рейт создания новых метрик также невысок, потому нагрузка на сервис невысокая. Аномалий в работе GC не было обнаружено, пока мы не разместили все GC‑метрики на одном дашборде.

Мы видим, что реальные хиккапы в p99.99 превышают таргет и доходят до 1 секунды. Cборка мусора составляет половину от общего потребления CPU приложением, 20% от аллокаций попадает в oldgen: на старое поколение приходится 95% регионов. Визуализируем GC‑логи и видим, что значительное число регионов занимают Humongous‑объекты.

В логах видим, что эвристика G1 установила IHOP=0 Это значит, что часть регионов из старого поколения будет собираться конкурентно или в момент GC каждый раз, когда выполняется минорная сборка или аллоцируется крупный объект.
GC(1920) Basic information (value update), threshold: 0B (0.00), target occupancy: 31406948352B, current occupancy: 16143684784B, recent allocation size: 186612376B, recent allocation duration: 52176.56ms, recent old gen allocation rate: 3576555.67B/s, recent marking phase length: 100534.64ms GC(1920) Adaptive IHOP information (value update), threshold: 0B (0.00), internal target occupancy: 26695906099B, occupancy: 16143684784B, additional buffer size: 13421772800B, predicted old gen allocation rate: 338461145.70B/s, predicted marking phase length: 169470.15ms, prediction active: true
Про регионы G1

G1 использует фиксированный размер региона: обычно от 1 до 32 МБ, вычисляется эвристически в момент старта в зависимости от размера хипа. Можно задать размер региона вручную при помощи параметра ‑XX:G1HeapRegionSize (размер в мегабайтах должен быть равен степени двойки), тогда лимит на размер региона повышается до 512 МБ (начиная с Java 18).
Если объект занимает больше 50% от размера региона, он считается огромным (Humongous) и размещается не в обычных регионах, а в последовательности смежных регионов, выровненных по границе региона.
Такие объекты сразу попадают в oldgen и увеличивают promotion rate, но собираются в момент минорной сборки (если являются примитивными массивами; массивы объектов проверяются в минорной сборке начиная с Java 26).
Чтобы избежать FullGC, G1 при минорной сборке либо Humongous‑аллокации может запустить конкурентную сборку нескольких регионов старого поколения. Решение принимается путём сравнения текущей занятости хипа и значения InitialHeapOccupancyPercent (IHOP).
По умолчанию G1 адаптивно вычисляет IHOP как TargetOccupancy — (AllocationRate * MarkingDuration). В случае аллокации Humongous‑объектов IHOP снижается независимо от их времени жизни, и конкурентные циклы сборки старого поколения запускаются слишком часто и приносят мало пользы.
Какие были варианты это исправить:
переписать приложение, раздробив крупные коллекции (потребует много времени на разработку);
увеличить размер региона, используя
-XX:G1HeapRegionSize;увеличить IHOP и отключить адаптивный режим:
-XX:InitiatingHeapOccupancyPercent=80 -XX:-G1UseAdaptiveIHOP;сменить сборщик мусора.
Наше решение
Для использования low‑latency GC было недостаточно ресурсов, потоки часто уходили в пейсинг, потому мы остались на G1. Мы увеличили размер региона до 256 МБ, после чего потребление CPU на сборку мусора снизилось в 25 раз, а хиккапы — в 5 раз в p99.9 и 15 в p99.99.

Это существенно отразилось на таймингах GRPC‑ответов, например, время ответа от ручки Find стабилизировалось на 55 мс в p99.99.

Итоги эксперимента
Включение дополнительных параметров в GC‑логах позволяют обнаружить аномалии в работе GC. Короткоживущие большие объекты ломают эвристики G1. Сокращение числа больших объектов (даже путём изменение понятия «большого объекта») может нивелировать проблему увеличения времени очистки отдельно взятого региона.
Вывод
Мы приобрели экспертизу в области работы Java GC, настроили дашборды и алерты, позволяющие быстро обнаружить проблемы со сборкой мусора и понять причину изменений.
Нам не хватало метрик, поставляемых через GarbageCollectorMXBean, и мы стали собирать дополнительные метрики через JFR, дающие большую точность и улучшающие наблюдаемость за современными low‑latency коллекторами. Мы опубликовали библиотеку, в которой эти метрики собираются и экспортируются в формате OpenTelemetry, а также рабочий дашборд для Monium, которым мы постоянно пользуемся.
Рекомендации
Используйте современные версии Java, они приносят действительно много впечатляющих возможностей:
Generational ZGC — Java 21.
Generational Shenandoah — Java 24.
Lilliput — Java 24.
Ускорение компиляции GC барьеров — Java 24.
Снижение потребляемой памяти на хранение RSet в G1 — Java 25.
Уменьшение синхронизации в барьерах G1 — Java 26.
Включите подробные логи GC. В наших сервисах мы используем следующие параметры:
-Xlog:async -Xlog:safepoint,gc*,gc+ergo=trace,gc+age=trace,gc+phases=trace,gc+humongous=trace,gc+ihop=trace:file=/logs/app-gc.log:tags,time,uptime,level:filecount=2,filesize=50M -Xlog:deoptimization=debug:file=/logs/app-deopt.log:time:filecount=2,filesize=50MЭто позволяет следить за аномалиями в эвристике определения IHOP, смотреть размеры Humongous‑объектов и удобно визуализировать логи GC при помощи опенсорс‑инструментов.
Включите метрики GC и хиккапов.
Настройте GC‑дашборды. Обратите внимание на хиккапы, потребление CPU, размеры поколений, promotion rate.
Встройте GC‑дашборд в релизную инструкцию и реагируйте на резкие изменения метрик.
Спасибо. Делитесь своими историями тюнинга GC в комментариях и заглядывайте в чат пользователей Monium, где мы обсуждаем работу платформы, делимся новостями и фичами.
