«Хороший сайт делается для людей, а поисковики приходят следом»

— из принципов редакции SeoLiga.ru
Главная » Статьи » .NET и C# » Сборка мусора в .NET: как это работает

Сборка мусора в .NET: как это работает

Одно из главных удобств платформы .NET — вам не нужно вручную освобождать память. Вы создаёте объекты, пользуетесь ими, а среда сама определяет, когда они больше не нужны, и убирает. За это отвечает сборщик мусора. Но «автоматически» не значит «бесплатно»: плохо написанный код способен заставить сборщик работать на износ и заметно замедлить приложение.

Разберёмся, как устроен этот механизм, почему он делит объекты на поколения, зачем нужны финализаторы и что делать разработчику, чтобы помогать, а не мешать.

Базовая идея: достижимость объектов

Сборщик мусора не считает ссылки на объект. Он решает другую задачу: определяет, какие объекты достижимы из «корней» программы. К корням относятся статические поля, локальные переменные в активных методах, регистры процессора и некоторые служебные структуры. Всё, до чего можно добраться по цепочке ссылок от корня, считается живым. Остальное — мусор.

Такой подход решает проблему циклических ссылок. Два объекта, указывающие друг на друга, но недостижимые из корней, будут убраны вместе. В языках, где применяется простой подсчёт ссылок, подобные циклы приводят к утечкам.

Процесс сборки в упрощённом виде состоит из трёх этапов:

  1. Пометка. Сборщик обходит граф объектов от корней и помечает достижимые.
  2. Перемещение. Живые объекты уплотняются, сдвигаясь в начало освобождённого пространства.
  3. Обновление ссылок. Все указатели на перемещённые объекты корректируются.

Уплотнение делает выделение памяти очень быстрым: новая память просто выдаётся с конца используемой области, как в стеке.

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

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

Значимые типы — структуры и числа — обычно лежат в стеке или внутри других объектов, и сборщик их отдельно не трогает. Это одна из причин, почему мелкие неизменяемые данные иногда выгодно оформлять как структуры: меньше давления на кучу.

Но когда память в куче заканчивается, запускается сборка. И вот тут вступает в игру ключевая оптимизация — поколения.

Поколения объектов

Наблюдения за реальными программами показали: большинство объектов живут очень недолго, а те, что пережили несколько сборок, обычно живут долго. На этом основана генерационная модель. Куча делится на три поколения:

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

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

Вывод для разработчика: создавать много короткоживущих объектов почти безопасно, а вот объекты, которые «задерживаются» ровно настолько, чтобы попасть в старшие поколения, а потом умирают, — самые неприятные. Они заставляют запускать дорогие сборки.

Куча больших объектов

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

Последствия такие:

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

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

Финализаторы и освобождение ресурсов

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

Финализатор вызывается сборщиком перед удалением объекта, но у него серьёзные недостатки:

  • неизвестно, когда именно он сработает — возможно, через долгое время;
  • объект с финализатором переживает как минимум одну лишнюю сборку, оказываясь в более старшем поколении;
  • порядок вызова финализаторов разных объектов не гарантирован.

Поэтому основным способом остаётся явное освобождение через интерфейс IDisposable. Классический шаблон выглядит так:

public class FileHolder : IDisposable
{
    private IntPtr handle;
    private bool disposed;

    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }

    protected virtual void Dispose(bool disposing)
    {
        if (disposed) return;
        if (disposing)
        {
            // освободить управляемые ресурсы
        }
        // освободить неуправляемые ресурсы
        disposed = true;
    }

    ~FileHolder()
    {
        Dispose(false);
    }
}

Вызов подавления финализации сообщает сборщику, что объект уже прибран и ставить его в очередь финализации не нужно. А в обычном коде достаточно оператора using, который гарантирует вызов освобождения даже при исключении:

using (var stream = new FileStream(path, FileMode.Open))
{
    // работа с потоком
}

Режимы работы и фоновая сборка

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

Современные версии также умеют выполнять сборку второго поколения в фоне, не останавливая полностью рабочие потоки. Тем не менее короткие паузы всё равно остаются, и в системах реального времени их приходится учитывать.

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

Типичные причины утечек памяти

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

  1. События. Подписчик на событие долгоживущего объекта остаётся в памяти, пока не отпишется. Забытые подписки — классика.
  2. Статические коллекции. Кэш без ограничения размера или справочник, в который только добавляют.
  3. Замыкания. Лямбда неожиданно удерживает ссылку на весь окружающий объект.
  4. Неосвобождённые ресурсы. Забытый вызов освобождения у компонентов, держащих неуправляемые дескрипторы.
  5. Таймеры и потоки. Активный таймер держит ссылку на обработчик, а значит, и на его владельца.

Для поиска таких проблем применяют профилировщики памяти: они показывают, какие объекты накапливаются и какая цепочка ссылок их удерживает.

Слабые ссылки

Иногда нужно хранить ссылку на объект, но не мешать его сборке — например, в кэше. Для этого есть слабые ссылки. Они не считаются при определении достижимости, и если объект больше никому не нужен, он будет убран, а слабая ссылка перестанет указывать на него.

var weak = new WeakReference(bigObject);
bigObject = null;

if (weak.Target is BigObject alive)
{
    // объект ещё жив, можно использовать
}

Используйте их осторожно: логика, которая зависит от точного момента сборки, получается хрупкой.

Практические рекомендации

  • Не создавайте лишних объектов в «горячих» циклах: переиспользуйте буферы и коллекции.
  • Для сборки длинных строк применяйте построитель, а не сложение в цикле.
  • Всегда освобождайте объекты, реализующие интерфейс освобождения, через оператор using.
  • Отписывайтесь от событий, когда объект больше не нужен.
  • Не пишите финализаторы без необходимости: обычно хватает безопасных обёрток над дескрипторами.
  • Измеряйте, а не гадайте: счётчики производительности и профилировщики покажут реальную картину.

Заключение

Сборка мусора в .NET — продуманный механизм, который избавляет от большинства забот о памяти. Он строится на достижимости объектов, делит кучу на поколения и отдельно обращается с крупными данными. Но он не заменяет внимательности: ресурсы нужно освобождать явно, события — отписывать, а количество долгоживущих «полупокойников» — держать под контролем.

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

ПО
Павел Орлов
Автор SeoLiga.ru. Пишет о практике: что работает на реальных проектах, а что лучше обойти стороной. Хотите предложить свою статью? Загляните в раздел «Авторам».