openjdk.ruOpenJDK на русском

JEP 522: G1 GC: Improve Throughput by Reducing Synchronization

G1 GC: повышение пропускной способности за счёт сокращения синхронизации

АвторIvan Walulya & Thomas Schatzl
ОтветственныйIvan Walulya
ТипFeature
ОбластьImplementation
СтатусClosed / Delivered
Выпуск26
Компонентhotspot / gc
Обсуждениеhotspot dash gc dash dev at openjdk dot org
ТрудоёмкостьM
ДлительностьM
РецензентыAlex Buckley, Vladimir Kozlov
ОдобренVladimir Kozlov
Создан2024/09/24 16:13
Обновлён2026/01/21 20:53
Задача8340827

Аннотация

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

Цели

  • Снизить накладные расходы сборщика мусора G1 на синхронизацию.

  • Уменьшить размер внедряемого кода барьеров записи G1.

  • Сохранить общую архитектуру G1 без изменений во взаимодействии с пользователем.

Что не является целью

  • Целью не является сравнять пропускную способность сборщика мусора G1 с пропускной способностью любого другого сборщика мусора в HotSpot JVM.

Мотивация

Сборщик Garbage-First (G1), сборщик мусора по умолчанию в HotSpot JVM, спроектирован так, чтобы соблюдать баланс между задержкой и пропускной способностью. Однако достижение этого баланса иногда может сказываться на производительности приложения по сравнению со сборщиками, ориентированными на пропускную способность, такими как сборщики Parallel и Serial.

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

Описание

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

Предпосылки

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

Чтобы найти ссылки, которые нужно обновить, G1 не сканирует всю кучу: это было бы неэффективно. Вместо этого G1 отслеживает ссылки на объекты между областями в структуре данных, называемой таблицей карт (card table), которая обновляется каждый раз, когда ссылка на объект записывается в поле. Эти обновления выполняют небольшие фрагменты кода, называемые барьерами записи (write barriers), которые G1 внедряет в приложение совместно с JIT. Во время паузы GC сборщику G1 достаточно просканировать таблицу карт, чтобы найти объекты, содержащие поля, которые требуют обновления.

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

Предложение

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

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

Производительность

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

Основной выигрыш даёт сокращение синхронизации между потоками приложения и потоками-оптимизаторами. В приложениях, интенсивно изменяющих поля со ссылками на объекты, мы наблюдали рост пропускной способности в диапазоне 5–15 %.

Некоторый дополнительный выигрыш даёт то, что барьеры записи стали проще и быстрее. Например, на x64 барьеры записи сократились примерно с 50 инструкций всего до 12. Благодаря этому мы наблюдали рост пропускной способности до 5 % даже в приложениях, которые не изменяют интенсивно поля со ссылками на объекты.

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

Потребление нативной памяти

Вторая таблица карт имеет ту же ёмкость, что и первая, и использует столько же дополнительной нативной памяти. Каждая таблица карт требует 0,2 % от ёмкости кучи Java, что соответствует дополнительным 2 МБ нативной памяти на каждый 1 ГБ ёмкости кучи Java.

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

Альтернативы

Ранее мы создали прототипы нескольких других подходов к повышению пропускной способности G1:

  • Использовать зависящие от операционной системы механизмы синхронизации для атомарной смены таблиц карт. Это требовало кода, специфичного для операционной системы или даже для её версии, что чрезвычайно усложняло код, не давая ожидаемого выигрыша. В данном предложении мы избежали этой проблемы, используя универсальные локальные для потоков handshake-операции (JEP 312).

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

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

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

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

Риски и допущения

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

Мы исходим из того, что нет необходимости давать пользователю дополнительные средства управления потоками оптимизации сверх уже существующих. Пользователь по-прежнему может полностью отключить потоки оптимизации с помощью -XX:-G1UseConcRefinement или ограничить их количество с помощью -XX:G1ConcRefinementThreads=<number>. Мы исходим из того, что эти два параметра по-прежнему покрывают все необходимые сценарии использования, которые иначе не обрабатываются лучше внутренними эвристиками.

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