Сборщик мусора в среде .NET
Здравствуйте, Великие и Ужасные хабражители!
Как я недавно узнал, не так много народу знает о том, как работает сборщик мусора. Хотя я понимаю, что 99% разработчиков это не особо нужно, но я хотел бы, чтобы все, кто разрабатывают приложения на .NET, знали принцип его работы. В этой статье я постараюсь вкратце рассказать, как собственно работает сборщик мусора.
Базовые сведения о времени жизни объекта
Как известно, при инициализации объекта в памяти выделяет нужное под объект место. Использование ключевого слова new приводит к добавление объекта класса в так называемую управляемую кучу, а назад возвращается ссылка на объект.
При создании приложений на C# можно смело полагать, что исполняющая среда .NET будет сама заботиться об управляющей куче без непосредственного вмешательства со стороны программиста. На самом деле «золотое правило» по управлению памятью звучит так:
Размещайте объект в управляющей куче с помощью ключевого слова new и забывайте об этом.
После создания объект будет автоматически удалён сборщиком мусора, когда в нём отпадёт надобность. Сразу возникает вопрос о том, каким образом сборщик мусора определяет, когда в объект отпадает необходимость?
Давайте просмотрим простой пример:
public static void MakeACar() < // Если myCar является единственной ссылкой на объект // Car, тогда при возврате результата данных // методом объекта Car *может* быть уничтожен. Car myCar = new Car(); . >
Обратите внимание, что ссылка на объект Car(myCar) была создана непосредственно внутри метода MakeACar() и не передавалась за пределы определяющей области действия (ни в виде возвращающегося значения, ни в виде параметров ref/out). По этому после вызова метода ссылка на myCar окажется недостижимой, а объект Car — кандидатом на удаление сборщиком мусора. Следует, однако, понимать, что нет никакой гарантии на удаление объекта сразу после выполнение метода MakeACar(). Всё, что в данный момент можно предполагать, так это то, что когда в CLR-среде будет в следующий раз проводиться сборка мусора, то объект myCar будет поставлен на удаление.
Программистам на C++ хорошо известно, что если они специально не позаботятся об удалении размещённых в куче объектов, вскоре обязательно начнут возникать «утечки памяти». На самом деле отслеживание проблем, связанных с проблемой утечки памяти, являются одним из самых утомительных и длинных аспектов программирования в неуправляемых средах.
Роль корневых элементов приложения
- Ссылки на глобальные объекты (хотя в C# они не разрешены, CIL-код позволяет размещать глобальные объекты
- Ссылки на любые статические объекты или статические поля.
- Ссылки на локальные объекты в пределах кодовой базы приложения.
- Ссылки на передаваемые методу параметры объекта.
- Ссылки на объект, ожидающий финализации.
- Любые регистры центрального процессора, которые ссылаются на объект.
Во время процессы сборки мусора исполняющая среда будет исследовать объекты в куче, чтобы определить, являются ли они по прежнему достижимыми (т.е. корневыми) для приложения. Для этого среда CLR будет создавать графы объектов, представляющие все достижимые для приложения объекты. Кроме того, следует иметь ввиду, что сборщик мусора никогда не будет создавать граф для одного и того же объекта дважды, избегая необходимости выполнения подсчёта циклических ссылок, который характерен для программирования в среде COM.
Поколения объектов
При попытке обнаружить недостижимый код объекты CLR-среды не проверяют буквально каждый находящийся в куче объект. Очевидно, что на это уходила бы масса времени, особенно в крупных проектах.
Для оптимизации процесса каждый объект в куче относится к определённому «поколению» Смысл в применении поколений выглядит довольно просто:
Чем дольше объект находится в куче, тем выше вероятность того, что он там будет оставаться.
- Поколение 0. Идентифицируется новый только что размещённый объект, который ещё никогда не помечался как надлежащий удалению в процессе сборки мусора
- Поколение 1. Идентифицирует объект, который уже «пережил» один процесс сборки мусора (был помечен, как надлежащий удалению, но не был удалён из-за достаточного свободного места в куче).
- Поколение 2. Идентифицирует объект, который пережил более одного прогона сбора мусора
Поколения 0 и 1 называются эфемерными.
Сборщик мусора сначала анализирует все объекты, которые относятся к поколению 0. Если после их удаления остаётся достаточное количество памяти, статус всех уцелевших объектов повышается до поколения 1. Если все объекты поколения 0 были проверены, но всё равно требуется дополнительное пространство, то будет запцщени проверка объектов поколения 1. Объекты этого поколения, которым удалось уцелеть, станут объектами поколения 2. если же сборщику мусора всё равно понадобится память, что сборке мусора подвергнуться объекты поколения 2. Так как объектов выше 2 поколения не бывает, то статус объектов не изменится.
Из всего вышесказанного можно сделать вывод, что более новые объекты будут удалятся быстрее, нежели более старые.
Параллельная сборка мусора в .NET 1.0 — .NET 3.5
До выхода .NET 4.0 очистка неиспользуемых объектов проводилась с применением техники параллельной сборки мусора. В этой модели при выполнении сбора мусора эфемерных объектов сборщик мусора временно приостанавливал все активные потоки внутри текущего процесса, чтобы приложение не могло получить доступ к управляемой куче вплоть до завершения процесса сборки мусора.
По завершению цикла сборки мусора приостановленным потокам разрешалось снова продолжить работу. К счастью, в .NET 3.5 сборщик мусора был хорошо оптимизирован и потому связанные с ним короткие перерывы в работе с приложением редко становились заметными.
Как и оптимизация, параллельная сборка мусора позволяла проводить очистку объектов, которые не были обнаружены ни в одном из эфемерных поколений, в отдельном потоке. Это сокращало (но не устраняло) необходимость в приостановке активных потоков исполняющей средой .NET. Тем более, параллельная сборка мусора позволяла размещать объекты в куче во время сборки объектов не эфемерных поколений.
Фоновая сборка мусора в .NET 4.0
И напоследок я хотел бы вам рассказать об улучшении работы сборщика мусора в .NET 4.0.
В .NET 4.0 сборщик мусора по-другому решает вопрос о приостановке потоков и очистке объектов в управляемой куче, используя при этом технику фоновой сборки мусора. Не смотря на её название, это вовсе не означает, что вся сборка мусора теперь происходит в дополнительных фоновых потоках выполнения. На самом деле в случае фоновой сборки мусора для объектов, не относящихся к эфемерному поколению, исполняющая среда .NET теперь может проводить сборку мусора объектов эфемерного поколения в отдельном фоновом потоке.
Механизм сборки мусора в .NET 4.0 был улучшен так, чтобы на приостановку потока, связанного с деталями сбора мусора, требовалось меньше времени. Благодаря этим изменениям, процесс очистки неиспользуемых объектов поколения 0 и 1 стал оптимальным. Он позволяет получать более высокий уровень производительности приложений.
Заключение
В заключении я хотел бы сказать, что сборщик мусора, который используется в .NET 4.0 позволяет оптимизировать работу программ и практически не влияет на производительность.
В следующей статье я расскажу, как можно собственноручно управлять процессом сборки мусора с помощью пространства имён System.GС.
Сборка мусора, управление памятью и указатели
Ранее в теме Типы значений и ссылочные типы мы рассматривали отдельные типы данных и как они располагаются в памяти. Так, при использовании переменных типов значений в методе, все значения этих переменных попадают в стек. После завершения работы метода стек очищается.
При использовании же ссылочных типов, например, объектов классов, для них также будет отводиться место в стеке, только там будет храниться не значение, а адрес на участок памяти в хипе или куче, в котором и будут находиться сами значения данного объекта. И если объект класса перестает использоваться, то при очистке стека ссылка на участок памяти также очищается, однако это не приводит к немедленной очистке самого участка памяти в куче. Впоследствии сборщик мусора (garbage collector) увидит, что на данный участок памяти больше нет ссылок, и очистит его.
Test(); void Test() < Person tom = new Person("Tom"); Console.WriteLine(tom.Name); >record class Person(string Name);
В методе Test создается объект Person. С помощью оператора new в куче для хранения объекта CLR выделяет участок памяти. А в стек добавляет адрес на этот участок памяти. В неявно определенном методе Main мы вызываем метод Test. И после того, как Test отработает, место в стеке очищается, а сборщик мусора очищает ранее выделенный под хранение объекта Person участок памяти.
Сборщик мусора не запускается сразу после удаления из стека ссылки на объект, размещенный в куче. Он запускается в то время, когда среда CLR обнаружит в этом потребность, например, когда программе требуется дополнительная память.
Как правило, объекты в куче располагаются неупорядочено, между ними могут иметься пустоты. Куча довольно сильно фрагментирована. Поэтому после очистки памяти в результате очередной сборки мусора оставшиеся объекты перемещаются в один непрерывный блок памяти. Вместе с этим происходит обновление ссылок, чтобы они правильно указывали на новые адреса объектов.
Так же надо отметить, что для крупных объектов существует своя куча — Large Object Heap . В эту кучу помещаются объекты, размер которых больше 85 000 байт. Особенность этой кучи состоит в том, что при сборке мусора сжатие памяти не проводится по причине больших издержек, связанных с размером объектов.
Несмотря на то что, на сжатие занятого пространства требуется время, да и приложение не сможет продолжать работу, пока не отработает сборщик мусора, однако благодаря подобному подходу также происходит оптимизация приложения. Теперь чтобы найти свободное место в куче среде CLR не надо искать островки пустого пространства среди занятых блоков. Ей достаточно обратиться к указателю кучи, который указывает на свободный участок памяти, что уменьшает количество обращений к памяти.
Кроме того, чтобы снизить издержки от работы сборщика мусора, все объекты в куче разделяются по поколениям. Всего существует три поколения объектов: 0, 1 и 2-е.
К поколению 0 относятся новые объекты, которые еще ни разу не подвергались сборке мусора. К поколению 1 относятся объекты, которые пережили одну сборку, а к поколению 2 — объекты, прошедшие более одной сборки мусора.
Когда сборщик мусора приступает к работе, он сначала анализирует объекты из поколения 0. Те объекты, которые остаются актуальными после очистки, повышаются до поколения 1.
Если после обработки объектов поколения 0 все еще необходима дополнительная память, то сборщик мусора приступает к объектам из поколения 1. Те объекты, на которые уже нет ссылок, уничтожаются, а те, которые по-прежнему актуальны, повышаются до поколения 2.
Поскольку объекты из поколения 0 являются более молодыми и нередко находятся в адресном пространстве памяти рядом друг с другом, то их удаление проходит с наименьшими издержками.
Класс System.GC
Функционал сборщика мусора в библиотеке классов .NET представляет класс System.GC . Через статические методы данный класс позволяет обращаться к сборщику мусора. Как правило, надобность в применении этого класса отсутствует. Наиболее распространенным случаем его использования является сборка мусора при работе с неуправляемыми ресурсами, при интенсивном выделении больших объемов памяти, при которых необходимо такое же быстрое их освобождение.
Рассмотрим некоторые методы и свойства класса System.GC:
- Метод AddMemoryPressure информирует среду CLR о выделении большого объема неуправляемой памяти, которую надо учесть при планировании сборки мусора. В связке с этим методом используется метод RemoveMemoryPressure , который указывает CLR, что ранее выделенная память освобождена, и ее не надо учитывать при сборке мусора.
- Метод Collect приводит в действие механизм сборки мусора. Перегруженные версии метода позволяют указать поколение объектов, вплоть до которого надо произвести сборку мусора
- Метод GetGeneration(Object) позволяет определить номер поколения, к которому относится переданый в качестве параметра объект
- Метод GetTotalMemory возвращает объем памяти в байтах, которое занято в управляемой куче
- Метод WaitForPendingFinalizers приостанавливает работу текущего потока до освобождения всех объектов, для которых производится сборка мусора
Работать с методами System.GC несложно:
// . long totalMemory = GC.GetTotalMemory(false); GC.Collect(); GC.WaitForPendingFinalizers(); //.
С помощью перегруженных версий метода GC.Collect можно выполнить более точную настройку сборки мусора. Так, его перегруженная версия принимает в качестве параметра число — номер поколения, вплоть до которого надо выполнить очистку. Например, GC.Collect(0) — удаляются только объекты поколения 0.
Еще одна перегруженная версия принимает еще и второй параметр — перечисление GCCollectionMode . Это перечисление может принимать три значения:
- Default : значение по умолчанию для данного перечисления (Forced)
- Forced : вызывает немедленное выполнение сборки мусора
- Optimized : позволяет сборщику мусора определить, является ли текущий момент оптимальным для сборки мусора
Например, немедленная сборка мусора вплоть до первого поколения объектов: GC.Collect(1, GCCollectionMode.Forced);
Сборка мусора
Управление памятью в JavaScript выполняется автоматически и незаметно. Мы создаём примитивы, объекты, функции… Всё это занимает память.
Но что происходит, когда что-то больше не нужно? Как движок JavaScript обнаруживает, что пора очищать память?
Достижимость
Основной концепцией управления памятью в JavaScript является принцип достижимости.
Если упростить, то «достижимые» значения – это те, которые доступны или используются. Они гарантированно находятся в памяти.
- Существует базовое множество достижимых значений, которые не могут быть удалены. Например:
- Выполняемая в данный момент функция, её локальные переменные и параметры.
- Другие функции в текущей цепочке вложенных вызовов, их локальные переменные и параметры.
- Глобальные переменные.
- (некоторые другие внутренние значения)
Эти значения мы будем называть корнями.
В движке JavaScript есть фоновый процесс, который называется сборщиком мусора. Он отслеживает все объекты и удаляет те, которые стали недоступными.
Простой пример
Вот самый простой пример:
// в user находится ссылка на объект let user = < name: "John" >;
Здесь стрелка обозначает ссылку на объект. Глобальная переменная user ссылается на объект (мы будем называть его просто «John» для краткости). В свойстве «name» объекта John хранится примитив, поэтому оно нарисовано внутри объекта.
Если перезаписать значение user , то ссылка потеряется:
user = null;
Теперь объект John становится недостижимым. К нему нет доступа, на него нет ссылок. Сборщик мусора удалит эти данные и освободит память.
Две ссылки
Представим, что мы скопировали ссылку из user в admin :
// в user находится ссылка на объект let user = < name: "John" >; let admin = user;
Теперь, если мы сделаем то же самое:
user = null;
…то объект John всё ещё достижим через глобальную переменную admin , поэтому он находится в памяти. Если бы мы также перезаписали admin , то John был бы удалён.
Взаимосвязанные объекты
Теперь более сложный пример. Семья:
function marry(man, woman) < woman.husband = man; man.wife = woman; return < father: man, mother: woman >> let family = marry(< name: "John" >, < name: "Ann" >);
Функция marry «женит» два объекта, давая им ссылки друг на друга, и возвращает новый объект, содержащий ссылки на два предыдущих.
В результате получаем такую структуру памяти:
На данный момент все объекты достижимы.
Теперь удалим две ссылки:
delete family.father; delete family.mother.husband;
Недостаточно удалить только одну из этих двух ссылок, потому что все объекты останутся достижимыми.
Но если мы удалим обе, то увидим, что у объекта John больше нет входящих ссылок:
Исходящие ссылки не имеют значения. Только входящие ссылки могут сделать объект достижимым. Объект John теперь недостижим и будет удалён из памяти со всеми своими данными, которые также стали недоступны.
После сборки мусора:
Недостижимый «остров»
Вполне возможна ситуация, при которой целый «остров» взаимосвязанных объектов может стать недостижимым и удалиться из памяти.
Возьмём объект family из примера выше. А затем:
family = null;
Структура в памяти теперь станет такой:
Этот пример демонстрирует, насколько важна концепция достижимости.
Объекты John и Ann всё ещё связаны, оба имеют входящие ссылки, но этого недостаточно.
Бывший объект family был отсоединён от корня, на него больше нет ссылки, поэтому весь «остров» становится недостижимым и будет удалён.
Внутренние алгоритмы
Основной алгоритм сборки мусора называется «алгоритм пометок» (от англ. «mark-and-sweep»).
Согласно этому алгоритму, сборщик мусора регулярно выполняет следующие шаги:
- Сборщик мусора «помечает» (запоминает) все корневые объекты.
- Затем он идёт по ним и «помечает» все ссылки из них.
- Затем он идёт по отмеченным объектам и отмечает их ссылки. Все посещённые объекты запоминаются, чтобы в будущем не посещать один и тот же объект дважды.
- …И так далее, пока не будут посещены все достижимые (из корней) ссылки.
- Все непомеченные объекты удаляются.
Например, пусть наша структура объектов выглядит так:
Мы ясно видим «недостижимый остров» справа. Теперь давайте посмотрим, как будет работать «алгоритм пометок» сборщика мусора.
На первом шаге помечаются корни:
Затем помечаются объекты по их ссылкам:
…А затем объекты по их ссылкам и так далее, пока это возможно:
Теперь объекты, которые не удалось посетить в процессе, считаются недостижимыми и будут удалены:
Мы также можем представить себе этот процесс как выливание огромного ведра краски из корней, которая течёт по всем ссылкам и отмечает все достижимые объекты. Затем непомеченные удаляются.
Это концепция того, как работает сборка мусора. Движки JavaScript применяют множество оптимизаций, чтобы она работала быстрее и не задерживала выполнение кода.
Вот некоторые из оптимизаций:
- Сборка по поколениям (Generational collection) – объекты делятся на два набора: «новые» и «старые». В типичном коде многие объекты имеют короткую жизнь: они появляются, выполняют свою работу и быстро умирают, так что имеет смысл отслеживать новые объекты и, если это так, быстро очищать от них память. Те, которые выживают достаточно долго, становятся «старыми» и проверяются реже.
- Инкрементальная сборка (Incremental collection) – если объектов много, и мы пытаемся обойти и пометить весь набор объектов сразу, это может занять некоторое время и привести к видимым задержкам в выполнении скрипта. Так что движок делит всё множество объектов на части, и далее очищает их одну за другой. Получается несколько небольших сборок мусора вместо одной всеобщей. Это требует дополнительного учёта для отслеживания изменений между частями, но зато получается много крошечных задержек вместо одной большой.
- Сборка в свободное время (Idle-time collection) – чтобы уменьшить возможное влияние на производительность, сборщик мусора старается работать только во время простоя процессора.
Существуют и другие способы оптимизации и разновидности алгоритмов сборки мусора. Но как бы мне ни хотелось описать их здесь, я должен воздержаться, потому что разные движки реализуют разные хитрости и методы. И, что ещё более важно, все меняется по мере развития движков, поэтому изучать тему глубоко «заранее», без реальной необходимости, вероятно, не стоит. Если, конечно, это не вопрос чистого интереса, тогда для вас будет несколько ссылок ниже.
Итого
Главное, что нужно знать:
- Сборка мусора выполняется автоматически. Мы не можем ускорить или предотвратить её.
- Объекты сохраняются в памяти, пока они достижимы.
- Если на объект есть ссылка – вовсе не факт, что он является достижимым (из корня): набор взаимосвязанных объектов может стать недоступен в целом, как мы видели в примере выше.
Современные движки реализуют разные продвинутые алгоритмы сборки мусора.
О многих из них рассказано в прекрасной книге о сборке мусора «The Garbage Collection Handbook: The Art of Automatic Memory Management» (R. Jones и др.).
Если вы знакомы с низкоуровневым программированием, то более подробная информация о сборщике мусора V8 находится в статье A tour of V8: Garbage Collection.
Также в блоге V8 время от времени публикуются статьи об изменениях в управлении памятью. Разумеется, чтобы изучить сборку мусора, вам лучше подготовиться, узнав о том как устроен движок V8 внутри в целом и почитав блог Вячеслава Егорова, одного из инженеров, разрабатывавших V8. Я говорю про «V8», потому что он лучше всего освещается в статьях в Интернете. Для других движков многие подходы схожи, но сборка мусора отличается во многих аспектах.
Глубокое понимание работы движков полезно, когда вам нужна низкоуровневая оптимизация. Было бы разумно запланировать их изучение как следующий шаг после того, как вы познакомитесь с языком.
Введение в сборку мусора .NET
Если вы поймете, как работает сборщик мусора в .NET, то поймете и причины ряда проблем, возникающих в приложениях. И хоть .NET обещал конец ручному управлению памятью, вам все еще нужно следить за ее использованием при разработке приложений, чтобы избежать проблем с потреблением памяти и низкой производительностью.
Сборщик мусора в .NET предрекал конец ручного управления памятью и защиту от ее утечек. Идея в том, что при наличии сборщика мусора, работающего в фоновом режиме, разработчикам больше не нужно беспокоиться о необходимости управления жизненным циклом объектов — сборщик сам позаботится о них, когда они станут не нужны.
В реальности все оказалось гораздо сложнее. Сборщик мусора, конечно, помогает избежать наиболее распространенных утечек из тех, что встречаются в неуправляемых программах, утечек, которые возникают из-за того, что разработчик забыл освободить выделенную память, когда работа с ней закончена. Автоматическая сборка мусора также решает проблему преждевременного освобождения памяти, хотя способ решения этой проблемы может и сам привести к утечкам, ведь у сборщика может быть свое, особое мнение на то, является ли объект еще «живым» и в какой момент его необходимо удалить. И чтобы мы могли со всем этим справиться, необходимо понять, как работает сборщик мусора.
Как работает сборщик мусора
Так как же все-таки работает магия сборщика мусора? Основная идея довольно проста — он изучает, как объекты размещены в памяти, определяя те из них, до которых может добраться запущенная программа, следуя некоторой цепочке ссылок.
Когда начинается сборка мусора, сборщик просматривает набор ссылок, называемых корнями. Это участки памяти, которые в силу определенных причин должны быть доступны всегда, и которые содержат ссылки на объекты, созданные программой. Сборщик помечает эти объекты как живые, а затем просматривает все объекты, на которые они ссылаются, помечая живыми и их. Сборщик мусора продолжает в том же духе, пока не пометит живыми все объекты, которые он смог найти таким способом.
Сборщик мусора определяет объект как ссылающийся на другой объект, если он или один из его предков имеет поле, содержащее ссылку на другой объект.
Когда найдены все живые объекты, остальные могут быть уничтожены, а освободившееся место можно использовать для новых объектов. Кроме того, .NET уплотняет память, чтобы в ней не оставалось пробелов, перемещая живые и фактически перезатирая уничтоженные объекты. Это означает, что свободная память всегда находится в конце кучи, что делает выделение новых объектов очень быстрым.
Сами по себе корни не являются объектами, они представляют собой ссылки на объекты. Любой объект, на который ссылается корень, автоматически переживет следующую сборку мусора. В .NET существует четыре основных вида корней:
- Локальные переменные ссылочного типа в методе, который выполняется в данный момент. Объекты, на которые ссылаются эти переменные, всегда должны быть немедленно доступны методу, в котором они объявлены, поэтому их необходимо хранить. Время жизни таких корней может зависеть от того, как была собрана программа. В отладочных сборках локальная переменная живет до тех пор, пока метод находится в стеке. В релизных сборках оптимизирующий JIT-компилятор может посмотреть на структуру программы, чтобы определить последнюю точку, когда переменная используется методом, и удалить ее, когда она больше не нужна. Эта стратегия используется не всегда — ее можно отключить, например, запустив программу в отладчике.
- Статические поля. Статические поля также всегда считаются корнями. Объекты, на которые они ссылаются, могут быть доступны в любое время классу, который их объявил, или остальной части программы, если они объявлены, как public . Поэтому .NET всегда будет держать их в памяти. При этом поля, объявленные как ThreadStatic, будут существовать только до тех пор, пока выполняется использующий их поток.
- Управляемые объекты, переданные в неуправляемую библиотеку через Interop. Если управляемый объект передается в неуправляемую библиотеку COM+ через Interop, то он станет корневым объектом с подсчетом ссылок. Это происходит потому, что COM+ не выполняет сборку мусора. Вместо этого он использует систему подсчета ссылок. Как только библиотека COM+ завершает работу с объектом, устанавливая счетчик ссылок в 0, он перестает быть корневым и может быть удален.
- Ссылки на объекты с финализатором. Если у объекта есть финализатор, то он не удаляется сразу, как только сборщик мусора решит, что он больше не нужен. Вместо этого, ссылка на него становится особым видом корня до тех пор, пока .NET не вызовет финализатор. Для удаления таких объектов из памяти обычно требуется более одной сборки мусора, так как когда их признают ненужными в первый раз, они выживут, чтобы позже можно было вызвать финализатор.
Граф объектов
Память в .NET образует сложный запутанный граф перекрестных ссылок. Это может затруднить определение объема памяти, используемой конкретным объектом. Например, память, используемая непосредственно объектом List , довольно мала, поскольку класс List имеет всего несколько полей. Однако одним из этих полей является массив хранимых в списке объектов, который может быть довольно большим, если список имеет много записей. Этот массив принадлежит только одному конкретному списку, поэтому отношения между ними довольно просты. Общий размер списка — это размер маленького начального объекта и большого массива, на который он ссылается. Другое дело, объекты в массиве — вполне возможно, что существует и другой путь через память, по которому они могут быть доступны. В этом случае нет смысла считать их частью размера списка, так как они останутся, даже если список перестанет существовать, но и нет смысла считать их по альтернативному пути — они останутся, если список он будет удален.
Все становится еще более запутанным, когда в игру вступают циклические ссылки.
При разработке приложений программистам часто удобнее представлять память организованной в дерево, начинающееся с отдельных корней:
Это упрощает представление о том, как объекты располагаются в памяти, удобно при написании приложений и при использовании отладчика. Однако из-за этого легко забыть, что объект может быть связан более, чем с одним корнем. Именно из-за этого обычно и происходят утечки памяти в .NET — разработчик забывает или не понимает, что объект привязан более, чем к одному корню. Например, в случае, показанном на схеме выше, установка корня GC root 2 в null на самом деле не позволит сборщику мусора удалить ни одного объекта. Это видно при просмотре полного графа, но не понятно при изучении дерева.
Профилировщик памяти позволяет взглянуть на граф иначе — как на дерево, начинающееся с какого-либо корневого объекта (не следует путать корни сборщика мусора и корневые объекты дерева). Следуя по ссылкам, указывающим на объекты дерева начиная с корневого (т. е. в обратном направлении), мы можем поместить в его листья все корни сборщика мусора. Например, начиная с объекта ClassC , на который ссылается корень GC root 2, мы можем проследить все ссылки и получить следующий граф:
Таким образом мы увидим, что объект ClassC имеет двух корней-владельцев, оба из которых должны перестать на него ссылаться, прежде чем сборщик мусора сможет его удалить. Чтобы объект ClassC был удален после того, как корень GC root 2 будет установлен в null , должна быть разорвана любая из промежуточных связей между корнем GC root 3 и объектом.
Такая ситуация запросто может возникнуть в приложениях .NET. Наиболее распространенным является случай, когда на объект данных ссылается элемент пользовательского интерфейса, и этот объект не удаляется после завершения работы с ним. Строго говоря, это не является утечкой — память будет восстановлена, когда элемент пользовательского интерфейса будет обновлен новыми данными, но это может привести к тому, что приложение будет использовать гораздо больше памяти, чем ожидалось. Обработчики событий — еще одна распространенная причина чрезмерного потребления памяти. Легко забыть, что объект будет существовать по крайней мере столько же, сколько и объекты, от которых он получает события, что в случае некоторых глобальных событий (например, определенных в классе Application ) является вечностью.
Реальные приложения, особенно с компонентами пользовательского интерфейса, имеют гораздо более сложные графы, чем в примерах выше. Даже на такую простую вещь, как label в диалоговом окне, можно ссылаться из огромного количества различных мест:
В таком лабиринте может запросто потеряться какой-нибудь объект.
Ограничения сборщика мусора
Неиспользуемые объекты, на которые все еще есть ссылки
Самым большим ограничением сборщика мусора в .NET является то, что хотя он и преподносится как способный обнаруживать и удалять неиспользуемые объекты, на самом деле он находит только те объекты, на которые отсутствуют ссылки. Это очень важное различие — объект может больше никогда не упоминаться в программе, но, так как от него есть некоторый путь к объекту, который еще используется, он никогда не будет удален из памяти. Это и приводит к утечкам, возникающим, когда объект, который не используется, все равно остается в памяти.
Источник таких утечек бывает довольно трудно обнаружить, хотя симптомы очевидны — рост потребления памяти. Для начала, необходимо определить, какие неиспользуемые объекты остаются в памяти, а затем отследить ссылки, ведущие на них, чтобы выяснить, почему объекты не удаляются. Для решения этой задачи необходим профилировщик памяти. Сравнивая снимки памяти во время утечки, можно найти проблемные неиспользуемые объекты, но отследить ссылки на них в обратном направлении не сможет ни один отладчик.
Сборщик мусора предназначен для работы с избыточными ресурсами, то есть когда момент освобождения конкретного ресурса не имеет особого значения. В современных системах в эту категорию попадает память — не важно, когда она освобождается, главное сделать это вовремя, чтобы предотвратить неудачное выделение памяти под новый объект. Есть и ресурсы, которые не попадают в эту категорию, например, дескрипторы файлов должны быть закрыты как можно быстрее, чтобы не вызвать конфликтов между приложениями. Такие ресурсы не могут полностью управляться сборщиком мусора, поэтому .NET предоставляет метод Dispose() вместе с конструкцией using() для объектов, управляющих этими ресурсами. Дефицитные ресурсы, используемые объектом, быстро освобождаются реализацией метода Dispose() вручную (или используя метод using() , что также можно считать ручным освобождением), тогда как гораздо менее критичная память автоматически освобождается сборщиком мусора позже.
Dispose() не означает ничего особенного для .NET, поэтому утилизированные объекты (т. е. объекты, у которых был вызван метод Dispose() ) все равно должны быть освобождены (т. е. на них не должно быть ссылок). Это делает объекты, которые утилизированы, но не освобождены, хорошими кандидатами для источника утечки памяти.
Фрагментация кучи
Менее известное ограничение — это куча больших объектов (Large Object Heap, LOH), в которой размещаются объекты размером от 85000 байт. Куча больших объектов никогда не уплотняется, соответственно, объекты, которые в ней размещены, никогда не перемещаются, что может привести к преждевременному исчерпанию памяти в программе. Когда одни объекты живут дольше других, в куче образуются так называемые дыры — это называется фрагментацией. Проблема возникает, когда программа запрашивает большой блок памяти, но куча стала настолько фрагментированной, что в ней нет ни одной непрерывной области, достаточно большой, чтобы вместить его. Исключение OutOfMemoryException , вызванное фрагментацией, обычно происходит, когда программа имеет много свободной памяти, но из-за фрагментации не может разместить в ней новый объект.
Другим симптомом фрагментации является то, что .NET-приложению приходится держать память, «занятую» пустыми дырами. Это приводит к тому, что при просмотре в диспетчере задач кажется, что приложение использует гораздо больше памяти, чем ему нужно. Именно это часто происходит, когда профилировщик показывает, что выделенные программой объекты используют лишь небольшой объем памяти, а диспетчер задач показывает, что процесс занимает большой объем.
Производительность сборщика мусора
С точки зрения производительности, наиболее важной особенностью систем с автоматической сборкой мусора является то, что сборщик может начать выполнение в любое время. Это делает такие системы непригодными для использования в случаях, когда время выполнения критически важно, поскольку любая операция может быть приостановлена работой сборщика мусора.
Режимы работы сборщика мусора
Сборщик мусора в .NET имеет два основных режима работы: режим рабочей станции и режим сервера, а также два подрежима: параллельный и непараллельный. Параллельный режим рабочей станции используется в настольных приложениях, а режим сервера — в серверных, например, в ASP.NET.
В параллельном режиме рабочей станции, .NET пытается избежать долгой приостановки приложения за счет того, что параллельно с работой потоков программы, в фоновом режиме работает и поток сборщика мусора, который находит объекты для уничтожения. Это означает, что общий объем работы, которую сборщик может выполнить за определенный промежуток времени, будет меньше, но и приложение не будет на долго останавливаться. Такой подход хорош для интерактивных приложений, где важно создать у пользователя впечатление, что программа реагирует немедленно.
В непараллельном серверном режиме, .NET приостанавливает работу приложения на время работы сборщика мусора. В целом это более эффективно, чем параллельный режим — сборка мусора занимает столько же времени, но при этом ей не приходится бороться с продолжающей работать программой. Однако, при выполнении полной сборки могут возникать заметные паузы.
Режим сборки мусора можно задать в конфигурационном файле приложения, если значение по умолчанию не подходит. Выбор непараллельного режима сборки может быть полезен, когда важнее, чтобы приложение имело высокую пропускную способность, а не выглядело отзывчивым.
Поколения сборщика мусора
В больших приложениях количество объектов, с которыми приходится иметь дело сборщику мусора, может стать очень большим. В таком случае просмотр и перемещение всех объектов может занять очень много времени. Для решения этой проблемы в .NET используются поколения сборщика мусора. Идея поколений заключается в предположении, что объекты, созданные недавно, скорее всего, будут быстро освобождены, поэтому сборщик мусора будет чаще пытается уничтожить именно их. .NET сначала просматривает объекты, которые были созданы с момента последней сборки мусора, и начинает рассматривать более старые объекты, только если у него не получилось освободить достаточно места.
Лучше всего это работает, если .NET может самостоятельно выбирать время сборки мусора. Вызывая GC.Collect() вручную, вы нарушаете эффективность процесса, поскольку это часто приводит к преждевременному устареванию новых объектов, что увеличивает вероятность дорогостоящей полной сборки мусора в ближайшем будущем.
Классы с финализаторами также могут нарушить бесперебойную работу сборщика мусора. Объекты этих классов не могут быть удалены немедленно, вместо этого они попадают в очередь финализаторов и удаляются из памяти только после того, как финализатор будет выполнен. Это означает, что любой объект, на который они ссылаются (и любой объект, на который ссылаются эти объекты, и так далее), должен храниться в памяти как минимум до момента вызова финализатора, и потребуется как минимум две сборки мусора, прежде чем память снова станет доступной. Если граф содержит много объектов с финализаторами, это может означать, что сборщику мусора потребуется много проходов, чтобы полностью освободить и удалить ненужные объекты.
Существует простой способ избежать этой проблемы — реализуйте интерфейс IDisposable на финализируемых классах, перенесите необходимые для финализации объекта действия в метод Dispose() , в конце которого вызовите GC.SuppressFinalize() . Финализатор затем может быть модифицирован так, чтобы в нем вызывался метод Dispose() . GC.SuppressFinalize() сообщает сборщику мусора, что объект больше не нуждается в финализации и может быть немедленно удален, в результате чего память будет освобождена гораздо быстрее.
Заключение
Понимать проблемы с памятью и производительностью в .NET-приложении станет намного проще, если потратить время на изучение работы сборщика мусора. .NET, хоть и облегчает управление памятью, все же не полностью устраняет необходимость отслеживать и управлять ресурсами. Тем не менее, в .NET проще использовать профилировщик памяти для диагностики и устранения проблем. Отслеживание потребления и освобождения памяти на ранних этапах разработки поможет уменьшить количество проблем, но даже в этом случае они все равно могут возникнуть из-за сложности фреймворка или библиотек сторонних разработчиков.
Примечание переводчика
Одной статьи, конечно, недостаточно, чтобы полностью описать работу сборщика мусора в .NET, так что многие аспекты здесь опущены, многие — упрощены. Тем не менее, статья может дать новичкам начальное представление о сборке мусора в .NET, позволяя даже свои первые приложения писать с оглядкой на производительность и избегать многих распространенных ошибок.
Тем же, кто прочитав статью заинтересовался этой темой, я могу посоветовать ставшую уже классикой в мире .NET книгу Джеффри Рихтера «CLR via C#», в которой вы найдете не только гораздо более подробное описание процесса сборки мусора в .NET, но и массу другой полезной информации.