Команда Spring АйО перевела и адаптировала доклад "Garbage Collection in Java: The progress since JDK 8" Стефана Йоханссона(Stefan Johansson) с последнего Devoxx Belgium.

Доклад получилось поделить на две статьи. В первой вы узнаете об основах работы сборки мусора в Java, различных сборщиках мусора, а также об их особенностях, плюсах и минусах. Вторая часть будет посвящена сравнению производительности сборщиков и их прогрессу с момента выхода JDK 8.


Что такое сборка мусора?

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

Первая часть, выделение памяти, осуществляется более или менее одинаково, независимо от того, какой алгоритм используется. Существуют так называемые TLABs (от thread-local allocation buffers — буферы выделения памяти, привязанные к потокам, прим. пер.), которые выдаются сборщиком мусора потокам, занимающимся выделением памяти, что позволяет выполнять очень быстрое выделение памяти через указатели на эти буферы без какой-либо синхронизации с другими потоками. Как только TLABs заполняются, они передаются обратно в GC,а сборщик мусора получает новый TLAB в другом потоке. Конечно, в этот момент производится определенная синхронизация. Это очень эффективный алгоритм, и он работает примерно одинаково для всех сборщиков мусора.

Что отличается, так это то, как осуществляется управление эффективным высвобождением. Мы рассмотрим этот вопрос более подробно ниже, но сначала следует немного рассказать о причинах наличия нескольких сборщиков мусора. Часто у разработчиков возникают вопросы на тему того, зачем нужно столько сборщиков мусора и  почему нельзя иметь только один, который будет делать все. Главная причина состоит в том, что платформа Java так велика и поддерживает так много вариантов использования, что очень трудно иметь один сборщик мусора, который мог бы решить все проблемы. Нам необходимо иметь возможность управлять процедурой batch processing, приложениями с графическим интерфейсом, малыми и большими облачными сервисами и практически всем на свете. Все это должно управляться одной и той же платформой Java. Было бы исключительно сложно создать сборщик мусора, который был бы оптимален для всех этих случаев.

Принципы работы алгоритмов высвобождения памяти

Давайте взглянем более пристально на то, как мы высвобождаем память. Первым делом мы должны определить статус жизни (liveness) объектов, то есть узнать, какие объекты нам необходимо сохранить в живом виде, чтобы приложение продолжило корректно работать. Чтобы получить эту информацию, просматриваются графы объектов, что означает, что мы начинаем с хорошо известных корневых элементов.  Это именно те объекты, про которые мы уже знаем, что их необходимо поддерживать в живом состоянии. Поэтому, например, объекты в стеках потоков, статические поля в классах и некоторые другие объекты, которые нам нужны, всегда сохраняются в живом виде, иначе приложение не будет работать.

Получив эти первоначальные объекты, мы следуем по ссылкам, которые в них содержатся и ведут на другие объекты. И таким образом, осуществляя эту операцию рекурсивно, мы в конце концов установим все объекты, которые надо поддерживать в живом состоянии в так называемой “куче” (Java heap) или в той ее части, где производится сборка мусора.

Получив всю информацию по статусу жизни объектов, мы получаем возможность высвободить память. Существует несколько разных техник, используемых для высвобождения памяти: можно хранить в памяти списки с информацией о местонахождении свободных блоков памяти и выделять ее оттуда, альтернативно можно выполнить операцию уплотнения (compaction), которая также может называться копированием (copying). В этом случае мы копируем объекты, которые надо сохранить в живом виде, в новое место, убеждаемся в том, что они сохранились в уплотненном виде, без каких-либо пустот между ними, и затем мы можем освободить их первоначальное местоположение, таким образом высвобождая неиспользуемую память. Это лишь два способа из множества существующих в настоящее время.

На следующем шаге необходимо уяснить, когда производить собственно работу по сборке мусора. Либо эта работа производится, когда все потоки остановлены, и такой способ называется stop-the-world сборкой, то есть работа производится в основном в GC паузе (термин, описывающий приостановку работы приложения с целью сборки мусора — прим. пер.); либо можно производить сборку мусора, пока потоки приложения работают, и такой вариант называется concurrent (одновременный). Естественно, мы хотим сделать этот процесс настолько эффективным, насколько возможно.

Существует много техник, позволяющих сделать процесс сборки мусора более эффективным, и одна из наиболее важных техник называется Generational Collections. Подразумевается под этим термином следующее: вся куча делится на два поколения. Молодое поколение (young generation) — это то пространство, где хранятся только что получившие выделенную память объекты, и старое поколение (old generation) — это та часть кучи, куда перемещаются объекты, которые должны прожить долгую жизнь. Таким образом объекты, которые уже просуществовали несколько циклов сборки мусора, перемещаются в старое поколение, потому что возникает ощущение, что они должны остаться с нами надолго.

Логика, обосновывающая такую технику, называется Generational Hypothesis (гипотеза о поколениях). Она говорит нам, что недавно получившие выделенную память объекты живут только в течение короткого периода времени. Если использовать эту гипотезу, можно выполнить сборку мусора части пространства, отведенного под молодое поколение, с более низкими затратами. Лишь очень немногие объекты из молодого поколения должны поддерживаться в живом состоянии. Если эта гипотеза выполняется, у нас будет гораздо более эффективный сборщик мусора, поскольку сборка мусора только в молодом поколении требует гораздо меньших затрат, чем сборка во всей куче. 

Какие сборщики мусора существуют в настоящее время

Еще более важной информацией является то, какие сборщики мусора являются доступными в OpenJDK или в Java. Чтобы научиться правильно выбирать сборщик мусора для своего приложения, вы должны понять, что является его главным фокусом. Обычно мы фокусируемся на трех главных аспектах, когда выбираем сборщик мусора. 

  • Первый аспект — это пропускная способность (throughput); подразумевается под этим, по сути, сырое количество транзакций, которое можно выполнить за заданный период времени. 

  • Второй аспект — задержка (latency), здесь главный фокус сосредоточен на количестве времени, необходимом для завершения одной транзакции. Например, если у вас длинная GC пауза, это повлияет на задержку в рамках вашей транзакции и в рамках всего вашего приложения. 

  • Третий аспект — это объем потребляемой памяти (memory footprint), и это более или менее общая стоимость выполнения алгоритма сборки мусора в терминах дополнительного расхода памяти или ресурсов CPU.

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

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

Это означает, что у нас есть набор потоков приложения, которые запущены, и в какой-то момент появляется необходимость произвести сборку мусора, чтобы освободить память. Алгоритм делает это настолько эффективно, насколько возможно, используя несколько потоков, но все же GC пауза должна закончиться, прежде чем потоки приложения смогут запуститься опять. 

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

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

Сегодня в OpenJDK существуют пять разных сборщиков мусора:

Название сборщика мусора

На чем фокусируется

Serial

Объем потребляемой памяти

Parallel

Пропускная способность

G1

Сбалансированная производительность

ZGC

Низкая задержка

Shenandoah

Низкая задержка

Первый в этом списке Serial, и его главная цель — иметь малый объем потребляемой памяти; далее идет Parallel, который ориентируется на пропускную способность; третий в списке существующих сборщиков мусора  — это G1, который имеет сбалансированный профиль производительности и пытается обеспечить как хорошую пропускную способность, так и низкую задержку. Также в списке присутствуют две новинки, ZGC и Shenandoah, которые фокусируются на низкой задержке. 

В Oracle поддерживаются четыре из этих GC: Serial, Parallel, G1 и ZGC.

Название сборщика мусора

На чем фокусируется

Serial

Объем потребляемой памяти

Parallel

Пропускная способность

G1

Сбалансированная производительность

ZGC

Низкая задержка

Именно об этих четырех сборщиках мусора будет рассказано в статье далее. Давайте посмотрим на них более внимательно.

Serial

Прежде всего поговорим о Serial. Как уже упоминалось, его главный фокус состоит в том, чтобы минимизировать перерасход памяти и не использовать дополнительную нативную память. Он поддерживает generational сборку мусора, а устройство кучи можно проиллюстрировать вот так:

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

Parallel

Перейдем к Parallel, сборщику мусора, который ориентируется на пропускную способность. Устройство кучи будет похоже на предыдущий случай.

Также Parallel поддерживает generational сборку. Большим различием по сравнению с Serial является то, что он использует несколько потоков для выполнения работы по сборке мусора. 

До JDK 8 Parallel был сборщиком мусора по умолчанию, поэтому, если у вас есть приложения, которые работают на JDK 8, и вы не задаете свой сборщик мусора в явном виде, скорее всего, а ваших приложениях используется Parallel.

G1  

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

Куча делится на регионы одинакового размера, и эти регионы могут принадлежать либо молодому поколению, либо старому. Естественно, они также могут быть свободными, если в данный момент они не используются. Однако, регион не остается привязанным к поколению на все время жизни приложения; на самом деле, его предназначение может меняться со временем. Такое устройство кучи является гораздо более гибким.

G1 также поддерживает одновременную маркировку (concurrent marking). Это значит, что вместе с работающими потоками приложения G1 может определить статус жизни объектов в регионах старого поколения, чтобы понять, какие объекты необходимо поддерживать в живом состоянии в старом поколении без остановки Java потоков. Эти две вещи вместе — устройство кучи, базирующееся на регионах, и одновременная маркировка — позволяют G1 делать то, что называется смешанной сборкой мусора. Смешанная сборка мусора — это такая сборка, при которой мы собираем мусор из молодого поколения одновременно с перезаполнением регионов старого поколения за одну GC паузу. С помощью многочисленных смешанных сборок мы можем собрать мусор из старого поколения полностью без необходимости делать это за одну длинную паузу. 

Именно так удается избежать появления задержки наихудшего сценария, которая иногда появляется в Parallel.

Еще одна новинка, которую разработчики внедрили в G1 — это его простота в тонкой настройке: существует одно свойство, которое настраивает целевую длительность паузы (pause time goal). В G1 значение по умолчанию для целевой длительности паузы равно 200 миллисекунд, что означает, что по умолчанию G1 будет пытаться удерживать продолжительность пауз ниже 200 миллисекунд. Для некоторых сервисов это может быть слишком много, и тогда можно легко перенастроить этот параметр до 50 или 100 миллисекунд, чтобы проверить на практике, хорошо ли это работает, дает ли меньшую задержку.

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

G1 стал сборщиком мусора по умолчанию в JDK 9, так что все приложения, которые работают на JDK 9 и на более новых JDK, скорее всего используют G1, если какой-то другой сборщик мусора не был задан явно. 

ZGC

Теперь давайте обсудим новейшее решение — ZGC, главный акцент которого сделан на минимальной задержке. Разработчики позаботились о том, чтобы ZGC легко масштабировался, обеспечивая паузы меньше миллисекунды, даже при работе с памятью, исчисляемой терабайтами. Это достигается тем, что паузы сборщика мусора не увеличиваются вместе с размером кучи.

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

  

Здесь по-прежнему существуют GC паузы, но они очень короткие и служат в основном как точки синхронизации, то есть они служат тому, чтобы ZGC и потоки приложения могли решать, в какой фазе работа ZGC находится в текущий момент: отмечает ли он объекты (marking), проверяет ли статус жизни объектов или перемещает объекты (обновляет указатели на местоположение объектов). GC паузы служат для синхронизации всех этих действий.

Чтобы этот алгоритм работал эффективно, ZGC использует так называемые load barriers (барьеры по нагрузке). Это высоко оптимизированный код, так что потоки приложения на Java будут работать практически незаметно, получая доступ к объекту, чтобы узнать, безопасно ли использовать этот объект прямо сейчас, либо сперва необходимо обновить местоположение этого объекта или даже помочь с его перемещением. Когда сборщик мусора работает параллельно с потоками приложения, может получиться так, что потоки приложения помогают в выполнении некоторой работы по сборке мусора, если они оказываются первыми потоками, которые столкнулись с объектом, который надо переместить. Это весьма удачное решение.

Разработчики добавили поддержку generational сборки в ZGC, начиная с JDK 21. В JDK 21 generational режим включается при помощи отдельного флага, который называется ZGenerational. А в JDK 23 режим generational уже является включенным по умолчанию.

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

Подведем итоги по имеющимся сборщикам мусора.

Название сборщика мусора

Краткое резюме о возможностях

Serial

Низкий перерасход памяти, подходит для малых контейнеров

Parallel

Прекрасная пропускная способность, подходит для рабочих нагрузок, для которых допустима задержка наихудшего сценария

G1

Хорошая пропускная способность, управляемая задержка, подходит для долгоживущих сервисов, где размер кучи меньше 16 ГБ

ZGC

Хорошая пропускная способность, ультранизкая задержка, подходит для долгоживущих сервисов, где размер кучи превышает 4 ГБ

Заключение

Мы рассмотрели четыре сборщика мусора для Java, каждый со своими сильными сторонами:

  • Serial — минимальный расход памяти, идеально для небольших контейнеров.

  • Parallel — высокая пропускная способность, подходит для рабочих нагрузок, где задержка не критична.

  • G1 — сочетает пропускную способность и контролируемую задержку, оптимален для долгоживущих сервисов с кучей до 16 ГБ (ориентир). С большими объемами (от 16–32 ГБ) стоит рассмотреть ZGC.

  • ZGC — высокая пропускная способность и сверхнизкая задержка, предназначен для больших нагрузок и кучи от 4 ГБ, хотя эффективен и на меньших объемах.

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

Присоединяйтесь к русскоязычному сообществу разработчиков на Spring Boot в телеграм — Spring АйО, чтобы быть в курсе последних новостей из мира разработки на Spring Boot и всего, что с ним связано.

Ждем всех, присоединяйтесь!

Комментарии (1)


  1. Gmugra
    06.11.2024 12:30

    Мне кажется что в нынешних реалиях SerialGC это не столько про экономию памяти, сколько про работу в ситуации когда JVM доступно только одно процессорное ядро.

    Известно что JVM, по умолчанию в такой ситуации, запускает именно SerialGC, видимо потому, что все остальные GC не дают никаких выигрышей, если для нужных им дополнительных потоков у окружения доступных процессорных ядер нету.

    И это не редкая ситуация в реальности, когда люди запускаю приложение в какой-нить виртуальной ноде с единственным доступным процессорным ядром. А потом удивляются.

    А память... я сильно сомневаюсь, что кто-то реально живет с SerialGC чтобы память экономить.