Сборщик мусора: поколения, LOH, режимы GC

↑ 2.2 .NET runtime: CLR, память, GC · ← Предыдущая · → Следующая

Обязательно~5 мин чтенияУровень: middle

Зачем это на собесе

GC — главный вопрос про .NET runtime на middle+/senior собесе: поколения, LOH, режимы Workstation/Server, паузы, влияние на латентность API и память контейнера.

Объяснение

Как работает GC

.NET использует трассирующий сборщик с поколениями и уплотнением (compacting):

  1. Mark — от корней (стеки потоков, статические поля, регистры, GC handles) помечаются достижимые объекты.
  2. Sweep/Plan — определяются мёртвые объекты.
  3. Compact — живые объекты сдвигаются, чтобы убрать фрагментацию, ссылки обновляются. Выделение памяти очень дешёвое: указатель просто сдвигается в текущем сегменте (bump allocation).

Поколения

Гипотеза: большинство объектов умирают молодыми.

  • Gen 0 — новые объекты. Собирается чаще всего и быстро (мало живых).
  • Gen 1 — пережившие одну сборку, буфер между молодыми и старыми.
  • Gen 2 — долгоживущие (синглтоны, кэши, статические данные). Полная сборка дорогая. Выжившие при сборке объекты продвигаются в следующее поколение. Сборка Gen N включает все младшие поколения.

LOH и POH

  • Large Object Heap — объекты ≥ 85 000 байт (большие массивы, строки, MemoryStream с большими буферами). Логически относится к Gen 2, по умолчанию не уплотняется → фрагментация. Можно уплотнить разово: GCSettings.LargeObjectHeapCompactionMode = CompactOnce.
  • Pinned Object Heap (.NET 5+) — для закреплённых объектов (буферы для I/O), чтобы они не мешали уплотнению.

Режимы GC

  • Workstation — одна куча, оптимизирован под отзывчивость UI. По умолчанию для консольных и десктопных приложений.
  • Server — отдельная куча и поток GC на каждое ядро, выше пропускная способность, больше памяти. По умолчанию для ASP.NET Core.
  • Concurrent/Background GC — сборка Gen 2 идёт параллельно с работой приложения, паузы короче.
  • DATAS (Dynamic Adaptation To Application Sizes, по умолчанию с .NET 9 для Server GC) — динамически подстраивает число куч под нагрузку: меньше памяти в контейнерах.
<PropertyGroup>
  <ServerGarbageCollector>true</ServerGarbageCollector>
  <ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>

Или переменные: DOTNET_gcServer=1, DOTNET_GCHeapHardLimit, DOTNET_GCHeapHardLimitPercent=75.

Паузы

Во время части фаз GC потоки приложения останавливаются (stop-the-world). Для API это рост p99 латентности. Паузы тем длиннее, чем больше живых объектов и чем больше Gen 2.

GC в контейнерах

  • .NET учитывает cgroup-лимиты: лимит кучи по умолчанию ~75% лимита памяти контейнера.
  • Server GC на машине с многими ядрами, но с небольшим лимитом памяти может резервировать много: DATAS или Workstation для маленьких сервисов.

Как снизить нагрузку на GC

  • Меньше аллокаций в горячих путях: Span, ArrayPool, struct для маленьких значений, StringBuilder, избегать боксинга и LINQ в самых горячих циклах.
  • Не создавать большие временные массивы (LOH): потоковая обработка, пулы (RecyclableMemoryStream).
  • Не держать ненужные ссылки в долгоживущих объектах (кэши без ограничения размера, статические списки).
  • GC.Collect() вызывать не нужно: почти всегда вредно.

Диагностика

dotnet-counters monitor -p <pid> --counters System.Runtime
# gc-heap-size, gen-0/1/2-gc-count, time-in-gc, alloc-rate, loh-size
dotnet-gcdump collect -p <pid>     # снимок кучи для анализа в VS или PerfView

В коде: GC.GetGCMemoryInfo(), GC.CollectionCount(0), метрики System.Runtime через OpenTelemetry.

Нюансы и подводные камни

  • Высокий % time in GC (больше 10–20%) и частые Gen 2 — признак проблем с аллокациями или утечкой.
  • GC.Collect() в коде продвигает объекты в старшие поколения и ухудшает ситуацию.
  • Финализаторы откладывают освобождение объектов на одну сборку и нагружают поток финализации.
  • Много закреплённых (pinned) объектов мешает уплотнению.

Вопросы с ответами

Связанные темы