В статье представлена переработанная текстовая версия доклада, прочитанного на JPoint 2026 с некоторыми изменениями и дополнениями.
Речь пойдет об изменениях, которые произошли в сборщике мусора G1 в версиях JDK с 17 по 25, а также дано краткое введение в то, как сборщик мусора устроен.
Начало истории
Сборка мусора как программная концепция появилась в языке Lisp примерно в 1958–1960 году. Джон МакКарти, будучи математиком, в работе по истории языка писал:
Рекурсивное определение дифференцирования не предусматривало удаления отброшенных списочных структур. На тот момент не просматривалось никакого решения, но идея усложнить элегантное дифференцирование явным удалением была непривлекательной. Излишне говорить, что суть упражнения заключалась не в самой программе дифференцирования, а в прояснении операций, участвующих в символьных вычислениях.
То есть, будучи математиком, идея усложнять красивый алгоритм стиранием отброшенных элементов списка показалась ему некрасивой! Причем сам термин родился как шутка.
В документе Recursive Functions of Symbolic Expressions and Their Computation by Machine, Part I 1960 года МакКарти пишет, что «запускается цикл очистки», а в примечании к документу (на стр. 27) пишет, что «Мы уже называем это сборка мусора, но я струсил использовать такой термин в статьях, иначе дамы‑редакторы Исследовательской лаборатории электроники не пропустили бы»
Но термин прижился и теперь мы его используем.
Чуть более подробно можете посмотреть в моей статье о языке Lisp.
Первое упоминание G1
Сборщик G1 был представлен в июне 2009 года в Java версии 6. Произошло это на конференции JavaOne. На этой же конференции, кстати, активно обсуждалось слияние фирм Sun и Oracle.

Сборщик мусора в составе Java 6 был, как это и полагается, сначала экспериментальным, включался специальным параметром командной строки ‑XX:+UnlockExperimentalVMOptions ‑XX:+UseG1GC.
С самого начала G1 создавался для серверного использования на мультипроцессорных системах с большим объемом памяти, и, что важно, для достижения с высокой вероятностью целевых показателей мягкого реального времени. При этом должна была обеспечиваться высокая пропускная способность приложений.
Перечень целей, которые ставились при разработке G1:
Операции над всей кучей, должны выполняться конкурентно (одновременно) с мутаторами (то есть потоками, модифицирующими данные в куче), без пауз, пропорциональных размеру кучи или размеру живых данных. Конкурентная разметка (marking) обеспечивает полноту сборки и готовит регионы к очистке посредством эвакуации.
Уже при проектировании сборщика мусора задачи ставились весьма амбициозные.
И, как мы теперь знаем, цели эти были успешно достигнуты, а задачи выполнены.
Название G1 означает Garbage First, то есть «сначала мусор». Сам термин, в свою очередь, появился в работе Garbage‑First Garbage Collection 2004 года, которую выпустили сотрудники фирмы Sun David Detlefs, Christine Flood, Steve Heller, Tony Printezis.

Означает этот термин такую архитектуру, при которой идентификация удаленных объектов происходит максимально быстро и эффективно, поэтому для обработки выбираются регионы с наибольшим количеством мусора.
В основе сборщика мусора лежит много идей и работ. Я выделил 3 самых, на мой взгляд, существенных:
A Generational Mostly‑concurrent Garbage Collector DOI:10.1145/362422.362480.
Real‑Time Garbage Collection on General‑Purpose Machines DOI:10.1016/0164-1212(90)90084-Y.
Generation Scavenging: A Non‑disruptive High Performance Storage Reclamation Algorithm DOI:10.1145/800020.808261.
Работы над сборкой мусора в то время — а это 80-е годы прошлого века — преимущественно велись в языках Lisp и Smalltalk.
Собственно Java во многом унаследовала свойства этих языков, во всяком случае, в устройстве рантайма. Приведенные здесь работы заложили принципиальные свойства сборщика мусора:
поколенческий
с контролируемой паузой
конкурентный
Принципиальное устройство сборщика G1
Чтобы понять устройство сборщика, погрузимся в его архитектуру.
Иллюстративный материал подобран для облегчения понимания и намеренно несколько упрощен.
Регионы
У операционной системы запрашивается память под кучу Java.

Тут в целом ничего необычного, так многие системы делают.
Кстати говоря, сборщик использует не только память кучи, но и еще расходует нативную память, иногда в коде она называется память ОС или память C.
Доступная куча программы разбивается на регионы равного размера.

Регион — это непрерывный блок памяти. Аллокация в регионе производится сдвигом указателя, отделяющего занятую и незанятую области памяти.

Один регион выбирается как текущий регион. Мутаторы могут выделять память только в текущем регионе, причем делают они это не как угодно, а делают это в thread‑local allocation buffer, TLAB, выделенный с помощью CAS‑операций. Аллокация производится уже внутри TLABов для минимизации конкуренции за аллокацию.
Заполнили регион, переходим к следующему.
Объекты размером больше половины размера региона считаются огромными и аллоцируются в специально выделенной связанной последовательности регионов.
В исходном документе 2004 года есть сноска, что работа с огромными объектами не рассматривается.
Remembered set
В процессе работы сборщика производится маркировка достижимых (или «живых») объектов в куче. Для того, чтобы исключить долгую разметку, разработчики сборщика применили очень элегантное решение.
Регион поделили на блоки по 512 байт, назвали это картой, каждой группе в 64 байта сопоставили один бит в битовой карте (в байте).

Установленный бит в этой карте означает, что в этих 64 байтах есть указатель на объект. Входящий указатель внутри региона, или, говоря по‑другому, начало «живого» объекта.
В работе 2004 года таблица карт хранилась как хеш-таблица. Для обеспечения параллелизма каждый регион содержит столько копий этой таблицы, сколько есть потоков сборщика.
Эта конструкция называется «remembered set», она позволяет не анализировать всю кучу, как это делают сборщики другого типа, а исследовать только занятые места в регионах.
Запись в кучу требует обновления remembered set, поэтому ставится барьер на запись и модификация ссылочных полей объекта приводит к обязательному изменению remembered set.
Объекты выделяются в регионе, и для «живых» объектов в области действия карточки делается пометка в remembered set.

Это сделано для того, чтобы помечать те места в куче, где необходимо производить сканирование на предмет «живых» объектов. Это позволяет сохранять баланс между тем, сколько информации нужно хранить и как быстро ее можно обработать. Речь о маркировке мусора, разумеется.
Теперь, когда объект удаляется, то информация в remembered set показывает, что тут надо посмотреть, поскольку в этой области был объект.

Соответственно, нам не надо все объекты пересматривать, достаточно только взглянуть на объекты, находящиеся в области, покрываемые этой карточкой.
Если мы показали, что объект недостижим, то при отсутствии других объектов в этой карточке, мы можем снять пометку в remembered set.

То же самое происходит, если объект переносится в другой регион, а это происходит, когда сборщик мусора перераспределяет объекты в регионах, откуда были удалены другие объекты, группируя данные.
Последним шагом объект очищается.
Это — принципиальная схема. Деталей там чуть больше, разумеется. Но структурно работа выглядит именно так.
Слабая генерационная гипотеза
Сборщик мусора G1 строится на основе гипотезы, что большая часть объектов уничтожается практически сразу после создания.

Опыт эксплуатации и анализ логов показывают, что для Java гипотеза подтверждается где‑то примерно в 70–80% случаев.
То есть большая часть объектов уничтожается (то есть перестает использоваться, если сказать точнее) почти сразу, а потому этот этап должен быть очень дешевым.
Эта гипотеза была предложена в одной из научных работ (в работе 3) и она же повлияла на то, что сборка состоит из двух крупных этапов, обработки поколения Young и обработки поколения Old.
Цикл сборки
Теперь перейдем к тому, что в документации называется цикл сборки мусора. Действия по сборке повторяются многократно, поэтому можно говорить о цикле.
Работа в поколении Young очень дешевая. В документации она называется «обычная» сборка. Объекты создаются в регионах, принадлежащих молодому поколению (поколению Young), и практически тут же уничтожаются. Выжившие объекты на этом этапе спустя какое‑то время переносятся в регионы поколения Old, то есть старые.
Этот процесс повторяется многократно, пока заполнение кучи не дойдет до определенного порога. На слайде это первые 4 кружочка.

После порога начинается процесс маркировки. В G1 эта сборка называется Concurrent Start. Определяются доступные объекты в поколении Old. Пока этот процесс идет, параллельно с ним продолжает работу нормальная сборка, то есть работа с поколением Young.

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

Во время этой паузы заканчивается маркировка, очищаются полностью свободные регионы, удаляются определения классов, а также подсчитывается информация о предстоящем удалении в фазе Space Reclamation.
Расчеты заканчиваются паузой Cleanup, которая уже получает точные данные о том, что будет высвобождено и будет ли вообще высвобождено.

Если принимается решение о том, что будет высвобождаться память в регионах поколения Old, то выполняется еще один раз сборка, называемая Prepare Mixed. На этом фаза Young Only заканчивается.
Но, возможен и возврат к обычной сборке.
После этого сборщик переходит к так называемый. Mixed сборке. В этом режиме производится и сборка в молодом поколении, и эвакуация регионов в поколении Old.

И, наконец, если случилось так, что памяти все еще недостаточно, мир останавливается и производится Full GC с упаковкой кучи. Это делается также, как и в других сборщиках.

Затем процесс повторяется.
Теперь перейдем непосредственно к доработкам самого сборщика.
Доработки сборщика
Доработок много, часть из них косметические, часть связаны с эргономикой использования (параметры, настройки и т.д.), а часть технические (изменение внутренних структур, подготовка, оптимизация). Рассматривать все не хватит никакой статьи, выделим лишь интересные.
JDK 17
JDK-8257774
В JDK-8257774 изменена работа с огромными объектами. Огромные объекты обрабатываются по отдельному сценарию, к ним сборщик ходит реже.

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

В рамках этой доработки добавили параметр G1AllowPreventiveGC.
При включении этого параметра перед аллокацией производится анализ состояния кучи и оценивается ожидаемое свободное место после проведения сборки. И если места под ожидаемое выделение недостаточно, запускается сборка, и уже после неё производится аллокация. Результатом стало отсутствие ООМ, когда часто выделяются огромные объекты.
JDK-8266489
На Windows платформах JDK-8266489 исправляет забавную ошибку.

Если на Windows используются страницы размером 2 Мб и сборщик использует регионы размером больше 2 Мб, то куча не выравнивается до размера страницы, что могло приводить к проблемам производительности.

Теперь в подобной ситуации куча выровнена до 2 Мб, что положительно повлияло на все показатели. В этой доработке есть интересная эвристика. На аллокацию блока памяти требуемого размера с выравниванием отводится 20 попыток. Причина такого поведения — система пытается выделить память в надежде, что за эти 20 попыток соседние потоки могут тоже попытаться выделить в этой области адресного пространства.
JDK-8262068
Иногда случается, что в регионе очень много живых объектов. Если их количество выше некоторого порога, то стоимость эвакуации в сравнении с полученной памятью оказывается или минимальна, или отрицательна. Это критично для FullGC. Для таких ситуаций внесли изменение JDK-8262068 в сборщик, учитывающий количество «живых» объектов.

Теперь сборщик использует параметр MarkSweepDeadRatio в формуле 100 — MarkSweepDeadRatio. На этапе маркировки производится подсчет количества живых объектов, эта цифра используется как оценка потенциальной выгоды от эвакуации. Если заполненность региона 95% (по умолчанию) и выше, тогда эвакуация не осуществляется.
Любопытно, как это было сделано. С расчетами в целом понятно, тем более, что они были ранее сделаны в другой доработке JDK-8263495.
А вот исключается регион из обработки также, как если бы это был pinned регион. То есть, реализация в основном свелась к правильным расчетам и установке свойств региона.
На тестах в ситуации заполнения регионов на 95 процентов FullGC стало проходить на 10–12% быстрее.
JDK 18
JDK-8017163
Обработка remembered set в JDK-8017163 переосмыслена.
Работа с памятью remembered set заметно переработана, что привело к уменьшение потребления памяти почти на 25 процентов. Производительность не упала или даже стала лучше.
Отдельные задействованные стадии показывают в целом меньшее время работы.
Кроме того, модификация remembered set требует глобальной блокировки. Хотя это не влияет напрямую на работу приложения, могли возникать задержки с получением этой блокировки на сотни секунд. Речь идет о потоках, которые занимаются слиянием частей remembered set, называемых refinement buffers. Но это все равно могло влиять на бюджет паузы сборки мусора.
JDK-8275056
До изменения JDK-8275056 размер карты составлял 512 байт. Для индексации карт G1 использовал двухбайтные индексы. То есть, перемножая, мы получаем, что размер региона ограничен сверху размером 32 Мб. Это при условии, что отображение карточки на множество remembered set выполняется 1:1.

Однако, с ростом размера кучи, а также с разрастанием приложений, работающих на Java, разработчики столкнулись с тем, что при работе с огромными объектами, при частой аллокации больших объектов, возникает фрагментация кучи в регионах с огромными объектами со всеми вытекающими последствиями.
С изменениями, приведенными в предыдущем разделе, в принципе ограничение на размер региона может быть снято. Особенность реализации множества remembered set такова, что размер может быть произвольным.
Однако, на момент релиза, было принято решение, что максимальный размер региона, используемый для расчетов кучи, если размер региона не указан, останется прежним, 32 Мб. Это было сделано из соображений, что все те, кто занимается эксплуатацией и производительностью, накопили достаточно опыта, как работать с такими регионами, бороться с фрагментацией и так далее.
Предлагается менять размер региона тогда, когда другие способы оптимизации уже испробованы и использованы.
Параметр, о котором идет речь:‑XX:G1HeapRegionSize.
JDK-8254739
Сборщик мусора на этом этапе (JDK-18) умеет подшивать (pin) регионы. То есть помечать регионы особым образом. Подшивка заставляет сборщик обходить регионы стороной.
Так, например, делается, для огромных объектов, их регионы просто выключаются из обработки сборщиком до прохождения Full GC. Кроме того, подшивка регионов также используется для CDS архивов. Все это — Old регионы.
Но что будет, если мы научим GC подшивать регионы (JDK-8254739), предназначенные для поколения Young? Зачем это может быть надо? Все дело в JNI.

Этот механизм согласно спецификации при работе с кучей Java умеет останавливать GC (GetPrimitiveArrayCritical(), например). Сначала это делали чтобы сборщик не мог навредить, пока работает код на C/C++.
Но это привело к проблемам производительности. Поэтому хотелось иметь возможность не останавливать GC.
Было много предложений, как это можно достичь, но основная проблема, с которой пришлось столкнуться — это необходимость подшить одиночный объект.
Все дело в том, что блокировка одного объекта приводит и к блокировке объекта, и к блокировке региона, а также ломает инварианты сборщика, ожидающего связанные области памяти.
В принципе, основу для того, чтобы эту задачу решить, уже в сборщик заложили. Это механизм обработки ошибок эвакуации. Объекты, которые не удалось эвакуировать, особым образом помечаются и в конце паузы сборки обрабатываются отдельно.
Проблема в том, что этот механизм дорогой и спроектирован исходя из предположения, что ошибки эвакуации случаются нечасто.
И вот, в JDK-18 меняют статус JEP-423.
В рамках этого JEP сделано так, что G1 научился подшивать произвольные регионы в рамках фаз сборки Young only и Space Reclamation:
Подсчитывается количество критических объектов в каждом регионе. Счетчик увеличивается, если появляется критический объект, и уменьшается при удалении, соответственно. Если счетчик равен 0, регион обрабатывается обычным образом, если не 0, тогда регион считается подшитым.
Во время фазы Space Reclamation сборки подшитые регионы пропускаются.
В фазе Young сборки, подшитые регионы в молодом поколении считаются failed evacuation, поэтому переводятся в Old. В Old подшитые регионы на этой фазе не трогаются.
Такая схема позволила вывести критические объекты (а по факту регионы, где лежат критические объекты) из под действия сборщика, тем самым сделав возможным работу без остановки сборки.
JDK-8278824
Любопытное наблюдение: в некоторых PR большое объяснение, но реализация — исправление одного или нескольких символов. Описание бага — несколько страниц текста и логов, а фикс (JDK-8278824) — вот:
-return 1u << (log_region_size / 2 - 7); +return 1u << (log_region_size / 2 - 4);

Стала увеличиваться пауза при сборке мусора.
В конечном итоге оказалось, что проблема в распределении работы между потоками. Во время сканирования remembered set поток мог затребовать все карточки региона, и в какой‑то момент началась конкуренция между потоками.
Решением стало изменение эвристики, по которым производится раздача карточек потокам. С этим изменением отдельному потоку выделяется большее количество карточек, поэтому не возникает конкуренции за карточки и remembered set.
JDK 19
В JDK 19 было много доработок, но в G1 были только доработки технического характера, существенно не повлиявшие на структуру сборщика, поэтому опустим.
JDK 20
JDK-8210708
Чтобы понять, что объект был удален, необходимо иметь понимание состояния кучи до и после удаления. Это достигалось тем, что фактически сборщик содержал 2 копии множества remembered set, и сравнивал их между собой.

В оригинальной работе 2004 года ситуация была еще хуже, там копии remembered set были еще и в каждом потоке сборщика.
Так вот в JDK 20 этот механизм изменили (JDK-8210708) в пользу tri‑color разметки кучи. Это высвободило примерно 1.5% размера кучи.
Что это такое и как оно работает вы можете посмотреть в докладе Александра Ланцова.
JDK-8256265
Продолжается работа над region pinning. До этой доработки (JDK-8256265) в случае появление evacuation failure сборщик назначал отдельный поток, один на регион. Это приводило к тому, что могла возникнуть конкуренция за потоки, разбирающие evacuation failures.

Теперь сделано так, что поток может обрабатывать регионы группами, что улучшает утилизацию потоков.
JDK-8288966
Еще одно любопытное изменение. Во время паузы при эвакуации выживших объектов аллокация осуществляется не напрямую в регионе, а в отдельном буфере, называемом PLAB (Promotion Local Allocation Buffer).
Это сделано для локализации расположения объектов и чтобы уменьшить конкуренцию потоков. Размер этой области рассчитывается на основе количества аллокаций перед уходом в паузу, соответственно, размер этой области может и увеличиваться, и уменьшаться. В некоторых ситуациях возможен взрывной размера этого буфера вместе с увеличившейся аллокацией. Эта доработка (JDK-8288966) меняет параметры расчета размера PLAB для предотвращения «вылетов».
JDK-8293861
В начале статьи я рассказывал про JDK 17, где для огромных объектов добавили отдельный сценарий обработки.

В JDK 20 этот механизм отключили. Он все еще доступен, но посчитали, что фактическая реализация — это отдельный GC с автоматическим подсчетом ссылок.
Отключен механизм, в пользу предиктивной сборки, основанной на количестве объектов в поколении Young. В полной мере эта функциональность раскрывается в JDK 21.
JDK 21
В JDK 21 крупных изменений в сборщике было немного. Выделим пару.
JDK-8191565
Последним шагом перед тем, как выдать OOM, во время Full GC сборщик теперь (JDK-8191565) может переносить огромные объекты.

Мы знаем, что предыдущие версии сборщика не трогали огромные объекты, считая их неподвижными. Сейчас же для борьбы с фрагментацией регионов делается попытка переноса огромных объектов.
В сам сборщик добавлена отдельная фаза «Phase 4: Humonguous Compaction».
На картинке представлена ситуация, когда потребовалось место под объект размером около 4 регионов, место вроде есть, но оно было несвязанное.
JDK-8302122
Подготовка и удаление потоков, включающие в себя расчет нового размера TLAB (ResizeTLAB), сброс логов и подготовка к уничтожению потока занимают ощутимое время, 3–7% паузы сборки. Когда в системе много потоков, то пауза становится дороже.
Доработка JDK-8302122 распараллеливает эту работу, создавая задачи с упомянутыми шагами, по 250 потоков на один worker в ситуации, когда в системе больше 10 000 потоков.
JDK 22
JDK-8315503
Довольно интересная доработка (JDK-8315503). Если есть регионы с кодом, в котором много «рутов», то есть достижимых ссылок, ссылающиеся на один регион или на небольшое количество регионов, то может возникнуть узкое место из‑за того, что «руты» сканируются одним потоком на регион.

Это изменили, теперь сканирование «рутов» выполняется группой потоков для расшивки узкого места. Даже в рамках одного региона.
JDK 23
Изменения в JDK 23 в основном связаны с эргономикой использования. Падение VM при нехватке памяти в стеке маркировки заменено на сообщение об ошибке и аккуратную остановку. Изменена балансировка слияния логов на большом количестве потоков.
Интереснее другая доработка: JDK-8319548, в ней изменено название класса-заполнителя пустого места в куче. Ранее это был класс FillerArray, его переименовали в FillerElement, что в целом, логично.
JDK 24
JDK-8343189
Сборщик мусора практически с самого своего создания содержал некоторое количество эвристик. Они подобраны так, чтобы сборщик вписывался в бюджет паузы даже на старте своей работы. Позже, когда уже сборщик (как и вся система) разогреется, куча заполнится, будет накоплена статистика использования и сборщик подстроится под нагрузку системы.
Эти эвристики таковы, что сборщик может потратить до 30 циклов сборки для получения адекватных настроек параметров.
Сейчас же сборщик изменен (JDK-8343189) так, чтобы настраивать свои параметры сильно раньше, практически с первой сборки. Ценой некоторого увеличения паузы первого цикла сборки.
JDK-8336086
Еще одно изменение (JDK-8336086) из серии что «опять переделали».

Для remembered set было требование много релизов подряд, что один регион содержит одно множество. Так вот, эти множества теперь объединили в одно для всех регионов поколения Young. Это позволило немного сэкономить на управляющих структурах и высвободило примерно 1 мегабайт на гигабайт кучи.
JDK 25
JDK-8351405
Когда сборщик мусора останавливается на стадии Remark, производится перепроверка объектов, которые могли измениться от начала конкурентной маркировки, далее производится некоторая уборка — очищается память полностью свободных регионов, выгружаются классы и очищаются внутренние структуры.
Дальше мы переходим на стадию Cleanup, на которой производится оценка потенциального объема, который нужно высвободить. Если на этой стадии система видит, что памяти достаточно, переход в Space Reclamation не происходит.

И вот тут обнаружилось тонкое место. Оценка производится по ожидаемой выгоде от высвобождения памяти. То есть формируется набор регионов, Collection Set, в котором регионы отсортированы по объему живых данных в регионе и размере remembered set для этого региона. Предпочтение отдается регионам с малым количеством живых данных и маленьким объемом remembered set. Но проблема в том, что remembered set будет пересчитана сразу после этого шага! Как сборщик решал эту проблему? Исходил из предположения, что объем remembered set и объем живых данных пропорциональны.
Но это бывает не так. В подавляющем большинстве случаев это не приводило к проблемам, но нашлись примеры, когда регионы с таким перекосом в размерах приводили резкому росту паузы, поскольку эти регионы попадали в mixed сборку и уже там обрабатывались, и это медленнее. Исправлено в JDK-8351405.
Заключение
Конечно, доработок в сборщике за этот период было значительно больше, чем показано в статье. Хотелось подсветить, что сборщик развивается, в нем происходят изменения, это очень живой код. При этом, правильно выбранная архитектура, сильные идеи, заложенные в его основу, позволяют спокойно развивать продукт и отвечать на вызовы, которые ставит эксплуатация на миллионах различных платформ и устройств.
В большинстве задач вы можете спокойно начать с использования сборщика G1, поскольку он покрывает очень много сценариев, и если действительно упретесь в какие‑то проблемы, тогда уже может смотреть на другие сборщики. Больше того, с принятием JEP-248 и JEP-523, JVM всегда выбирает G1, если не указано иное.
По прошествии 15 лет мы всё ещё используем G1 как первый сборщик, встречающий нас в системе. Он не просто протестирован, он проверен в таких условиях, которых многие программы не достигнут никогда. В нем обрабатывается много исключений из того, что можно было бы назвать «обычным» положением вещей.
Хочется верить, что через 15 лет мы снова будем изучать, а что там нового появилось в G1 за это время.
