nullreference.dev

Коспект: сборка мусора в dotnet

Feb 2025

Этапы уборки мусора

Память для всех объектов выделяется из управляемой кучи (managed heap). При запуске процесса резервируется область памяти под управляемую кучу и под указатель где расположить следующий объект - NextObjPtr.

При выполнении оператора new, среда CLR:

  • Подсчитывает число байт необходимых для размещения нового объекта.
  • Прибавляет число байт необходимых под системные поля объекта, указатель на тип и индекс блока синхронизации.
  • Если памяти достаточно, то память выделяется начиная с указателя NextObjPtr. Выделенная память обнуляется и вызывается конструктор типа, где в качестве this выступает указатель NextObjPtr.

a

Таким образом все объекты в памяти размещаются "друг за другом", а если принять во внимание тот факт что объекты создаются под одну операцию и в одно время, то все связанные объекты располагаются рядом и операции с ними работают очень быстро.

  • Для управления сроком жизни объекта используется алгоритм отслеживания ссылок. Когда среда CLR запускает уборку мусора, она сначала приостанавливает все программные потоки в процессе. Тем самым останавливается обращение к объектам и возможные изменения состояния.
  • Затем начинается этап маркировки, перебирает все объекты задавая биту в поле индекса блока синхронизации значение 0. Это означает что объект может быть удален, затем проверяются все активные корни и объекты на которые ссылаются. Если корень содержит null, то переходит к следующему, а если ссылается на объект, то устанавливается бит (маркировка). Таким образом перебираются все корни и встретив бит, сборщик останавливается (тем самым избегая бесконечного обхода по кругу).
  • После проверки всех корней куча содержит маркированные и немаркированные объекты. Немаркированные значит недостижимые (нету активных ссылок). Начинается процесс сжатия. Используемые объекты перемещаются для того чтобы занять смежную память обеспечивая быстро действенность. Кроме того освободившееся пространство тоже становится непрерывным. После перемещения объектов ссылки тоже обновляются и должны указывать на новые адреса.
  • Далее индекс NextObjPtr перемещается и занимает место сразу за последним "живым" объектом. После этого CLR возобновляет работу потоков.

a

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

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

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

Корни бывают локальные и статичные:

  • Локальные - это переменные что ссылаются на объект. Например переменная с XmlDocument.
  • Статичные - это корни что относятся к статичным классам и объектам, т.е. существуют на протяжении работы приложения.

Поколения

Уборщик мусора работает с поддержкой поколений (generation garbage collector). Работа с использованием поколений отталкивается от нескольких предположений:

  • Чем младше объект, тем короче его время жизни.
  • Чем старше объект, тем длиннее его время жизни.
  • Уборку мусора быстрей сделать в части кучи, чем сразу во всей.

Все вновь создаваемые объекты в куче относятся к поколению 0 (gen0). Их еще не касался уборщик мусора. Для поколения 0 установлен свой порог, сразу после которого должна начаться уборка мусора.

Объекты пережившие первую уборку мусора теперь относятся к поколению 1 (gen1). Соответственно после уборки мусора в поколении 0 ничего не остается. У поколения 1 в свою очередь тоже есть свой порог.

a

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

Постепенно поколение 1 достигает своего порога и начинает мешать выделению памяти в поколении 0. Теперь уборка мусора запускается сразу для двух поколений 0 и 1. На этом этапе образуется поколение 2 (gen2) - это те объекты из поколения gen1 что пережили уборку мусора. Поколение 2 является последним и в этом поколении тоже есть свой порог.

Уборка мусора является динамическим процессом и даже пороги это не конкретные значения, а постоянно меняющиеся для того чтобы сделать уборку мусора наиболее эффективной. Тут важно понимать что малый порог не значит эффективная уборка поскольку частая уборка это в свою очередь частые остановки процессов. Алгоритм стремится к такому значению порога при котором наиболее вероятна ситуация полной уборки мусора из поколения, что означает что нам достаточно сдвинуть NextObjPtr без необходимости что-либо перемещать в памяти.

Запуск уборки мусора зависит не только от порогов. Вот еще несколько причин по которым может запуститься уборка:

  • Вызов GC.Collect. Т.е. ручной запуск уборки мусора. Способ не рекомендуем к использованию и является скорее крайней мерой.
  • Сигнал операционной системы о нехватки памяти.
  • Выгрузка домена приложений.
  • Завершение работы CLR.

Большие объекты

До этого мы говорили лишь о малых объектах (soh), но также существуют и большие (loh) - это те размер которых превышает 85 000 байт.

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

Режимы уборки

Существует два основных режима уборки, режим определяется при запуске и не меняется:

  • Режим рабочей станции (по умолчанию). Оптимизирован для уменьшения времени работы уборки мусора. Чаще всего это приложения с интерфейсом и частые остановки на уборку могут негативно сказаться на пользовательском опыте.
  • Режим сервера. Предполагается что на машине отсутствуют другие приложения и все ресурсы можно бросить на уборку мусора. В данном случае куча разбивается на несколько разделов - по одному на каждый процессор. У каждого потока собственный раздел памяти.

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

Финализация

В случае если нашему коду потребовались системные ресурсы, т.е. что-то от операционной системы, простая уборка мусора с использованием поколений не приведет к высвобождению системного ресурса и ресурс останется потерянным. Для решения этого вопроса существует механизм финализации, любой объект использующий системный ресурс должен поддерживать механизм финализации. Метод финализации выглядит как конструктор, только в начале имени используется ~. Финализация разворачивается в конструкцию try/finally, где код собственного метода помещается в блок try, а base.Finalize в блок finally. Объекты с собственной финализацией гарантированно переживают как минимум одну сборку мусора и попадают в поколение 1.

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

Жизненный цикл объекта с финализацией:

  • Когда объект с собственным финализатором создается, то ссылка на него помещается в специальную очередь финализации (finalization queue), таким образом что даже если в коде не остается ссылок на объект, то остается еще как минимум одна в очереди финализации.
  • Сборщик мусора заметив что осталась лишь одна ссылка перемещает объект в следующую очередь, готовых к завершению (f-reacheable queue). Эта очередь является корнем и объекты продолжают существовать далее.
  • Сборщик мусора не занимается объектом, для этого есть специальный поток финализации (finalization thread), который удаляет ссылку и вызывает метод финализации. К следующей уборке мусора метод финализации уже вызван и не существует действующих ссылок.

Все эти сложности нужны для того что мы не знаем сколько потребуется времени на выполнение всех финализаторов и не хотим держать потоки остановленными все это время.

Финализацию разделяют на детерминированную (явную) и недетерминированную (неявную):

  • Под явную или детерминированную финализацию часто относят ручное освобождение ресурса. Например, явный вызов метода Close что был ранее открыт кодом.
  • недетерминированная или неявная и она же автоматическая финализация может показаться удобнее для программиста и в то же самое время не самая эффективная поскольку финализация растягивается во времени и поколениях памяти. Смотри жизненный цикл выше.

Интерфейс IDisposable является частью детерминированной финализации и является наиболее распространенным способом работы с системными (внешними) ресурсами. IDisposable является своего рода соглашением что метод Dispose будет вызван в коде и ресурсы освобождены. Соглашение это касается именно программиста и поэтому считается слабой частью детерминированной финализации.

Для того чтобы не оставлять все на совесть программиста есть запасной вариант для гарантированной очистки ресурсов. Для этого финализатор все еще необходимо реализовывать, но и использовать метод GC.SupressFinalize внутри Dispose для того чтобы сократить путь по очередям финализации в случае соблюдения контракта программистом.

public class BaseClassWithFinalizer : IDisposable
{
    private bool _disposedValue;

    ~BaseClassWithFinalizer() => Dispose(false);

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

    protected virtual void Dispose(bool disposing)
    {
        if (!_disposedValue)
        {
            if (disposing)
            {
                // TODO: dispose managed state (managed objects)
            }

            // TODO: free unmanaged resources (unmanaged objects) and override finalizer
            // TODO: set large fields to null
            _disposedValue = true;
        }
    }
}

Для полного погружения в тему сборки мусора стоит ознакомиться еще со слабыми ссылками, типом SafeHandle, ссылки между поколениями, pinned object heap, воскрешением и пулом объектов.

[ back to home ]