Синтетический прогон в пятницу вечером рисует радостную картину: у нового сборщика мусора паузы в доли миллисекунды, график ровный, автор теста уже собирает презентацию для команды. В понедельник тот же сборщик в продакшене встречает утренний пик, и клиентская сторона видит пятисотмиллисекундные замирания, которых нет ни в одном логе сборки. История стара: выбор сборщика мусора это выбор компромисса, а синтетика показывает ровно одну его сторону. Ниже разбор двух основных кандидатов для высоконагруженного Java приложения, честного G1 и низколатентного ZGC, их поведения под живым трафиком и причин, по которым пауза из журнала сборщика и замирание на клиенте измеряют разные вещи.

Чем G1 отличается от ZGC на уровне устройства памяти

G1 делит кучу на регионы размером от одного до тридцати двух мегабайт и собирает мусор регионами, эвакуируя живые объекты туда, где есть место. Сборщик стал коллекцией по умолчанию в JDK 9 в 2017 году и с тех пор остаётся рабочей лошадкой большинства сервисов. Часть работы он делает конкурентно, обход и разметку кучи, а перенос живых объектов выполняет с остановкой потоков приложения. Время этой остановки управляется мягкой целью, флаг MaxGCPauseMillis со значением по умолчанию 200 миллисекунд, и встроенная эвристика подстраивает размер молодого поколения так, чтобы предсказанная пауза в цель укладывалась.

Молодые сборки переносят выжившие объекты нового поколения, смешанные добавляют к ним часть старших регионов по итогам разметки. При обычной работе в 24-гигабайтной куче это выглядит как одна-две остановки в секунду по десять-сорок миллисекунд, и большинство сервисов годами живёт с таким ритмом, не заглядывая в журнал. Отдельные наборы remembered sets внутри G1 отслеживают ссылки между регионами и делают эвакуацию возможной.

ZGC устроен иначе. Раскраска указателей и барьеры загрузки позволяют переносить объекты, пока приложение работает: при обращении к объекту барьер проверяет, не устарел ли адрес, и добирает свежий. Все дорогие операции выполняются конкурентно, стоп-фазы короткие, и заявленная планка остановок не превышает одной миллисекунды независимо от размера кучи, которую сборщик переваривает вплоть до 16 терабайт. Путь был долгим: эксперимент в JDK 11 в 2018 году, статус готовности к продакшену с JDK 15 в 2020 году, поддержка поколений с JDK 21 в 2023 году, а в JDK 23 поколенческий режим стал режимом по умолчанию.

Разница одной фразой: G1 экономит процессор и расплачивается остановками на десятки миллисекунд, ZGC почти не останавливает приложение, но берёт процессором на каждом обращении к памяти и памятью под цветные указатели.

Что обещают цифры пауз и что стоит за каждой из них

Цифра 200 у G1 это не гарантия, а ориентир для внутренней модели предсказания. Сборщик смотрит на историю переносов, оценивает время эвакуации каждого региона и подбирает набор под цель. Пока приложение ведёт себя предсказуемо, модель работает и типичные молодые сборки на кучах в десятки гигабайт укладываются в десятки миллисекунд. Стоит темпу аллокаций скакнуть или живому набору подрасти, и предсказание отстаёт от реальности: пауза вылезает за цель, а следом за ней может начаться череда смешанных сборок, каждая со своей остановкой.

Обещание ZGC честнее своих цифр. Стоп-фазы у него действительно короткие, корни потоков и старт переноса, и на кучах в сотни гигабайт миллисекундная планка держится. Но за каждой паузой стоит конкурентная работа, которая должна успевать за темпом приложения, и вот здесь начинается экономика. Барьер на каждом обращении к памяти стоит процессорных циклов, конкурентные потоки требуют своей доли ядер, а свободной памяти ZGC просит с запасом, потому что перенос идёт параллельно работе приложения. Низкие паузы не бесплатны, они оплачены пропускной способностью.

Почему синтетический бенчмарк не показывает настоящих пауз

Бенчмарк в лаборатории это стерильная комната. Темп аллокаций равномерный, живой набор мал и постоянен, код прогрет компилятором до потолка, ввода-вывода нет, сеть не теряет пакеты, а ядра процессора отданы без ограничений. В таком тепличном режиме любой современный сборщик выглядит образцово, и разница между кандидатами помещается в погрешность.

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

Продакшен не читал сценарий теста. Темп аллокаций скачет от батчных импортов и перезагрузок кэшей, живой набор растёт к концу дня, огромные массивы байтов вылезают из разбора больших JSON документов, а контейнер ограничивает процессорную квоту, и конкурентные потоки сборщика делят её с приложением. Поверх всего этого работает механизм сейфпойнтов: чтобы остановить потоки для любой паузы, виртуальной машине нужно дождаться, пока каждый поток дойдёт до контрольной точки, и это время ожидания в журнал сборщика не попадает, а клиент его видит целиком.

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

Гигантские объекты и другие сюрпризы G1 под живым трафиком

У G1 есть слабое место, о котором узнают только на реальных объёмах. Объект размером от половины региона получает статус гигантского и размещается в специальной цепочке регионов, причём занимает память больше своего размера, а освобождается только по завершении конкурентной разметки. JSON документ на несколько мегабайт, превращённый в массив байтов, при стандартных четырёхмегабайтных регионах становится гигантом, и поток таких документов быстро покрывает кучу цепочками, которые нельзя уплотнить. В журнале gc+heap на это указывает строка вида Humongous regions: X->Y, и растущий X это сигнал бедствия.

Арифметика простая. Для кучи в 24 гигабайта G1 по умолчанию выбирает регионы около четырёх мегабайт, гигантским считается объект от половины региона, то есть от двух мегабайт. Разбор JSON документа в три мегабайта байтовым массивом попадает в категорию дважды. Профилактика известна: не держать большие документы в памяти целиком, обрабатывать их потоково и резать на части, а там, где формат не позволяет, поднимать размер региона до шестнадцати мегабайт.

Дальше сценарий известен по документации: гигантские аллокации замедляют конкурентную разметку, разметка не успевает завершиться к началу уплотнения, и дело заканчивается Full GC, тяжёлой остановкой, которую флагами настраивать нельзя. Лечение описано там же: увеличить размер региона через G1HeapRegionSize, снизить темп аллокаций в старшем поколении, добавить потоков разметки через ConcGCThreads. С JDK 10 полная сборка у G1 параллельная, но счастья она всё равно не приносит: пауза на секунды и остановленный транзит запросов.

ZGC в реальном трафике и скрытые заморозки при всплесках аллокаций

ZGC переносит стоимость из журнала пауз в другие места, и главное из них называется заморозкой аллокаций. Когда темп выделения памяти обгоняет способность конкурентных потоков освобождать её, потоки приложения, которым нужна новая память, встают в очередь и ждут. В журнале это не пауза сборщика, клиент видит самое натуральное замирание, а в отчёте по паузам всё тихо. Толерантность к всплескам настраивается, но лечит она прогноз, а не физику: если аллокаций больше, чем способен переварить конкурентный цикл, ожидание неизбежно.

Величина заморозок измерима и честно фиксируется в журнале отдельными строками: приложение ждёт памяти десятки и сотни миллисекунд в пиковые минуты, и записи об этом лежат в журнале рядом с паузами. Рядом живёт мягкий потолок кучи, настройка ниже жёсткого максимума, которая оставляет конкурентному переносу задел свободной памяти. Чем больше задел, тем реже заморозки, и платой за него служит память, которая лежит подстраховкой.

Поколенческий режим из JDK 21 улучшил именно эту сторону. Разделение на молодое и старшее поколения опирается на наблюдение, что подавляющая часть объектов перестаёт использоваться вскоре после создания, и сборка молодого поколения отдельным быстрым циклом снимает огромную долю конкурентной работы. На живых сервисах это заметно и по пропускной способности, и по частоте заморозок, поэтому в JDK 23 режим стал единственным.

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

Как измерять паузы честно от JFR до клиентских перцентилей

Честное сравнение начинается с одинаковых условий: куча фиксированного размера, одинаковые лимиты контейнера, одинаковый трафик. Типовой набор для снятия показаний выглядит так:

java -XX:+UseG1GC -Xms24g -Xmx24g \
     -Xlog:gc*,safepoint:file=g1.log:time,uptime -jar app.jar

java -XX:+UseZGC -Xms24g -Xmx24g \
     -Xlog:gc*,safepoint:file=zgc.log:time,uptime -jar app.jar

jfr print --events jdk.GarbageCollection recording.jfr

Равенство начального и максимального размера кучи убирает шум от роста, журнал сейфпойнтов показывает время выхода на точку остановки, а события JFR дают точные длительности каждой сборки. Дальше начинается та часть, которую пропускают почти все: сводить журналы с клиентскими перцентилями. Пауза сборки, время ожидания сейфпойнта и заморозка аллокаций в сумме дают то, что видит пользователь, и только client-side P999 может подтвердить, что стало лучше. Окно сравнения держат не меньше недели, чтобы в него попали пиковые дни, батчные окна и перезагрузки кэшей, иначе сравнение случайно поймает неспокойный день и посоветует неправильный сборщик.

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

Как выбирать между G1 и ZGC по метрикам а не по слухам

Решение о сборщике принимается по короткой процедуре, одинаковой для любой команды:

  1. Снять клиентские перцентили задержки P99 и P999 на реальном трафике и зафиксировать базу для сравнения;
  2. Свести журнал сборщика и журнал сейфпойнтов с замираниями, видимыми клиенту, чтобы понять природу каждой задержки;
  3. Для G1 проверить долю гигантских объектов и частоту полных сборок по журналу gc+heap до всякого перехода;
  4. Убедиться, что процессорная квота контейнера покрывает конкурентные потоки кандидата на пиковом темпе аллокаций;
  5. Прогнать кандидата на продакшен-контуре не меньше недели со всеми типовыми нагрузками и пиками;
  6. Сравнить итог по трём осям сразу, клиентским перцентилям, пропускной способности и потреблению процессора на запрос.

У каждой стороны своя территория. G1 остаётся правильным выбором для баточной аналитики, конвейеров обработки и сервисов с дефицитом ядер, где важнее всего соотношение полезной работы к процессору, а гигантские объекты лечатся размером региона. ZGC окупается там, где цена хвоста задержек высока: платёжные шлюзы, торговые системы, интерактивные сервисы с жёсткими обещаниями по P999 и кучи в десятки и сотни гигабайт, на которых G1 не удерживает паузы в разумных границах.

Для полноты картины у правила есть третье ответвление: чисто баточные конвейеры без человека за экраном честнее всего обслуживает старый Parallel, который не тратит процессор на барьеры и погоню за низкими паузами, потому что паузы там никому не мешают. И оговорка про маленькие кучи: на четырёх-восьми гигабайтах разница между G1 и ZGC сжимается, налог ZGC остаётся, а выгода по хвостам тает, и смена сборщика не окупит даже дня экспериментов.

Сборщик не делает приложение быстрым, он выбирает место оплаты. G1 берёт паузой в разгар работы, ZGC процессором на каждый такт и редкими ожиданиями памяти. Синтетический тест скрывает кассу, продакшен показывает чек, и платить всегда приходится там, где команда забыла посмотреть.