TL;DR
ZGC на настройках по умолчанию заметно улучшает хвостовые задержки по сравнению с G1: в тесте микросервиса выбросы на p999 у G1 совпадают с GC-паузами (вплоть до десятков миллисекунд), тогда как у ZGC паузы остаются на уровне десятков микросекунд.
Цена — дополнительные CPU: при упоре в процессор ZGC может не успевать освобождать память и начинает тормозить аллокации (allocation stalls), что тоже раздувает хвосты; это стоит мониторить через JFR.
Универсального выбора нет, проверяйте на своей нагрузке; с Java 25 ZGC стал более «по умолчанию» за счёт Generational ZGC.
В этой серии статей я разбираю интересные проекты, изменения и технологии из мира данных и стриминга. Это могут быть KIP и FLIP, open source‑проекты, сервисы, важные улучшения в Java и JVM и многое другое. Идея в том, чтобы получить практический опыт, понять потенциальные сценарии применения и разобраться в компромиссах. Если вам кажется, что есть тема, на которую мне стоит «посмотреть», напишите в комментариях ниже.
В сентябре 2025 состоялся релиз Java 25 — это первый LTS‑релиз Java, в котором Generational ZGC поставляется как единственный вариант сборщика мусора ZGC. Сам ZGC — относительно новый конкурентный сборщик, впервые добавленный в Java 11.
Базовая идея конкурентных сборщиков мусора (ещё один пример — Shenandoah) в том, чтобы перенести как можно больше работы из потоков приложения в отдельные GC‑потоки. Так они практически устраняют GC‑паузы, которые раньше мучили пользователей Java, проявляясь в виде больших хвостовых задержек приложений. ZGC снижает время работы GC в потоках приложения до долей миллисекунды, делая GC‑паузы практически несущественными для подавляющего большинства сценариев. Разумеется, бесплатных обедов не бывает: выполняя логику GC в отдельных потоках, конкурентные сборщики требуют больше CPU‑ресурсов, тем самым снижая общую пропускную способность системы.
Пока что у меня не было возможности получить практический опыт с ZGC, поэтому я решил сравнить ZGC и G1 — сборщик мусора по умолчанию в Java, начиная с версии 9. ZGC часто ассоциируют с огромными кучами на сотни гигабайт и больше, но мне было интересно, даст ли он пользу и в типичном микросервисном развертывании, где куча — всего несколько гигабайт. Кроме того, мне хотелось понять характеристики производительности при настройках по умолчанию, то есть без тонкой подстройки конкретных сборщиков мусора. На практике большинство людей этим и не занимается. Почти ни у кого нет времени или желания выискивать оптимальные параметры, которые к тому же очень скоро могут стать неактуальными: достаточно, чтобы изменилась нагрузка или вышла новая версия Java с изменениями в поведении GC. Поэтому, пожалуй, в большинстве случаев производительность на настройках по умолчанию важнее, чем теоретический пик, достижимый только при тщательно вылизанных настройках.
Я начал с бенчмарка примерного микросервиса на фреймворке Quarkus, который возвращает данные из базы Postgres. В качестве генератора нагрузки я использовал Vegeta и задал умеренную нагрузку — 1000 запросов в секунду. Тест запускался на инстансе Hetzner CCX43: использовались четыре из его 16 выделенных CPU‑ядер и 4 ГБ RAM. Ниже — задержки запросов при двухминутном прогоне для каждого сборщика; первые 30 секунд каждого прогона я отбросил, чтобы исключить влияние прогрева. Это, конечно, не сверхнаучный бенчмарк, но его достаточно, чтобы увидеть несколько любопытных результатов:

До 99-го перцентиля задержки практически одинаковые, но на p999 и p9999 у ZGC заметное преимущество. Попробуем понять, действительно ли разницу объясняют GC‑паузы. Если посмотреть на фактические задержки запросов на графике Vegeta, видно несколько крупных выбросов при G1:

Тогда как при ZGC прогоны выглядят куда более однородными:

Чтобы проверить, были ли эти выбросы при G1 действительно вызваны GC‑паузами, я включил JDK Flight Recorder на время тестов. И да — в записи JFR можно наблюдать GC‑паузы больше 20 мс в соответствующие моменты:

А при ZGC, наоборот, самая длинная наблюдаемая GC‑пауза — около 50 микросекунд:

Это довольно круто: просто выбрав ZGC в качестве сборщика мусора, мы смогли заметно улучшить хвостовые задержки этого примерного сервиса — без какой‑либо настройки. Заметьте, теоретически можно выжать лучшие результаты и из G1, поиграв с JVM‑опциями вроде ‑XX:MaxGCPauseMillis, но именно гораздо более низкие хвостовые задержки, которые ZGC даёт на настройках по умолчанию, делают его таким привлекательным. На ваших конкретных нагрузках картина может отличаться, но ZGC определённо стоит попробовать. Вполне вероятно, что вы получите приятные улучшения без больших усилий.
Сборка мусора — не единственная причина пауз в JVM. Среди других примеров — деоптимизация скомпилированных методов и создание дампов кучи. Эти и другие операции требуют, чтобы все потоки достигли safepoint в JVM, а это может занять некоторое время.
При этом описанный выше тест изначально не создавал большой нагрузки на сборщики мусора (примерно ~17 МБ/с), и вывод статьи точно не должен сводиться к тому, что ZGC всегда лучше. В частности, если система и так близка к высокой загрузке CPU, более ресурсозатратный подход ZGC — очистка мусора в конкурентных потоках — может привести к более высоким временам ответа по сравнению с G1.
ZGC: приостановки аллокаций
Чтобы увидеть, когда и как такое возможно, рассмотрим другой пример. Это синтетический бенчмарк, который в цикле выделяет большие объёмы объектов в виде List<Long> со случайными числами. Результаты любопытные:

При скорости аллокаций около ~12 ГБ/с (с использованием 4 ядер тестовой системы) картина похожа на предыдущую: до p99 G1 и ZGC идут примерно на равных, тогда как p999 и p9999 у ZGC заметно ниже. Но при скорости аллокаций около ~30 ГБ/с (с использованием всех 16 ядер тестовой системы) задержки в целом оказываются ниже у G1, чем у ZGC.
Как и раньше, запись JFR помогает найти причину. Однако смотреть только на времена GC‑пауз будет обманчиво: самая длинная пауза у ZGC всё ещё измеряется микросекундами. Так что же происходит? При работе на всех ядрах системы тестовая нагрузка упирается в CPU, и конкурентным GC‑потокам ZGC не остаётся достаточно процессорного времени. Из‑за этого сборщик не успевает освобождать память достаточно быстро, чтобы поспевать за темпом, с которым приложение создаёт новые объекты. В такой ситуации ZGC будет приостанавливать аллокации, пока память снова не станет доступной. Начиная с Java 15, для этого случая в JFR логируется отдельное событие — ZAllocationStall:

Подобно временам GC‑пауз, приостановки аллокаций увеличивают хвостовые задержки приложения. Но их не стоит приравнивать к GC‑паузам: в отличие от случая с неконкурентным сборщиком, который останавливает потоки приложения, «здоровое» приложение на ZGC обычно не должно сталкиваться с приостановками аллокаций во время работы. Если они всё же возникают, это признак того, что нагрузке не хватает доступной CPU‑мощности, и вам стоит либо найти потенциальные узкие места с помощью профайлера, либо выделить больше процессорных ресурсов. Полезно отслеживать приостановки аллокаций через стриминг событий JFR и поднимать алерт при их появлении.
В заключение
ZGC — очень интересное пополнение в портфеле сборщиков мусора JVM. Перенося всю тяжёлую работу в отдельные GC‑потоки, он по сути делает большие хвостовые задержки из‑за GC‑пауз делом прошлого, и благодаря этому платформа Java становится привлекательным выбором и для тех нагрузок, для которых исторически её могли и не рассматривать.
Если вы раньше не присматривались к ZGC, сейчас может быть самое время: Java 25 — первый LTS‑релиз, который включает Generational ZGC как единственную форму этого сборщика (в Java 21 поддержка поколений для ZGC тоже появилась, но её нужно было включать отдельной JVM‑опцией). Это даёт заметные улучшения по пропускной способности и хвостовым задержкам по сравнению с реализацией ZGC с непоколенческой (single‑generation) реализацией ZGC в Java 17.
Поколенческие сборщики мусора организуют кучу в несколько поколений, используя «слабую гипотезу о поколениях, которая утверждает, что большинство объектов становится недостижимым вскоре после создания». Объекты, которые прожили какое‑то время после создания, перемещаются в область кучи, называемую «старшим поколением», и оно сканируется реже, что позволяет эффективнее использовать CPU‑ресурсы.
При этом важно помнить, что не существует одного‑единственного лучшего сборщика мусора на все случаи жизни. Переход на ZGC может дать очень приятное улучшение хвостовых задержек, но за это приходится платить снижением общей пропускной способности. В частности, если ваше приложение и так близко к упору в CPU, ZGC может оказаться не лучшим выбором. Для многих нагрузок это, впрочем, можно компенсировать горизонтальным масштабированием — добавив несколько вычислительных узлов в кластер. Тестируйте на своей нагрузке и в своём окружении, чтобы понять, даст ли переход на ZGC пользу. К счастью, попробовать просто: достаточно указать ‑XX:+UseZGC при запуске JVM.
Исходный код бенчмарков из этого поста можно найти здесь и здесь. Если вы хотите глубже разобраться в ZGC и его принципах, рекомендую блог разработчика OpenJDK Пера Лидена.
Когда у меня будет время, я хочу прогнать на ZGC несколько стриминговых нагрузок с Apache Kafka и Flink и поделиться результатами в продолжении этой статьи. Если у вас есть опыт и наблюдения по запуску этих систем на ZGC, делитесь в комментариях.
Профессиональный рост Java-разработчика до уровня Advanced начинается там, где заканчиваются «настройки по умолчанию»: понимание JVM, профилирование под нагрузкой, выбор GC под задачу и разбор памяти в работающем сервисе. На продвинутом курсе по Java это доводится до практики в облачной среде — K8s, метрики/трейсинг/логи, Prometheus+Grafana — чтобы оптимизировать производительность и предсказуемость продакшена, а не бенчмарка. Готовы к серьезному обучению? Пройдите вступительный тест по Java.
А чтобы узнать больше о формате обучения и задать свои вопросы экспертам, приходите на бесплатные демо-уроки:
28 января в 20:00. «Apache Camel: масштабируемые интеграции для Highload». Записаться
4 февраля в 20:00. «Eclipse Memory Analyzer (MAT): помощь в работе с heap». Записаться
17 февраля в 20:00. «Class Data Sharing и его перспективы». Записаться
