awesome-everything EN
↑ Обратно к восхождению

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

Алгоритмы GC: поколенческая гипотеза, concurrent marking и write barrier

Суть Большинство объектов умирают молодыми — generational коллекторы используют это. Concurrent marking делает работу параллельно с приложением, но требует write barriers. Go, JVM, V8 и .NET реализуют эти идеи по-разному.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на middle-высоте — в небе
◷ 16 min

JVM-сервис имеет кучу 8 ГБ. 95% объектов живут менее 200 мс. Классический stop-the-world коллектор должен сканировать все 8 ГБ при каждом цикле. Generational коллектор сканирует только 256 МБ молодого поколения — и возвращает 95% мусора за ту же цену.

Mark-and-sweep: фундамент

Любой tracing GC следует одной схеме. Кратко паузит приложение, собирает корни GC: активные регистры CPU, стек каждого потока, глобальные переменные, JNI-дескрипторы. От корней обходит граф ссылок — каждый достижимый объект «живой»; всё недостижимое — «мусор». Маркирует живые объекты (переключает бит в заголовке или в побочной таблице). После маркировки выполняет sweep: проходит кучу, возвращает память немаркированных объектов в free-list.

Варианты компактизируют (перемещают живые объекты, устраняя фрагментацию) и могут использовать bump-pointer allocation для быстрых последующих аллокаций.

Наивный алгоритм — stop-the-world mark-and-sweep: пауза на всё, маркировка, очистка, возобновление. Полное время паузы растёт с размером кучи. Для кучи 32 ГБ это могут быть секунды — неприемлемо для latency-sensitive сервиса. Каждый современный GC — вариант, сокращающий эту паузу.

Поколенческая гипотеза

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

Generational коллекторы делят кучу на молодое поколение (small, собирается часто) и старое поколение (большое, собирается редко). Аллокации идут в молодое; выжившие «продвигаются» в старое после нескольких циклов.

ПоколениеРазмерЧастота сборкиТип цикла
Молодое (Young / Gen 0)256 МБ – 2 ГБЧасто (ms до секунды)Minor GC
Старое (Old / Gen 2)ГБ – десятки ГБРедко (секунды – минуты)Major / Full GC

Математика: если 95% объектов умирают в молодом поколении, minor GC возвращает 95% аллоцированных байт, пройдя только по (меньшей) молодой куче. Исключение: concurrent GC Go непоколенческий — единая куча, tri-color маркировка. Аргумент команды Go: простота рантайма и равномерный профиль пауз важнее выигрыша в throughput от поколенческого подхода.

Concurrent marking и write barriers

Чтобы добиться пауз ниже 10 мс на кучах в несколько ГБ, маркировка должна происходить пока приложение работает. Это concurrent marking: GC работает как параллельный поток, обходя граф объектов вместе с mutator-потоками (потоками приложения).

Проблема: приложение может изменять ссылки во время маркировки. Поэтому нужен write barrier — небольшой код, выполняемый при каждой записи ссылки в приложении, информирующий GC о новой ссылке, чтобы ничего не было пропущено.

Два подхода:

  • Snapshot-at-the-beginning (SATB): барьер маркирует старую ссылку, которую вот-вот перезапишут, обеспечивая её выживание в текущем цикле. Используется в G1, Shenandoah, ZGC, Go.
  • Incremental-update: барьер маркирует новую ссылку, записанную в поле черного объекта. Используется в CMS, классическом V8 mark-compact.

Стоимость: ~2–10% CPU на каждой записи ссылки — цена concurrent маркировки.

Обзор рантаймов

Go: concurrent tri-color непоколенческий mark-sweep без компактизации. Запускается по rate аллокаций; pacer удерживает STW-паузы ниже 1 мс. GOGC управляет целевым ростом кучи (по умолчанию 100% = удвоение); GOMEMLIMIT (добавлен в 1.19) — мягкий лимит памяти.

JVM: G1 — по умолчанию для большинства серверов, region-based, generational с concurrent marking, паузы 10–50 мс типично. ZGC (JEP 333 + 377) — субмиллисекундные паузы на кучах до 16 ТБ, colored pointers + load barriers. Shenandoah (Red Hat) — схожие цели с Brooks pointers.

V8: generational Scavenger для молодого (semi-space copying), Mark-Compact для старого. Проект Orinoco добавил concurrent marking + parallel compaction (с 2017 г.). Куча ограничена на изолят (~1,5 ГБ по умолчанию в Node, настраивается через --max-old-space-size).

.NET: workstation/server GC, generational (gen 0/1/2 + LOH), background concurrent marking; настраивается через GCSettings.LatencyMode.

CPython: reference counting (объект освобождается при refcount=0, без больших пауз) + cycle collector для ссылочных циклов через трассировку; GIL сериализует мутацию, но подсчёт ссылок стоит ~10–20% throughput.

Почему это работает

Go непоколенческий, потому что escape analysis переносит большую часть аллокаций в стек, а не в кучу. Короткоживущие локальные объекты часто не попадают в кучу вообще — поколенческий коллектор не помог бы там, где мусора нет. Всё, что попадает в кучу, Go обрабатывает единым concurrent mark-sweep.

Викторина

Почему generational GC собирает только молодое поколение в большинстве циклов?

Викторина

Для чего нужен write barrier при concurrent маркировке?

Викторина

Python-сервис однопоточный из-за GIL, но всё равно имеет GC-накладные расходы. Какова их основная причина?

Расставь шаги по порядку

Расставьте фазы concurrent GC цикла — от запуска до возобновления:

  1. 1 Начальная STW-пауза: сканирование корней (регистры, стеки)
  2. 2 Concurrent маркировка: GC-поток обходит граф объектов параллельно с приложением
  3. 3 Write barriers перехватывают изменения ссылок во время маркировки
  4. 4 Финальная STW-пауза: remark — фиксация изменений, сделанных во время concurrent фазы
  5. 5 Concurrent очистка: возврат памяти немаркированных объектов
  6. 6 Возобновление на полной скорости
Вспомните перед уходом
  1. 01
    Объясните поколенческую гипотезу и почему она определяет большинство production GC. Приведите один контрпример.
  2. 02
    Почему сокращение rate аллокаций — более долгосрочное решение, чем настройка параметров GC? Как измерить rate аллокаций в production?
Итог

Любой tracing GC следует схеме mark-sweep: паузит для сбора корней, маркирует достижимое, возвращает остальное. Generational коллекторы используют поколенческую гипотезу (большинство объектов умирают молодыми), собирая маленькое молодое поколение часто и дёшево. Concurrent marking выполняет большую часть маркировки параллельно с приложением; write barriers (~2-10% CPU) поддерживают корректность маркировки при изменении ссылок. Go непоколенческий по дизайну; JVM (G1/ZGC/Shenandoah), V8 (Orinoco), .NET и многие другие поколенческие. Зная архитектуру рантайма, понимаешь, почему определённые паттерны аллокаций дорогие и как их исправить.

Связанные уроки
встречается в159
Продолжить восхождение ↑GC tradeoffs: пауза, throughput, память и давление аллокаций
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources6
expand
  1. 01
  2. 02
  3. 03
  4. 04
  5. 05
  6. 06

Trademarks belong to their respective owners. Editorial reference only.