Работаю в финтехе на должности Java инженера. Разрабатываем highload-сервис. Прод версия нашего сервиса раскатана примерно на 150 юнитах, и она обслуживает запросы буквально со всех отделений банка.
Моя команда занимается системной разработкой. Соответственно нам приходится достаточно часто взаимодействовать командой, отвечающей за производительность системы.
Разработка HL-решения со строгим SLA по RPS и latency затрагивает множество аспектов. В частности, OLTP, мультиплексирование запросов, неблокирующий I/O и прочее. В данной статье хотел бы поговорить про сборку мусора. Мы не будем погружаться в цитирование спецификации. Напротив, взглянем на сборку глазами инженера, перед которым стоит задача оптимизировать процесс с минимальными издержками.
Сравнение сборщиков
JDK 12 поставляет 7 сборщиков на выбор: Serial, Parallel, Concurrent Mark Sweep (CMS), Garbage-First (G1), Shenandoah , ZGC, Epsilon.
Работа над настройкой сборщика обычно начинается с формализации требований к его показателям, после чего на HL-стенде происходит проверка гипотез и настройка коллектора.
Типовые метрики, по которым оценивается качество сборщика следующие:
Максимальное STW — на длительном промежутке работы приложения
Throughput (пропускная способность) — отношение общего времени работы программы к общему времени простоя
Resources (ресурсы) — объем ресурсов процессорного времени и памяти, потребляемых сборщиком
Promptness (период проворства) — время от момента, когда объект стал нам не нужен, до момента его фактического удаления из памяти
Если попытаться быстро сравнить сборщики получим примерно следующую таблицу
Parallel |
G1 |
ZGC |
Shenandoah |
|
---|---|---|---|---|
Max STW-pause, ms ~ |
10 000 |
200 |
10 |
10 |
Throughput |
⭐⭐⭐⭐⭐ |
⭐⭐⭐ |
⭐⭐ |
⭐⭐ |
Promptness |
⭐ |
⭐⭐⭐ |
⭐⭐⭐⭐⭐ |
⭐⭐⭐⭐⭐ |
Max heap, GB |
10 |
100 |
16 384 |
16 384 |
Max CPU load |
low |
mid |
high G1 + 30% |
high G1 + 20% |
Max mem load |
mid |
mid |
high G1 + 30% |
high G1 + 20% |
Latency guarantee |
нет |
soft real time |
near strict real time |
near real time |
Java |
JDK 5+ |
JDK 9+ |
JDK 11+ |
JDK 12+ |
Memory barriers |
mark phase |
write, SATB |
load, store |
read, write |
Barriers throughput cost ≤ |
3% |
15% |
4% |
10% |
Кто не любит длинных статей может остановиться на этом кратком обзоре.
Кому интересно почему 3 сборщика не вошли в сравнительную таблицу и подойдет ли сборщик с оптимальными метриками для конкретного проекта, приглашаю глубже познакомиться с "под капотом" каждого коллектора.
Введение в сборку мусора
В большинстве случаев в Java мы не удаляем объекты из памяти вручную. Потому что сборка мусора – это автоматизированный процесс.
Присутствие в Runtime большой теневой службы создает конкуренцию за вычислительные ресурсы. Поэтому столько сколько существуют сборщики мусора, идет работа над их оптимизацией.
В основу одной из первых идей оптимизации работы сборщика легла т.н. слабая гипотеза о поколениях.
Объекты в runtime можно разделить на категории по их числу и продолжительности жизни:
Короткоживущие объекты (short‑lived) – объекты, цикл жизни которых не превышает цикл выполнения 1 метода. Примеры: итераторы, объекты в циклах, переменные методов, боксинг-оболочки.
Объекты средней продолжительности жизни (medium-lived) – объекты, цикл жизни которых от 1 до нескольких циклов выполнения методов. Примеры: объекты сессий, кэш, транспортные объекты.
Долгоживущие объекты (long-lived) – объекты цикл жизни которых стремиться ко времени работы приложения. Примеры: сервисы, статические поля классов, константы.
Учитывая рассмотренные особенности принято делить объекты на поколения:
Young generation (молодое поколение) включает в себя short-lived и medium-lived объекты
Old generation (старшее поколение) включает в себя long-lived оbjects
В общем случае абсолютное большинство runtime объектов относятся к молодому поколению.
Слабая гипотеза о поколениях говорит о том что вероятность смерти объекта как функция от возраста снижается очень быстро.
Вывод из слабой гипотезы о поколениях в том, что имеет смысл разделить кучу на регионы по поколениям и проводить чистку в регионе молодого поколения чаще чем в регионе старшего.
Из модели поколений проистекает деление циклов сборки по регионам. Сборка мусора в регионе молодого поколения называется минорным циклом (minor gc). Аналогично выделяют мажорный цикл (major gc) сборки в регионе старшего поколении. Полный цикл сборки включает в себя оба цикла сборки сразу. Обычно он инициируется когда сборщик не справляется с высвобождением памяти.
Выделение поколений с отдельными циклами сборки мусора привело к необходимости структурировать кучу (heap) на регионы (regions) по назначению. Модель регионов кучи определяется сборщиком мусора.
Цикл сборки
Алгоритм сборки структурируется на фазы. В общем случае можно выделить следующие фазы сборки:
Mark – пометить живые объекты в процессе обхода графа объектов.
Sweep – зачистка мертвых объектов.
Compact – дефрагментация объектов в памяти.
Evacuate. Наличие фазы эвакуации живых объектов зависит от имплементации сборщика. Фаза может включать в себя маркировку или дефрагментации. Главная цель эвакуации – разделить регионы памяти на содержащий живые и мертвые объекты перед очисткой.
Фазы сборки мусора могут происходить в конкурентном режиме (сoncurrent) или в режиме STW (Stop The World). Конкурентный режим означает, что потоки выполнения сборщика происходят параллельно с основными потоками приложения. Выполнение фазы сборки мусора в STW приводит к прерыванию выполнения основных потоков приложения.
Для выполнения операций сборки бывает важно прервать основные потоки приложения, т.к. среди них есть т.н. потоки-мутаторы (mutator threads). Мутаторы меняют ссылки на объекты. Изменение графа ссылок одновременно со сканированием графа сборщиком мусора может привести к проблеме плавающего мусора либо к коррупции данных.
Serial GC
Предназначен для сборки мусора в монопоточном режиме. Первая версия вышла в 1996, JDK 1.0
Схема регионов кучи сборщика

Особенности алгоритма сборки
Все новые объекты появляются в Эдеме. При заполнении Эдема происходит минорная сборка мусора, которая распространяется строго на Эдем и на 1 из областей выживших.
Вторая область выживших всегда пустая. В ходе цикла сборки живые объекты всегда перемещаются во вторую пустую область выживших. Так достигается дефрагментация объектов памяти. После чего в Эдеме и в текущей области выживших удаляются все объекты.
После некоторого числа перемещений объекта по пространствам выживших объект переходит в старшее поколение и перемещается в Tenured (штатный) регион. Мажорный цикл сборки происходит при заполнении штатного региона.
Все фазы сборки происходят в монопоточном STW-режиме. Т.е. с прерыванием работы основных потоков приложения.
Применение
Oracle рекомендует этот сборщик для одноядерных систем с размером кучи до 100 МБ, когда прерывания работы основных потоков до 10 секунд не являются критическим.
Parallel GC
Параллельный сборщик мусора является версией серийного сборщика, адоптированной для многоядерных систем. Сохраняет похожую структуру регионов и идею алгоритма сборки как в серийном.
Главное отличие алгоритма сборки по сравнению с серийным в том, что Минорный и мажорный циклы сборки происходят в несколько потоков в STW.
При настройке Parallel GC можно задать целевые показатели максимального STW и пропускной способности, к которым он будет стремиться. Не рекомендуется задавать оба показателя сразу, т.н. ведет к непредсказуемому поведению
Параллельный сборщик обеспечивает более короткие STW-прерывания по сравнению с серийным сборщиком в следствии распараллеливания работы
Для эффективной работы рекомендованный запас свободной памяти составляет не менее 25% от общей памяти кучи. Риск при нехватке свободной памяти – частые Full GC.
Параллельный сборщик подходит для применения в многоядерных системах с размером кучи до 4 ГБ в ситуации, когда прерывания работы основных потоков до 10 секунд не являются критическим.
Concurrent Mark Sweep GC
В качестве альтернативы параллельному сборщику в JDK 5 появляется CMS GC.
Идея алгоритма сборки заложена в названии. Идея в том, чтобы фазы маркировки и удаления мертвых объектов происходили конкурентно с основными потоками приложения.
Схема регионов кучи по-прежнему основывается на модели поколений, аналогично схеме регионов серийного сборщика.
Из особенностей алгоритма сборки CMS можно отметить что в действительности фаза маркировки объектов была поделена на 3 подэтапа, 2 из которых происходят в режиме STW-прерывания. Из-за того, что один из подэтапов маркировки происходит в конкурентом режиме есть шанс, что мусор может быть помечен как живой объект. В спецификации эта аномалия называется плавающим мусором (floating garbage).
Оба цикла сборки происходят в несколько потоков, при чем минорный происходит в режиме в STW, а мажорный в конкурентном режиме. При этом мажорный цикл запускается не по событию заполнения региона, а по периоду и в нем отсутствует фаза дефрагментации объектов.
Этот факт в совокупности с наличием плавающего мусора приводит к повышенному потреблению памяти. Oracle рекомендует увеличить размер кучи CMS-сборщика на 20% по сравнению с кучей для Параллельного сборщика.
В сравнении с Параллельным сборщиком, CMS обеспечивает более короткие управляемые прерывания. Но при этом снижается пропускная способность приложения.
Применимость CMS коллектора
В современном мире CMS имеет смысл использовать для поддержания legacy систем на JDK 8 либо младше.
Проблема плавающего мусора стала одной из причин, по которой на смену CMS пришел Garbage first сборщик. Это случилось в JDK 9. В этой же версии CMS получает в статус Deprecated, а начиная с JDK 14 CMS удален из поставки (см. JEP 363 2019/08/03)
Garbage first GC
Основная идея Garbage First сборщика состоит в том, чтобы постоянно сканировать регионы и за счет знания о регионах эффективно восстанавливать память.
G1 делит кучу на множество регионов равного размера. При этом тип региона может меняться динамически.
Типы регионов могут быть:
eden (регион новых объектов), survivor (регион выживших), tenured (регион штатного поколения)
humongous-регион – предназначен для humongous-объектов, т.е. для объектов, имеющих размера более 50% размера региона
available-регион – зарезервированное пространство для будущих аллокаций
Новшеством G1 сборщика становится внедрение т.н. функций-барьеров.
Барьеры (barriers) — это фрагменты кода, которые при помощи JIT-компиляции встраиваются в точку доступа к объекту памяти. Барьеры выполняются не в выделенных потоках сборщика, в основных потоках приложения, в т.н. потоках-мутаторах
Самый интересный из барьеров G1 называется Snapshot-At-The-Beginning Barrier (SATB)
Snapshot-At-The-Beginning Barrier
SATB-барьер активируется если цикл алгоритма сборщика находится в фазе начальной маркировки живых объектов и происходит изменение ссылки на объект мутатором.
SATB-барьер записывает старую ссылку на объект в т.н. SATB-буфер текущего потока.
Использование SATB-буферов в фазах алгоритма сборщика позволяет решить проблемы плавающего мусора и коррупции данных в условиях полностью конкурентной маркировки объектов
Особенности алгоритма
G1 постоянно сканирует регионы в конкурентном режиме, знает в каком больше мусора и чистит эти регионы первыми «Garbage First!»
Минорный цикл хоть и происходит параллельно в STW но не во всех регионах, а лишь в некоторых что минимизирует продолжительность прерываний.
Основной цикл сборки называется Mixed gc т.к. включает в себя фазы маркировки живых объектов и фазу очистку областей старого и молодого поколений. Большинство подэтапов смешанного цикла сборки происходит в конкурентном режиме.

Алгоритм сборщика предусматривает специальное поведение в отношении humongous-объектов. Эти объекты не перемещаются между регионами, и в их регион не подселяют другие объекты. Такой подход позволяет экономить ресурсы, но может стать проблемой в ситуации, если в рантайме появляется много больших объектов.
Есть возможность задать целевой показатель максимальной задержки, под который подстроится алгоритм. Алгоритм обеспечивает soft real time гарантию достижимости целевого показателя.
Для эффективной работы рекомендован запас свободной памяти 20% от общего числа регионов. Риск при дефиците памяти – деградация до Serial mode, снижение throughput.
В итоге Garbage First сборщик обеспечивает низкие STW прерывания на уровне до 200 мс. При этом снижается пропускная способность алгоритма. Гарантированный throuput приложения с G1 GC всего 90%.
Применимость сборщика
Garbage First подходит для приложений со средним размером кучи (~ от 4 до 100 ГБ), пропускная способность которых не является критическим показателем.
Garbage First является универсальным сборщиком, который используется виртуальной машиной по-умолчанию, начиная с JDK 9.
Z GC
Главная идея Z-сборщика в том, чтобы обеспечить алгоритм сборки при котором задержки не будут увеличиваться с ростом кучи и числа объектов.
В итоге он поддерживает приложения с размером кучи до 16 ТБ. А эффективная работа сборщика позволяет обеспечить STW паузы на субмиллисекундном уровне. Т.е. при нормальной работе продолжительность прерываний основных потоков может составлять менее 1 мс.
Модель регионов на цветах указателей.
ZGC структурирует кучу на множество регионов одинакового размера.
Роль типов регионов выполняют цвета, закодированные в указателе на объект. Цвет указателя объекта определяется комбинацией из 4 битов. Каждый бит цвета отражает свойство объекта, которое учитывается в алгоритме сборки.
Так как информация о регионе хранится в указателе, при изменении цвета, объект как бы перемещается в другой регион, что существенно снижает нагрузку, связанную и перемещением объектов

Особенности алгоритма
Указатель на объект реализует модель виртуальной адресации. Указатели хранятся в специальных таблицах (в forwarding tables). Поэтому 1 объект памяти может иметь несколько адресов. Соответственно объект может единовременно находится в нескольких регионах и участвовать фазах сборки разных циклов.
Сборщик использует функции-барьеры. Он управляет логикой барьеров и при этом стремится делегировать максимально большую часть работы с объектами основным потокам приложения. В частности, эвакуация объектов, происходит исключительно в рамках основных потоков. За счет этой особенности сборщика удалось достичь того, что размер и число объектов в куче не влияет на продолжительность задержек.
Цикл сборки представляет собой 7 фаз. Подробно погружаться в него не будем. Стоит упомянуть, что фазы циклов сборки Z коллектора структурированы таким образом, чтобы начальные фазы текущего цикла могли включать в себя действия финальных фаз прошлого цикла. При этом, для того чтобы различать в какой фазе цикла находится текущий объект используются биты цвета указателя на объект. В частности биты Marked0, Marked1
Из негативных сторон алгоритма можно выделить что из-за широкого применения барьеров скорость чтения объектов памяти основными потоками приложения снижается на до 5%
Для эффективной работы сборщика необходимо поддерживать запас свободной памяти кучи не менее 25% от общего размера кучи. Риск при дефиците памяти – рост числа псевдо-STW прерываний.
Псевдо-STW прерывания — это кратковременные блокировки потоков приложения, которые возникают из-за нехватки свободной памяти в куче. В отличие от обычных STW прерываний они длятся менее 1 мс, но могут участиться при высокой нагрузке.
Применение Z сборщика предполагает все кейсы, где строгий SLA по latency либо большой размер кучи. В частности ряд служб Hypixel-сервера (Minecraft) используют ZGC. Z-сборщик является типовым решением для таких инструментов, как Elastisearch и Kafka.
Shenandoah GC
Основная идея Shenandoah сборщика похожа на ZGC. Shenandoah стремиться к тому, чтобы время и количество прерываний не зависело от размера кучи.
Схема регионов
Shenandoah структурирует кучу на множество регионов одинакового размера. Тип региона может меняться в райтнайм динамически
Регионы могут быть типов:
регион живых объектов, не подлежат очистке
регион живых объекты и подлежит очистке
регион забронирован для перемещения в него выживших
humongous регион для больших объектов
Особенности алгоритма
При использовании Shenandoah в метаструктуре объекта кучи появляется новое обязательное поле – Forward pointer (указатель перенаправления).
Если указатель перенаправления указывает на себя, значит происходит работа с целевым объектом. Если указывает на другой объект, значит надо обновить ссылку и продолжить работу с объектом указанным в Forward pointer
Структура заголовка объекта кучи
Каждый объект кучи имеет заголовок (header). Метаструктура полей заголовка может варьироваться в зависимости от типа объекта, но всегда есть обязательные поля:
MARK WORD
используется при блокировках, хешировании, очистки мусораCLASS POINTER
указатель на класс объекта
Работу по выполнению логики указателя перенаправления делает поток-мутатор за счет наличия соответствующих барьеров. Таким образом для перемещения объекта сборщику достаточно объявить его копию и переназначить указатель перенаправления.

Сам алгоритм сборки разбит на 8 фаз, большинство из которых происходит в конкурентом режиме. На графике видно, что STW прерывания есть, отмечены красным, но они занимают незначительное время.
Failure Mode сборщика (состояния при котором сборщик перестает справляться с высвобождением памяти) предусматривает Pacing-режим. Pacing притормаживает работу основных потоков приложения. Если этого недостаточно, возможна дальнейшая деградация приложения вплоть до полной остановки основных потоков для проведения полного цикла сборки.
Для эффективной работы рекомендуется обеспечить запас свободной памяти кучи на уровне 30% от общего объекма. Риск при дефиците памяти – рост частоты прерываний.
В итоге Shenandoah применяется для чувствительных к прерываниям приложений с большой кучей. К таковым можно стриминговые платформы, финтех, онлайн-игры
Шенандо является типовым выбором для таких инструментов как: Системы процессинга больших данных Apache Spark, Flink; Базы данных Apache Ignite, Cassandra
Epsilon GC
Второе название Epsilon сборщика – «No-Op Garbage Collector». Так как он не выполняет никаких действий. Можно легко заполнить память и спровоцировать OutOfMemoryError. Преимущество Эпсилон сборщика в том, что он обещает предельно низкие накладные расходы на сборку мусора без снижения пропускной способности приложения.
JEP 318 от 2017/02/14 08:23, JDK 11
Epsilon управляет распределением памяти, но не реализует какой-либо реальный механизм восстановления памяти. Как только доступная куча Java будет исчерпана, JVM завершит работу
Применение этого сборщика довольно широкое:
Short-lived приложения. Пример: тесты, CI/CD-пайплайны
Бенчмарки и профилирование. Благодаря Epsilon GC можем провести замер производительности без потери пропускной способности.
Специальные задачи, когда требуется low-latency приложение с ручным управлением памятью. Не знаю зачем создавать такое приложение на Java, но благодаря Эпсилон сборщику это возможно.
Заключение
Если попробуем сравнить рассмотренные сборщики, увидим, что лучшего решения нету.
Видим четкую обратную зависимость между снижением STW и пропускной способностью.
Параллельный сборщик демонстрирует лучшую пропускную способность.
Shenandoah побеждает Z GC по эффективности потребления ресурсов, но уступает собрату в стабильности алгоритма.
В ситуации, когда у нас строгий SLA по latency имеет смысл задуматься на Z сборщиком так как он дает более строгие гарантии достижимости целевых показателей по прерываниям.
Garbage first дает лучшее соотношение цена/качество, поэтому является дефолтом.
В случае оптимизации сборщиков, как и во многих других ситуациях проектирования приложений, нет лучшего решения, мы выбираем наименее худшее решение.
Важно понимать, что качество работы с памятью зависит бизнес задачи, от версии доступной JDK, настройки среды ОС и даже железа на котором развернута среда.
dersoverflow
к сожалению, вы и близко не представляете НАСКОЛЬКО все плохо!
"80% more" - вдумайтесь!!