Освобождение памяти и сборка мусора net приложений

Обновлено: 06.07.2024

В этой статье — важное понятие из компьютерной теории. Читайте, если хотите разбираться в устройстве компьютеров и памяти. Особенно полезно для бэкенда и разработки высоконагруженных систем.

Ситуация

Когда мы пишем программы, мы обычно действуем по такому принципу:

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

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

Проблема

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

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

👉 Особенно это опасно на контроллерах или носимых устройствах: там обычно мало памяти и её легко заполнить. А контроллеры управляют не только умным домом, но и, например, самолётами или промышленными станками. Если не подумать о памяти в контроллере двигателя самолёта, то у тебя на третьем часу полёта откажет двигатель.

Решение — очистка мусора и управление памятью

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

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

Автоматический режим называется сборкой мусора. Это такая отдельная мини-программа внутри основной программы, которая периодически пробегает по объектам и переменным в коде и смотрит, нужны они или нет. Если нет — сборщик мусора сам удаляет переменную и освобождает память.

Особенности ручного управления

При ручной работе с памятью программист получает полный контроль над ресурсами и может в любой момент освободить уже ненужную память. Это значит, что можно написать такую программу, когда в сумме переменным нужно 500 мегабайт памяти, но за счёт своевременного удаления всегда заняты только 100 мегабайт.

Ручное управление идеально для систем со слабыми ресурсами и систем реального времени — там, где программа не имеет права тормозить.

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

Особенности автоматического сборщика

Автоматический сборщик сам ходит по программе во время исполнения и аккуратно подчищает память, как только находит мусор.

Вроде хорошо, но нет. Сборщик мусора тоже работает неидеально:

❌ Сборщик удаляет только те переменные, в которых он уверен стопроцентно. Если есть один шанс, что переменная может когда-нибудь понадобиться, — её оставляют в памяти. То есть эффективность не стопроцентная.

❌ Сборщик — это отдельная программа, которая работает вместе с основной. И ей тоже нужны ресурсы и процессорное время. Это замедляет работу основной программы. Как если бы уборщик приходил в отделение Сбербанка посреди рабочего дня и заставлял всех на два часа покинуть рабочие места, чтобы он тут провёл влажную уборку.

❌ Если рабочей памяти очень мало, то сборщик будет работать постоянно. Но ему тоже нужна своя память для работы. И может получиться, что сборщик, например, занимает 100 МБ, а освобождает 10 МБ. Может оказаться так, что без сборщика программа будет работать эффективнее.

Автоматические сборщики — добро или зло?

Тут у разработчиков мнения разделяются.

С точки зрения быстродействия сборщики — однозначно зло, потому что они всегда замедляют работу. Есть ситуации, когда замедление незаметно, а бывает, когда оно критично.

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

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

Что выбрать?

С выбором интересная ситуация.

Если вы пишете приложения для iOS или OSX, вам нельзя использовать сборщики мусора из соображений быстродействия.

Языки типа JavaScript и Ruby собирают мусор сами, вы об этом можете даже никогда не узнать.

В некоторые языки можно подключить сбор мусора, пожертвовав производительностью — например, в Java, C или C++.

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

Базовые сведения о времени жизни объекта

Размещайте объект в управляющей куче с помощью ключевого слова new и забывайте об этом.


После создания объект будет автоматически удалён сборщиком мусора, когда в нём отпадёт надобность. Сразу возникает вопрос о том, каким образом сборщик мусора определяет, когда в объект отпадает необходимость?

Давайте просмотрим простой пример:


Обратите внимание, что ссылка на объект Car(myCar) была создана непосредственно внутри метода MakeACar() и не передавалась за пределы определяющей области действия (ни в виде возвращающегося значения, ни в виде параметров ref/out). По этому после вызова метода ссылка на myCar окажется недостижимой, а объект Car — кандидатом на удаление сборщиком мусора. Следует, однако, понимать, что нет никакой гарантии на удаление объекта сразу после выполнение метода MakeACar(). Всё, что в данный момент можно предполагать, так это то, что когда в CLR-среде будет в следующий раз проводиться сборка мусора, то объект myCar будет поставлен на удаление.

Программистам на C++ хорошо известно, что если они специально не позаботятся об удалении размещённых в куче объектов, вскоре обязательно начнут возникать «утечки памяти». На самом деле отслеживание проблем, связанных с проблемой утечки памяти, являются одним из самых утомительных и длинных аспектов программирования в неуправляемых средах.

Роль корневых элементов приложения

Во время процессы сборки мусора исполняющая среда будет исследовать объекты в куче, чтобы определить, являются ли они по прежнему достижимыми (т.е. корневыми) для приложения. Для этого среда CLR будет создавать графы объектов, представляющие все достижимые для приложения объекты. Кроме того, следует иметь ввиду, что сборщик мусора никогда не будет создавать граф для одного и того же объекта дважды, избегая необходимости выполнения подсчёта циклических ссылок, который характерен для программирования в среде COM.

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

При попытке обнаружить недостижимый код объекты CLR-среды не проверяют буквально каждый находящийся в куче объект. Очевидно, что на это уходила бы масса времени, особенно в крупных проектах.
Для оптимизации процесса каждый объект в куче относится к определённому «поколению» Смысл в применении поколений выглядит довольно просто:
Чем дольше объект находится в куче, тем выше вероятность того, что он там будет оставаться.
  • Поколение 0. Идентифицируется новый только что размещённый объект, который ещё никогда не помечался как надлежащий удалению в процессе сборки мусора
  • Поколение 1. Идентифицирует объект, который уже «пережил» один процесс сборки мусора (был помечен, как надлежащий удалению, но не был удалён из-за достаточного свободного места в куче).
  • Поколение 2. Идентифицирует объект, который пережил более одного прогона сбора мусора

Поколения 0 и 1 называются эфемерными.

Сборщик мусора сначала анализирует все объекты, которые относятся к поколению 0. Если после их удаления остаётся достаточное количество памяти, статус всех уцелевших объектов повышается до поколения 1. Если все объекты поколения 0 были проверены, но всё равно требуется дополнительное пространство, то будет запцщени проверка объектов поколения 1. Объекты этого поколения, которым удалось уцелеть, станут объектами поколения 2. если же сборщику мусора всё равно понадобится память, что сборке мусора подвергнуться объекты поколения 2. Так как объектов выше 2 поколения не бывает, то статус объектов не изменится.
Из всего вышесказанного можно сделать вывод, что более новые объекты будут удалятся быстрее, нежели более старые.

Назначение сборщика мусора

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

избавиться от ошибок и ловушек, связанных с управлением памятью вручную;

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

В существующих языках программирования и фреймворках используются различные стратегии управления памятью. Мы коротко исследуем две из них: управление на основе списка свободных блоков (реализацию которой можно найти в коллекции стандартных инструментов управления памятью языка C) и сборка мусора на основе подсчета ссылок.

Управление свободным списком

Управление на основе списка свободных блоков - это механизм управления распределением памяти в стандартной библиотеке языка C, который также по умолчанию используется функциями управления памятью в C++, такими как new и delete. Это детерминированный диспетчер памяти, при использовании которого вся ответственность за выделение и освобождение памяти ложится на плечи разработчика. Свободные блоки памяти хранятся в виде связанного списка, откуда изымаются блоки памяти при выделении, и куда они возвращаются, при освобождении.

Диспетчер памяти хранит список свободных блоков

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

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

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

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

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

Ниже перечислены основные проблемы, связанные с управлением памятью на основе списка свободных блоков:

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

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

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

Сборка мусора на основе подсчета ссылок

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

Каждый объект содержит счетчик ссылок

Одним из примеров управления памятью на основе подсчета ссылок в экосистеме Windows является объектная модель программных компонентов (Component Object Model, COM). Объекты COM снабжаются счетчиками ссылок, определяющими продолжительность их существования. Когда значение счетчика ссылок достигает 0, объект может освободить занимаемую им память. Основное бремя подсчета ссылок ложится на плечи разработчика, в виде явного вызова методов AddRef() и Release(), хотя в большинстве языков имеются обертки, автоматизирующие вызовы этих методов при создании и удалении ссылок.

Ниже перечислены основные проблемы, связанные с управлением памятью на основе подсчета ссылок:

Высокая стоимость управления: всякий раз, когда создается или уничтожается ссылка на объект, необходимо обновлять счетчик ссылок. Это означает, что к стоимости обновления ссылки прибавляются накладные расходы на выполнение таких тривиальных операций, как присваивание ссылки (a = b) или передача ссылки в функцию по значению. В многопроцессорных системах выполнение таких операций требует применения механизмов синхронизации и вызывает «пробуксовку» кеша процессора, при попытке нескольких потоков выполнения одновременно изменить счетчик ссылок.

Использование памяти: счетчик ссылок на объект должен храниться в памяти объекта. Это на несколько байтов увеличивает объем памяти, занимаемой объектом, что делает подсчет ссылок нецелесообразным для легковесных объектов. (Впрочем, это не такая большая проблема для CLR, где к каждому объекту «в нагрузку» добавляется от 8 до 16 байт.)

Правильность: при управлении памятью на основе подсчета ссылок возникает проблема утилизации объектов с циклическими ссылками. Если приложение больше не ссылается на некоторую пару объектов, но каждый из них продолжает хранить ссылку на другой объект (как показано на рисунке ниже), возникает утечка памяти. Эта проблема описывается в документации COM, где явно оговаривается, что такие циклические ссылки должны уничтожаться вручную. Другие платформы, такие как язык программирования Python, поддерживают дополнительные механизмы определения циклических ссылок и их устранения, применение которых влечет за собой увеличение стоимости сборки мусора.

Уничтожение циклических ссылок

Как работает сборщик мусора?

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

Во время процесса сборки мусора исполняющая среда будет исследовать объекты в управляемой куче, чтобы определить, являются ли они по-прежнему достижимыми (т.е. корневыми) для приложения. Для этого среда CLR будет создавать графы объектов, представляющие все достижимые для приложения объекты в куче.

Во время сборки мусора эти объекты (а также любые внутренние объектные ссылки, которые они могут содержать) будут исследованы на предмет наличия у них активных корневых элементов. После построения графа все недостижимые объекты помечаются как являющиеся мусором.

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

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

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

Сборщик мусора сначала анализирует все объекты, которые относятся к поколению 0. Если после их удаления остается достаточное количество памяти, статус всех остальных (уцелевших) объектов повышается до поколения 1.

Если все объекты поколения 0 уже были проверены, но все равно требуется дополнительное пространство, проверяться на предмет достижимости и подвергаться процессу сборки мусора начинают объекты поколения 1. Объектам поколения 1, которым удалось уцелеть после этого процесса, затем назначается статус объектов поколения 2. Если же сборщику мусора все равно требуется дополнительная память, тогда на предмет достижимости начинают проверяться и объекты поколения 2. Объектам, которым удается пережить сборку мусора на этом этапе, оставляется статус объектов поколения 2, поскольку более высокие поколения просто не поддерживаются.

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

Метод System.Object.Finalize() и финализатор

Вместо этого для достижения того же эффекта должен применяться синтаксис деструктора (подобно С++). Объясняется это тем, что при обработке синтаксиса финализатора компилятор автоматически добавляет в неявно переопределяемый метод Finalize() приличное количество требуемых элементов инфраструктуры.

Класс System.GC (Garbage Collector)

В библиотеках базовых классов доступен класс по имени System.GC, который позволяет программно взаимодействовать со сборщиком мусора за счет обращения к его статическим членам. Необходимость в непосредственном использовании этого класса в разрабатываемом коде возникает крайне редко (а то и вообще никогда). Обычно единственным случаем, когда нужно применять члены System.GC. является создание классов, предусматривающих использование на внутреннем уровне неуправляемых ресурсов.

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

Для проектов с неуправляемым кодом особое значение имеют два следующих метода из класса GC: AddMemoryPressure() и RemoveMemoryPressure(). С их помощью указывается большой объем неуправляемой памяти, выделяемой или освобождаемой в программе.

Особое значение этих методов состоит в том, что система управления памятью не контролирует область неуправляемой памяти. Если программа выделяет большой объем неуправляемой памяти, то это может сказаться на производительности, поскольку системе ничего неизвестно о таком сокращении объема свободно доступной памяти. Если же большой объем неуправляемой памяти выделяется с помощью метода AddMemoryPressure(), то система CLR уведомляется о сокращении объема свободно доступной памяти. А если выделенная область памяти освобождается с помощью метода RemoveMemoryPressure(), то система CLR уведомляется о соответствующем восстановлении объема свободно доступной памяти. Следует, однако, иметь в виду, что метод RemoveMemoryPressure() необходимо вызывать только для уведомления об освобождении области неуправляемой памяти, выделенной с помощью метода AddMemoryPressure().

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

Первый подход заключается в переопределении метода System.Object.Finalize() и позволяет гарантировать то, что объект будет очищать себя сам во время процесса сборки мусора (когда бы тот не запускался) без вмешательства со стороны пользователя.

Второй подход предусматривает реализацию интерфейса IDisposable и позволяет обеспечить пользователя объекта возможностью очищать объект сразу же по окончании работы с ним. Однако если пользователь забудет вызвать метод Dispose(), неуправляемые ресурсы могут оставаться в памяти на неопределенный срок.

Оба подхода можно комбинировать и применять вместе в определении одного класса, получая преимущества от обеих моделей. Если пользователь объекта не забыл вызвать метод Dispose(), можно проинформировать сборщик мусора о пропуске финализации, вызвав метод GC.SuppressFinalize(). Если же пользователь забыл вызвать этот метод, объект рано или поздно будет подвергнут финализации и получит возможность освободить внутренние ресурсы. Преимущество такого подхода в том, что при этом внутренние ресурсы будут так или иначе, но всегда освобождаться.

(часто встречается при работе с баами данных)

Ключевое слово using упрощает работу с объектами которые реализуют интерфейс IDisposable.
Интерфейс IDisposable содержит один метод .Dispose(), который используется для освобождения ресурсов,
которые захватил объект. При использовании Using не обязательно явно вызывать .Dispose() для объекта.

Пример

При этом компилятор фактически генерирует следующий код:

Заметим, что Using-блоки делают код более читабельным и компактным (некоторый аналог лямбда-операторов).

Читайте также: