Сборка мусора и поколения

Один объект, три вызова GC.Collect() — и почти никто на собеседовании не называет все четыре строки вывода правильно.

Сборщик мусора (GC) в .NET — поколенческий: он делит объекты на три возрастные группы (0, 1, 2) и почти всегда осматривает только самую молодую, потому что большинство объектов умирает молодыми.

Начнём с вопроса, который любят задавать на позиции middle. Что напечатает эта программа? Не читайте дальше, пока не ответите себе — а потом нажмите «Запустить».

using System;

class Program
{
    static void Main()
    {
        object data = new object();
        Console.WriteLine($"Сразу после создания: поколение {GC.GetGeneration(data)}");

        GC.Collect();
        Console.WriteLine($"После 1-й сборки:     поколение {GC.GetGeneration(data)}");

        GC.Collect();
        Console.WriteLine($"После 2-й сборки:     поколение {GC.GetGeneration(data)}");

        GC.Collect();
        Console.WriteLine($"После 3-й сборки:     поколение {GC.GetGeneration(data)}");
    }
}

Результат:

Сразу после создания: поколение 0
После 1-й сборки:     поколение 1
После 2-й сборки:     поколение 2
После 3-й сборки:     поколение 2

Почему вывод именно такой

Новый объект всегда рождается в поколении 0 — это «детский сад» кучи. Переменная data живёт до конца Main, значит на каждой сборке объект достижим и переживает её. А правило простое: пережил сборку — повысился на поколение. Ноль превращается в единицу, единица — в двойку. Дальше расти некуда: поколение 2 — последнее, объект остаётся в нём навсегда.

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

Как это работает под капотом

Почему GC вообще получается быстрым

Обычно «сборка мусора» звучит как что-то дорогое, но в .NET дешевле оказывается даже выделение памяти. Молодые объекты складываются в кучу подряд, один за другим, а аллокация — это просто сдвиг указателя на конец занятой области (bump allocation). Никакого поиска свободного блока, как в malloc.

Сборка поколения 0 (её называют ephemeral, эфемерной) обходит не всю кучу, а только корни (roots): статические поля, локальные переменные на стеках потоков, регистры, дескрипторы GC. От корней помечаются живые объекты, выжившие копируются («компактятся») в начало области поколения 1, а вся остальная память объявляется свободной одним махом. Ключевой момент: стоимость сборки пропорциональна количеству выживших объектов, а не количеству мусора. Если из тысячи созданных объектов умерли 990 — сборка почти бесплатна.

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

Large Object Heap

Есть отдельная куча для крупных объектов — LOH (Large Object Heap). Порог — 85 000 байт: всё, что больше, попадает туда сразу и сразу учитывается как поколение 2. Проверим:

using System;

class Program
{
    static void Main()
    {
        byte[] small = new byte[84000];
        byte[] big = new byte[85000];

        Console.WriteLine($"Массив 84 000 байт: поколение {GC.GetGeneration(small)}");
        Console.WriteLine($"Массив 85 000 байт: поколение {GC.GetGeneration(big)}");
    }
}

Результат:

Массив 84 000 байт: поколение 0
Массив 85 000 байт: поколение 2

Разница в тысячу байт — и объект живёт в совсем другом мире. LOH по умолчанию не компактится: копировать мегабайтные массивы слишком дорого, поэтому вместо перемещения GC ведёт список свободных дыр. Результат — фрагментация: памяти формально хватает, но непрерывного куска под новый массив нет, и процесс растёт. Классическая жертва — код, который в цикле создаёт большие byte[] под файлы или изображения. Лечение: переиспользовать буферы (ArrayPool<byte>), а в крайнем случае — разово попросить уплотнение через GCSettings.LargeObjectHeapCompactionMode.

Утечка памяти в управляемом коде: подписки на события

Любимая ловушка интервьюеров: «В .NET есть GC — значит утечек памяти не бывает?» Бывают. GC освобождает только недостижимые объекты. Если на объект тянется ссылка, которую вы забыли, — он бессмертен.

Самый частый источник таких ссылок — события. Когда вы пишете publisher.Tick += subscriber.OnTick, издатель получает делегат, а делегат хранит ссылку на subscriber (его поле Target). Ссылка идёт от издателя к подписчику, а не наоборот — это и удивляет. Долгоживущий издатель удерживает толпу «мёртвых» подписчиков.

using System;

class Publisher
{
    public event EventHandler Tick;
    public void Raise() => Tick?.Invoke(this, EventArgs.Empty);
}

class Subscriber : IDisposable
{
    private readonly Publisher _publisher;

    public Subscriber(Publisher publisher)
    {
        _publisher = publisher;
        _publisher.Tick += OnTick;
    }

    private void OnTick(object sender, EventArgs e) { }

    public void Dispose() => _publisher.Tick -= OnTick;
}

class Program
{
    static WeakReference CreateSubscriber(Publisher publisher, bool unsubscribe)
    {
        Subscriber s = new Subscriber(publisher);
        if (unsubscribe) s.Dispose();
        return new WeakReference(s);
    }

    static void Main()
    {
        Publisher publisher = new Publisher();

        WeakReference leaked = CreateSubscriber(publisher, unsubscribe: false);
        WeakReference clean = CreateSubscriber(publisher, unsubscribe: true);

        GC.Collect();
        GC.WaitForPendingFinalizers();
        GC.Collect();

        Console.WriteLine($"Подписчик БЕЗ отписки жив: {leaked.IsAlive}");
        Console.WriteLine($"Подписчик С отпиской жив:  {clean.IsAlive}");

        publisher.Raise();
        GC.KeepAlive(publisher);
    }
}

Результат:

Подписчик БЕЗ отписки жив: True
Подписчик С отпиской жив:  False

WeakReference — слабая ссылка, она не мешает сборке. Первый подписчик недостижим из нашего кода, мы его «выбросили» — и всё равно он жив, потому что на него смотрит делегат внутри издателя. Второй отписался и честно собран.

В реальных приложениях это выглядит так: долгоживущий сервис (шина сообщений, таймер, INotifyPropertyChanged-модель) держит цепочку из тысяч давно закрытых форм, view-моделей или контроллеров. Память растёт, дамп показывает гигабайты «мусора», который на самом деле достижим.

Частые ошибки на собеседовании

  • «GC.Collect() надо вызывать, чтобы освободить память». Наоборот: ручной вызов ломает эвристики, форсирует дорогую полную сборку и почти всегда делает хуже. Единственные оправданные случаи — бенчмарки, диагностика и редкие сценарии вроде «только что закрыли огромный документ».
  • «Утечек в .NET не бывает». Бывают: события, статические коллекции и кеши, незакрытые таймеры, захваченные лямбдами this, недоосвобождённые неуправляемые дескрипторы.
  • Путать поколения с приоритетом. Поколение — это не «важность», а возраст: сколько сборок объект пережил.
  • Забывать про LOH. На вопрос «почему приложение съело 4 ГБ, хотя живых объектов на 200 МБ» правильный ответ часто именно «фрагментация LOH».
  • «Финализатор освободит память». Финализатор не освобождает память — он лишь даёт последний шанс закрыть неуправляемый ресурс, причём ценой лишней сборки (об этом — следующий урок).

Итоги-шпаргалка

  • Объекты рождаются в поколении 0; пережил сборку — переехал в следующее поколение; выше 2 подниматься некуда.
  • Сборка gen 0 быстрая, потому что её цена пропорциональна числу выживших, а не объёму мусора; аллокация — сдвиг указателя.
  • Объекты крупнее 85 000 байт идут в LOH, считаются поколением 2 и по умолчанию не уплотняются → фрагментация. Спасение — ArrayPool<byte> и переиспользование буферов.
  • Утечка в управляемом коде = случайно сохранённая достижимость. Топ-1 причина — подписка на событие без отписки: ссылка идёт от издателя к подписчику.
  • Правило хорошего тона: кто подписался — тот и отписывается, чаще всего в Dispose().
Проверьте себя
1. Объект пережил один полный вызов GC.Collect(). В каком поколении он окажется?
AОстанется в 0 — поколение меняется только при явной настройке GC
BПерейдёт в поколение 1
CСразу перейдёт в поколение 2
DПереедет в Large Object Heap
2. Почему подписка на событие долгоживущего объекта может привести к утечке памяти?
AДелегат внутри издателя хранит ссылку на подписчика, поэтому издатель удерживает его достижимым
BВсе объекты с событиями попадают в Large Object Heap
CСборщик мусора не умеет собирать объекты, у которых есть события
DПодписчик автоматически становится статическим объектом