Сборка мусора и поколения
Один объект, три вызова 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
Подписчик С отпиской жив: FalseWeakReference — слабая ссылка, она не мешает сборке. Первый подписчик недостижим из нашего кода, мы его «выбросили» — и всё равно он жив, потому что на него смотрит делегат внутри издателя. Второй отписался и честно собран.
В реальных приложениях это выглядит так: долгоживущий сервис (шина сообщений, таймер, INotifyPropertyChanged-модель) держит цепочку из тысяч давно закрытых форм, view-моделей или контроллеров. Память растёт, дамп показывает гигабайты «мусора», который на самом деле достижим.
Частые ошибки на собеседовании
- «GC.Collect() надо вызывать, чтобы освободить память». Наоборот: ручной вызов ломает эвристики, форсирует дорогую полную сборку и почти всегда делает хуже. Единственные оправданные случаи — бенчмарки, диагностика и редкие сценарии вроде «только что закрыли огромный документ».
- «Утечек в .NET не бывает». Бывают: события, статические коллекции и кеши, незакрытые таймеры, захваченные лямбдами
this, недоосвобождённые неуправляемые дескрипторы. - Путать поколения с приоритетом. Поколение — это не «важность», а возраст: сколько сборок объект пережил.
- Забывать про LOH. На вопрос «почему приложение съело 4 ГБ, хотя живых объектов на 200 МБ» правильный ответ часто именно «фрагментация LOH».
- «Финализатор освободит память». Финализатор не освобождает память — он лишь даёт последний шанс закрыть неуправляемый ресурс, причём ценой лишней сборки (об этом — следующий урок).
Итоги-шпаргалка
- Объекты рождаются в поколении 0; пережил сборку — переехал в следующее поколение; выше 2 подниматься некуда.
- Сборка gen 0 быстрая, потому что её цена пропорциональна числу выживших, а не объёму мусора; аллокация — сдвиг указателя.
- Объекты крупнее 85 000 байт идут в LOH, считаются поколением 2 и по умолчанию не уплотняются → фрагментация. Спасение —
ArrayPool<byte>и переиспользование буферов. - Утечка в управляемом коде = случайно сохранённая достижимость. Топ-1 причина — подписка на событие без отписки: ссылка идёт от издателя к подписчику.
- Правило хорошего тона: кто подписался — тот и отписывается, чаще всего в
Dispose().