Дюк, вынеси мусор! — Часть 4 +21


Зачем еще один?

Мы уже успели рассмотреть четыре различных сборщика мусора, разработанных для разных целей, разных профилей нагрузки, разного железа. Что же такого особенного хотели предложить разработчики ZGC, чего мы еще не встречали?

Официальное описание говорит нам о том, что при его проектировании ставились следующие цели:

  1. Поддерживать паузы STW на уровне меньше одной миллисекунды.

  2. Сделать так, чтобы паузы не увеличивались с ростом размера кучи, количества живых объектов или количества корневых ссылок.

  3. Поддерживать кучи размером до 16 ТБ.

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

Использование ZGC включается опцией -XX:UseZGC (в версиях JDK с 11-й по 13-ю она доступна только при использовании опции -XX:+UnlockExperimentalVMOptions).

Виртуальная память

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

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

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

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

Далее мы увидим, как ZGC активно эксплуатирует эту особенность. Но предварительно нужно разобраться с устройством указателей на объекты при использовании данного сборщика.

Цветные указатели

Для достижения своих целей ZGC использует подход, называемый раскрашиванием указателей. На практике это означает, что каждый 64-битный указатель (а ZGC поддерживает только 64-битные системы) содержит не только адрес памяти, но и дополнительные метаданные, определяющие текущий статус указателя.

Под адрес в указателе выделяется от 42 до 44 младших бит в зависимости от установленного максимального размера кучи. Это значит, что ZGC может работать с кучами размером до 16 ТБ (одну из трех поставленных задач уже решили, это было несложно). До версии JDK 13 был вариант только с 42-битными адресами и ограничением на размер кучи 4 ТБ. Мы в данной статье будем рассматривать вариант с 44-битным указателем.

Еще четыре бита выделено под метаданные:

  • Marked0 (0001) и Marked1 (0010) — используются для пометки указателей на разных фазах сборки.

  • Remapped (0100) — указатель помечается этим битом в случае, если адрес в указателе является окончательным и не должен модифицироваться в рамках текущего цикла сборки.

  • Finalizable (1000) — этим битом помечаются объекты, достижимые только из финализатора.

Комбинация этих флагов определяет состояние указателя, которое при описании ZGC называется его "цветом".

А что с остальными 16-ю битами? Они всегда равны нулю и не используются.

В итоге, указатель на объект в памяти JVM при использовании ZGC имеет такую структуру:

Структура "цветного" указателя
Структура "цветного" указателя

Теперь давайте объединим это знание с тем, что мы вспомнили про устройство виртуальной памяти. В этом случае нулевой адрес (младшие 44 бита) с тем или иным установленным "красочным" битом будет представлять собой начало 16-терабайтной области в виртуальной памяти. Причем все эти области проецируются на одну и ту же область физической памяти — на кучу JVM:

Отображение виртуальной памяти на кучу
Отображение виртуальной памяти на кучу

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

Барьеры

Еще одной особенностью ZGC является использование т. н. барьеров (barriers) во время конкурентных фаз сборки мусора (когда сборщик работает одновременно с приложением, не останавливая его выполнение).

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

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

Какие конкретно манипуляции барьеры производят с указателями и с объектами, мы рассмотрим ниже. Пока что нам достаточно знаний просто про наличие таких барьеров.

Поиск живых объектов

Теперь давайте посмотрим, как ZGC использует цветные указатели и барьеры для очистки кучи. Первым этапом работы сборщика является покраска указателей на достижимые объекты.

Для иллюстрации работы ZGC будем использовать пример из презентации Oracle. Начинается всё с такого расположения и состояния объектов:

Начальное состояние кучи
Начальное состояние кучи

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

Этап поиска живых объектов и первоначальной раскраски ссылок состоит из трех фаз:

Фаза Pause Mark Start

Сборка мусора, как обычно, начинается с поиска живых объектов. А поиск живых объектов, как обычно, начинается с поиска объектов, достижимых из корней. Для этого ZGC использует короткую STW-паузу. Если вы не понимаете что-то в этих трех предложениях, лучше еще раз перечитать как минимум первую статью данного цикла

Особенностью ZGC является то, что в процессе обхода кучи он не только определяет, какие из объектов являются живыми, но и попутно красит указатели, по которым путешествует, устанавливая у них один из битов Marked0 или Marked1.

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

После пометки корней
После пометки корней

Фаза Concurrent Map

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

Так как во время этой фазы приложение работает и может создавать новые объекты, в этот период активно используются барьеры, которые красят все указатели, по которым в это время производится доступ к объектам.

Фаза Pause Mark End

После завершения конкурентной фазы опять возникает пауза STW, в рамках которой ZGC обрабатывает различные специальные кейсы. В частности, soft- и weak-references.

В результате все указатели на живые объекты оказываются раскрашенными, а все объекты, до которых можно добраться по таким указателям, являются достижимыми:

После пометки всех живых объектов
После пометки всех живых объектов

Перемещение

Следующий этап в работе ZGC — это перемещение объектов для дефрагментации и высвобождения памяти. Данный этап так же разбит на несколько фаз:

Фаза Concurrent Prepare for Relocate

В рамках этой активности сборщик определяет блоки памяти, объекты из которых подлежат перемещению. Эти блоки попадают в так называемый набор для перемещения (relocation set).

В такой набор заносятся блоки, в которых находится достаточно много мертвых объектов, чтобы их перемещение приносило пользу:

Выбраны блоки для перемещения
Выбраны блоки для перемещения

Фаза Pause Relocate Start

Дальше начинается непосредственно перемещение объектов. Как и их поиск, перемещение начинается с объектов, достижимых из корней, и производится в рамках паузы STW.

Если объект достижим по указателю из корня и подлежит перемещению, он переносится в новый блок памяти, корневой указатель на него красится и сборщик запоминает соответствие старого и нового адреса перемещенного объекта в специальных таблицах переадресации (forwarding tables). Такие таблицы ведутся отдельно для каждого блока в памяти за пределами кучи.

Корневые указатели на объекты, не подлежащие перемещению, просто перекрашиваются в тот же цвет.

После перемещения корневых объектов
После перемещения корневых объектов

Фаза Concurrent Relocate

Эта фаза распространяет описанную выше активность переноса объектов (вместе с ведением таблицы переходов) на оставшуюся кучу.

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

Например, без работы барьеров картина после данной фазы выглядела бы вот так:

Объекты перенесены, но указатели не перекрашены
Объекты перенесены, но указатели не перекрашены

Но если в течение этой фазы приложение попыталось получить объект 5 по устаревшему указателю из объекта 4, то барьер корректно перенаправит и перекрасит этот указатель:

Указатель перекрашен барьером
Указатель перекрашен барьером

Фаза Concurrent Remap

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

Чтобы поправить все такие указатели, необходимо совершить еще один обход графа объектов, проследовав по всем указателям и перенаправив их на новые адреса в соответствии с таблицами переадресации. Но ZGC не выполняет эту фазу сразу в рамках текущего цикла сборки, а совмещает ее с фазой Concurrent Mark следующего цикла сборки. То есть фазы в разных циклах сборки накладываются друг на друга:

Организация фаз внутри циклов сборки
Организация фаз внутри циклов сборки

После фазы Pause Mark Start следующего цикла сборки будут помечены объекты, достижимые из корней:

После пометки корней в следующем цикле сборки
После пометки корней в следующем цикле сборки

И далее в рамках фазы Concurrent Mark коллектор пройдется по всем оставшимся указателям, обнаружит несоответствие их цветов (красные) текущей фазе сборки (синий) и поправит. Либо сама программа, получая объекты по таким зависшим указателям и вызывая срабатывание барьеров, будет перемещать объекты и перекрашивать указатели в актуальный цвет.

В результате после завершения этой фазы всё встанет на свои места:

Результат сборки мусора
Результат сборки мусора

Полная сборка

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

При активном выделении памяти может возникнуть ситуация, когда у JVM не осталось свободных блоков для размещения новых объектов. В этом случае работа приложения останавливается (пауза STW) и, как и при использовании других сборщиков, запускается цикл полной сборки мусора. Здесь, конечно, речь о субмиллисекундных паузах уже не идет.

Настройка

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

С помощью опции -XX:ZFragmentationLimit=percent можно задавать процент фрагментации, при достижении которого блок памяти попадает в набор для перемещения. Чем меньше значение, тем интенсивнее очистка памяти.

Используя опцию -XX:ZCollectionInterval=seconds можно установить максимальное время между сборками, то есть инициировать сборку в некоторых случаях, когда сам сборщик пока еще не считает это необходимым.

Опция -XX:ZAllocationSpikeTolerance=factor определяет, насколько большие всплески активности по выделению новой памяти ожидает сборщик. На основании таких ожиданий ZGC планирует последующие сборки, чтобы в штатном режиме успевать собирать мусор до того, как иссякнет резерв свободных блоков и придется проводить полную сборку.

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

Опции -XX:ZUncommit и -XX:ZUncommitDelay регулируют возврат неиспользуемой памяти операционной системе.

Ну и общая для многопоточных сборщиков опция -XX:ConcGCThreads=threads (ровно как и другие общие опции), позволяющая задавать количество выделенных на сборку мусора потоков, применима и в ZGC.

Достоинства и недостатки

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

Паузы, действительно, короткие
Паузы, действительно, короткие

Но эти короткие паузы не даются бесплатно.

Во-первых, из-за наличия длительных конкурентных фаз сборки может страдать пропускная способность приложения. Насколько сильно — зависит от конкретного приложения.

Во-вторых, используемые для доступа к объектам барьеры совсем не бесплатные. В разных источниках фигурируют оценки замедления перехода по указателю на 4-5%, но это в оптимистичном сценарии, когда цвет указателя "хороший" и барьеру не требуется менять сам указатель или переносить соответствующий объект на новое место. В случае обнаружения "нехорошего" цвета указателя барьеру придется уйти на длинный путь и потратить значительно больше времени на приведение данных в порядок.

В-третьих, чтобы держать паузы на субмиллисекундном уровне и не допускать полных сборок мусора ZGC требуется достаточное количество свободных регионов памяти. Если ваше приложение очень активно создает новые объекты, ему, скорее всего, потребуется куча чуть большего размера, чем при использовании того же G1.

Заключение

Мы начали разбор ZGC с декларируемых целей его разработки. Достигнуты ли эти цели?

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

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

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

Ранее:

← Часть 3 — Сборщики CMS и G1

← Часть 2 — Сборщики Serial GC и Parallel GC

← Часть 1 — Введение




Комментарии (3):

  1. pin2t
    /#24585240 / -2

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

    • Ivanhoe
      /#24585308 / +2

      Люди программируют не только веб с микросервисами

    • alygin
      /#24585406 / +10

      монопольное использование ресурсов

      Не очень понял, на основании чего вы сделали такой вывод. Процесс Java использует те ресурсы, которые вы ему выделили. При желании можете ограничивать как считаете нужным.

      терабайтные кучи

      16 ТБ - это ограничение сверху, а не минимальное требование. Можете спокойно использовать ZGC и с мелкими кучами.

      использование процессорного механизма виртуальной памяти

      Виртуализацию памяти используют все прикладные процессы, на каком бы языке вы их не написали. Так работают современные компьютеры. Не очень понимаю, в чем у вас тут претензия к Java.

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

      Микросервисы - это про маленькие зоны ответственноси, а не про маленькие объемы оперативной памяти.

      Java идет своим особым путем

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