что такое сборщик мусора garbage collector на базовом уровне
Базовые сведения о времени жизни объекта
Размещайте объект в управляющей куче с помощью ключевого слова new и забывайте об этом.
После создания объект будет автоматически удалён сборщиком мусора, когда в нём отпадёт надобность. Сразу возникает вопрос о том, каким образом сборщик мусора определяет, когда в объект отпадает необходимость?
Давайте просмотрим простой пример:
Обратите внимание, что ссылка на объект Car(myCar) была создана непосредственно внутри метода MakeACar() и не передавалась за пределы определяющей области действия (ни в виде возвращающегося значения, ни в виде параметров ref/out). По этому после вызова метода ссылка на myCar окажется недостижимой, а объект Car — кандидатом на удаление сборщиком мусора. Следует, однако, понимать, что нет никакой гарантии на удаление объекта сразу после выполнение метода MakeACar(). Всё, что в данный момент можно предполагать, так это то, что когда в CLR-среде будет в следующий раз проводиться сборка мусора, то объект myCar будет поставлен на удаление.
Программистам на C++ хорошо известно, что если они специально не позаботятся об удалении размещённых в куче объектов, вскоре обязательно начнут возникать «утечки памяти». На самом деле отслеживание проблем, связанных с проблемой утечки памяти, являются одним из самых утомительных и длинных аспектов программирования в неуправляемых средах.
Роль корневых элементов приложения
Во время процессы сборки мусора исполняющая среда будет исследовать объекты в куче, чтобы определить, являются ли они по прежнему достижимыми (т.е. корневыми) для приложения. Для этого среда CLR будет создавать графы объектов, представляющие все достижимые для приложения объекты. Кроме того, следует иметь ввиду, что сборщик мусора никогда не будет создавать граф для одного и того же объекта дважды, избегая необходимости выполнения подсчёта циклических ссылок, который характерен для программирования в среде COM.
Поколения объектов
Чем дольше объект находится в куче, тем выше вероятность того, что он там будет оставаться.
Поколения 0 и 1 называются эфемерными.
Сборщик мусора сначала анализирует все объекты, которые относятся к поколению 0. Если после их удаления остаётся достаточное количество памяти, статус всех уцелевших объектов повышается до поколения 1. Если все объекты поколения 0 были проверены, но всё равно требуется дополнительное пространство, то будет запцщени проверка объектов поколения 1. Объекты этого поколения, которым удалось уцелеть, станут объектами поколения 2. если же сборщику мусора всё равно понадобится память, что сборке мусора подвергнуться объекты поколения 2. Так как объектов выше 2 поколения не бывает, то статус объектов не изменится.
Из всего вышесказанного можно сделать вывод, что более новые объекты будут удалятся быстрее, нежели более старые.
Сборка мусора, управление памятью и указатели
Сборщик мусора в C#
Ранее в теме Типы значений и ссылочные типы мы рассматривали отдельные типы данных и как они располагаются в памяти. Так, при использовании переменных типов значений в методе, все значения этих переменных попадают в стек. После завершения работы метода стек очищается.
При использовании же ссылочных типов, например, объектов классов, для них также будет отводиться место в стеке, только там будет храниться не значение, а адрес на участок памяти в хипе или куче, в котором и будут находиться сами значения данного объекта. И если объект класса перестает использоваться, то при очистке стека ссылка на участок памяти также очищается, однако это не приводит к немедленной очистке самого участка памяти в куче. Впоследствии сборщик мусора (garbage collector) увидит, что на данный участок памяти больше нет ссылок, и очистит его.
В методе Test создается объект Country. С помощью оператора new в куче для хранения объекта CLR выделяет участок памяти. А в стек добавляет адрес на этот участок памяти. В главном методе Main мы вызываем метод Test. И после того, как Test отработает, место в стеке очищается, а сборщик мусора очищает ранее выделенный под хранение объекта country участок памяти.
Сборщик мусора не запускается сразу после удаления из стека ссылки на объект, размещенный в куче. Он запускается в то время, когда среда CLR обнаружит в этом потребность, например, когда программе требуется дополнительная память.
Как правило, объекты в куче располагаются неупорядочено, между ними могут иметься пустоты. Куча довольно сильно фрагментирована. Поэтому после очистки памяти в результате очередной сборки мусора оставшиеся объекты перемещаются в один непрерывный блок памяти. Вместе с этим происходит обновление ссылок, чтобы они правильно указывали на новые адреса объектов.
Несмотря на то что, на сжатие занятого пространства требуется время, да и приложение не сможет продолжать работу, пока не отработает сборщик мусора, однако благодаря подобному подходу также происходит оптимизация приложения. Теперь чтобы найти свободное место в куче среде CLR не надо искать островки пустого пространства среди занятых блоков. Ей достаточно обратиться к указателю кучи, который указывает на свободный участок памяти, что уменьшает количество обращений к памяти.
Кроме того, чтобы снизить издержки от работы сборщика мусора, все объекты в куче разделяются по поколениям. Всего существует три поколения объектов: 0, 1 и 2-е.
Когда сборщик мусора приступает к работе, он сначала анализирует объекты из поколению 0. Те объекты, которые остаются актуальными после очистки, повышаются до поколения 1.
Если после обработки объектов поколения 0 все еще необходима дополнительная память, то сборщик мусора приступает к объектам из поколения 1. Те объекты, на которые уже нет ссылок, уничтожаются, а те, которые по-прежнему актуальны, повышаются до поколения 2.
Поскольку объекты из поколения 0 являются более молодыми и нередко находятся в адресном пространстве памяти рядом друг с другом, то их удаление проходит с наименьшими издержками.
Класс System.GC
Рассмотрим некоторые методы и свойства класса System.GC:
Метод Collect приводит в действие механизм сборки мусора. Перегруженные версии метода позволяют указать поколение объектов, вплоть до которого надо произвести сборку мусора
Метод GetGeneration(Object) позволяет определить номер поколения, к которому относится переданый в качестве параметра объект
Метод GetTotalMemory возвращает объем памяти в байтах, которое занято в управляемой куче
Метод WaitForPendingFinalizers приостанавливает работу текущего потока до освобождения всех объектов, для которых производится сборка мусора
Работать с методами System.GC очень просто:
Default : значение по умолчанию для данного перечисления (Forced)
Forced : вызывает немедленное выполнение сборки мусора
Optimized : позволяет сборщику мусора определить, является ли текущий момент оптимальным для сборки мусора
Garbage Collector & C++
Ручное управление памятью с С++ — одновременно один из самых больших плюсов и минусов в языке. Действительно, эта парадигма позволяет создавать очень производительные программы, однако она же рождает и кучу проблем. Существует несколько способов избавится от них. Я попробовал несколько и в итоге пришел к сборщику мусора. В этой статье я хочу изложить не столько реализацию сборщика мусора на С++, сколько историю идеи и его использования, каково им пользоваться и почему в итоге от него отказался.
Итак, как у большинства программистов у меня есть свой проект, а именно двумерный игровой движок. На нем и производились все «эксперименты».
Этап первый: отладка памяти
Самая часто встречающаяся проблема при ручном управлении памятью — это утечки. Чтобы узнать о них нужно следить за памятью. Именно таков и был мой первоначальных подход. Следим за созданием и удалением объектов, проверяем после завершения программы что осталось не удаленным. Делается очень просто: перегружаем операторы new и delete, чтобы они могли принимать в параметрах имя файла исходного кода и строчку, откуда происходит аллокация, и храним все аллокации в одном месте. Для удобства объявляем макрос, который и передает имя файла и строку в оператор. Соответственно при удалении объекта удаляем запись о соответствующей аллокации.
Этап второй: умные указатели
И правда же, самое распространенное решение проблемы управления памятью — умные указатели. О них вас обязательно спросят на собеседовании. Идея проста: вместо обычных указателей мы используем шаблонные классы, которые работают как указатели, но имеют дополнительный функционал для автоматического освобождения объектов. Вместе с объектом храним счетчик ссылок, при присваивании значения указателю увеличиваем счетчик, при уничтожении указателя — уменьшаем. Если счетчик равен нулю — объект никому не нужен и его можно освободить. Однако, есть небольшая проблема — если два объекта ссылаются друг на друга, они никогда не будут освобождены, так как у обоих счетчик ссылок равен единице.
Слабые указатели решают эту проблему зацикленности. Один из указателей делается слабым, и теперь счетчики ссылок равны нулю и оба объекта могут быть освобождены.
Что ж, давайте придумаем ситуацию посложнее.
Такую ситуацию тоже можно разрешить с помощью слабых/сильных указателей, но будет ли это легче ручного управления? Если программист что-то сделает неправильно, результат будет такой же: утекший неосвобожденный объект. На самом деле, такие ситуации встречаются редко и в целом такой подход значительно упрощает работу с памятью.
Этап третий: велосипедостроительство
Опробовав умные указатели, я остался неудовлетворен. Просто хочется использовать один у тот же тип умного указателя везде и не задумываться о зацикленных ссылках. Пусть он сам о них задумывается. Но как это сделать? Представим ситуацию:
Нужно как-то узнать что два нижних объекта зациклены и их можно удалить, ведь на них никто не ссылается. По рисунку уже несложно догадаться: если ссылки от объекта не ведут к верхнеуровневым объектам, значит он может быть освобожден. Верхнеуровневые объекты — это, грубо говоря, те объекты, с которых начинается инициализация приложения. Для С++ это объекты на стеке и статические.
Этап четвертый: сборщик мусора
Думаю, внимательный читатель уже понял, что это и есть схема работы сборщика мусора, только наоборот. Самый простой сборщик работает примерно так: спускаясь по ссылкам от верхнеуровневых объектов помечаем всех как актуальные, после этого мы имеем право удалить те, что оказались не помеченными.
Для реализации нам потребуется такой же шаблонный класс указателя, как и в случае с умными указателями. Плюс нужен сам сборщик, который будет за всем следить и производить собственно сборку мусора.
Для каждого созданного объекта создается мета-информация ObjectInfo и хранится в менеджере памяти MemoryManager. Каждый такой объект создается перегруженным оператором new. ObjectInfo хранит в себе информацию о размере объекта, месте, откуда он был создан, список указателей на него и указатель на функцию для вызова деструктора этого объекта.
Для работы с объектами вместо привычных указателей используются шаблонные классы Ptr и RootPtr. RootPtr необходим для обозначения верхнеуровневых объектов, так как в ходе выполнения программы невозможно узнать что объект создан на стеке или статически (поправьте меня, если я не прав). При инициализации или копировании указателей происходит добавление и удаление указателей из соответствующих ObjectInfo.
При вызове сборки мусора меняется булевая переменная маркера на противоположную и начинается цикл отметки объектов. Цикл проходит по верхнеуровневым указателям, затем рекурсивно по их дочерним указателям. Дочерний указатель — это тот, что находится в теле объекта. После этого все объекты, у которых маркер не совпадает с текущим удаляются.
Внимательный читатель наверняка заметил еще одно — за удобство мы платим производительностью. Мы получаем все типичные минусы сборщиков мусора: излишний расход памяти и большое время работы сборки мусора. Именно на моем проекте игрового движка это фатальный минус, ведь на каждый кадр должно уходить несколько миллисекунд и просто нет времени чтобы все остановить и произвести сборку мусора. Однако, есть и хороший момент, этот сборщик мусора полностью наш и мы можем делать все что захотим!
Этап пятый: импровизация
Первое что приходит в голову — это то, что мы полностью управляем моментом сборки мусора. Совсем не обязательно делать это посреди геймплея. Можно сделать это как раз тогда, когда и происходит наибольшее количество инициализаций и уничтожений объектов, во время загрузки уровней. При разработке игр не принято делать много аллокаций/уничтожений во время активной игры. Если при каждом выстреле загружать и создавать объект пули, никакая оптимизация памяти вас не спасет. Поэтому делать долгую сборку мусора между уровнями кажется довольно живучей идеей.
Следующая идея — вовсе не вызывать сборку мусора или делать это крайне редко. Объекты по-прежнему удалять вручную или умными указателями, а сборщик мусора можно использовать в роли отладчика и подстраховки. В таком варианте мы получим производительность эквивалентную ручному управлению памятью и безопасность от утечек при сборке мусора.
Таким образом мы можем использовать сборщик по-разному. При быстром прототипировании нас не заботит производительность, но нужна стабильность, в этом случае используем автоматическую сборку. В обычной ситуации стараемся вручную управлять памятью, а сборщик нам подсказывает и подстраховывает. Ну и конечно же его можно просто выключить и перейти к полностью ручному управлению.
Помимо этого можно реализовать еще немного «вкусностей». Так как мы используем шаблонные классы-указатели, мы можем проверять их на корректность, то есть при удалении объекта вручную делать все ссылки на него невалидными. И далее в нужных местах их проверять. Еще одно из возможных улучшений — это дефрагментация памяти. На этапе сборки мусора можно перерасположить актуальные объекты в памяти для уменьшения кеш-промахов процессора, что несомненно даст прирост производительности.
Этап шестой: возврат к началу
В конце концов на решение об отказе от сборщика повлияла специфика моего проекта. Проект планируется с открытым исходным кодом и он нацелен прежде всего на удобство использования. Усложнение и без того сложного синтаксиса С++ специфичными указателями и добавление сборщика мусора несомненно плохо повлияют на проект. Просто представьте знакомство разработчика с новой технологией: ему необходимо изучать новое API да еще и с мудреной моделью управления памятью, тем более, что большинство программистов С++ и так неплохо управляются с памятью вручную.
Окончательно я убедился в возврате к ручной модели когда принял решение использовать скрипты. Именно они нужны для простоты и удобства. Делаешь прототип или простую игру — используй скрипты, экономь время и ресурсы. Необходима гибкость и производительность — добро пожаловать в С++. Тем, кто будет использовать С++ на самом деле вряд ли понадобится сборщик мусора.
Вот так я и вернулся к началу. Надеюсь мой опыт будет полезен или хотя бы интересен другим велосипедостроителям.
Garbage Collector. Полный курс + перевод из BOTR
В данной статье вы встретите сразу два источника информации:
1. CLRium #5: Полный курс по Garbage Collector
2. Устройство сборщика мусора by Maoni Stephens (@maoni0)
Архитектура компонентов
Сборка мусора связана с двумя компонентами: распределителем и сборщиком. Распределитель отвечает за выделение памяти и вызов сборщика при необходимости. Сборщик собирает мусор или память объектов, которые больше не используются программой.
Есть и другие способы вызвать сборщик, например вручную, с помощью GC.Collect. Также поток финализатора может получить асинхронное уведомление о том, что память заканчивается (что вызовет сборщик).
Устройство распределителя
Распределитель вызывается вспомогательными компонентами среды выполнения с указанием следующей информации:
Сборщик мусора не предусматривает специальных методов обработки для разных типов объектов. Он получает информацию о размере объекта от среды выполнения.
В зависимости от размера, сборщик делит объекты на две категории: маленькие ( = 85 000 байт). В целом сборка маленьких и больших объектов может происходить одинаково. Однако сборщик разделяет их по размеру, поскольку сжатие больших объектов требует много ресурсов.
Сборщик мусора выделяет память распределителю с учётом контекстов выделения. Размер контекста выделения определяется блоками выделяемой памяти.
Контексты выделения – небольшие области определённого сегмента кучи, каждая из которых предназначена для определённого потока исполнения. На машине с одним процессором (имеется в виду 1 логический процессор) используется один контекст выделения памяти для объектов поколения 0.
Блок выделяемой памяти – объём памяти, выделяемый распределителем каждый раз, когда ему требуется больше памяти, чтобы расположить объект внутри области. Размер блока обычно составляет 8 Кб, а средний размер управляемых объектов – 35 байт. Поэтому в одном блоке можно расположить множество объектов.
Крупные объекты не используют контексты и блоки. Один большой объект может быть крупнее, чем эти маленькие участки памяти. Кроме того, преимущества использования этих участков (описано ниже) проявляются только при работе с маленькими объектами. Пространство для больших объектов выделяется напрямую в сегменте кучи.
Распределитель устроен таким образом, чтобы:
вызывать сборщик мусора, когда необходимо: распределитель вызывает сборщик, когда объём памяти, выделенной для объектов, превышает пороговое значение (установленное сборщиком), или если распределитель больше не может выделять память в данном сегменте. Пороговые значения и управляемые сегменты будут подробно описаны далее.
сохранить местоположение объектов: объекты, находящиеся вместе в одном сегменте кучи, хранятся по виртуальным адресам близким друг к другу.
эффективно использовать кэш: распределитель выделяет память блоками, а не для каждого объекта. Он обнуляет так много памяти, чтобы подготовить кэш процессора, поскольку некоторые объекты будут размещены прямо в нём. Блок выделяемой памяти обычно равен 8 Кб.
эффективно ограничивать область, выделенную потоку исполнения: близость контекстов и блоков памяти, выделенных для потока, гарантирует, что только один поток будет записывать данные в выделенный участок пространства. В результате не нужно ограничивать выделение памяти, пока пространство в текущем контексте выделения ещё не закончилось.
обеспечивать целостность памяти: сборщик мусора всегда обнуляет память для вновь размещаемых объектов, чтобы их ссылки не указывали на произвольные участки памяти.
обеспечивать непрерывность кучи: распределитель создаёт свободный объект из оставшейся памяти в каждом выделенном блоке. Например, если в блоке осталось 30 байт, а для размещения следующего объекта необходимо 40 байт, распределитель превратит эти 30 байт в свободный объект и запросит новый блок памяти.
С помощью указанных функций можно выделять память как для маленьких, так и больших объектов. Существует функция для выделения места прямо в куче больших объектов (LOH):
Устройство сборщика
Задачи сборщика мусора
GC предназначен для эффективного управления памятью. Разработчики, которые пишут управляемый код, смогут использовать его без особых усилий. Эффективное управление означает что:
Логическое описание управляемой кучи
Сборщик мусора CLR собирает объекты, логически разделённые по поколениям. После сборки объектов в поколении N, оставшиеся объекты маркируются как принадлежащие поколению N+1. Этот процесс называется продвижение объектов по поколениям. В этом процессе существуют исключения, когда необходимо перевести объект в поколение ниже или не продвигать его вообще.
В случае маленьких объектов куча делится на три поколения: gen0, gen1 и gen2. Для больших объектов существует только одно поколение – gen3. Gen0 и gen1 называют эфемерными поколениями (объекты живут в них короткое время).
Для кучи небольших объектов номер поколения означает их возраст. Например, gen0 – самое молодое поколение. Это не значит, что все объекты в gen0 моложе объектов в gen1 или gen2. Здесь существуют исключения, которые описаны ниже. Сборка поколения означает сборку объектов в этом поколении, а также во всех его более молодых поколениях.
Теоретически сборка больших и маленьких объектов может происходить одинаково. Однако поскольку сжатие крупных объектов требует много ресурсов, их сборка происходит по-другому. Большие объекты содержатся только в gen2 и собираются только во время сборки мусора в этом поколении из соображений производительности. Как gen2, так и gen3 могут быть большими, а сборка объекта в эфемерных поколениях (gen0 и gen1) не должна быть слишком ресурсозатратной.
Объекты размещаются в самом младшем поколении. Для маленьких объектов это gen0, а для больших – gen3.
Физическое описание управляемой кучи
Управляемая куча состоит из набора сегментов. Сегмент – непрерывный блок памяти, который ОС передаёт сборщику мусора. Сегменты кучи разбиваются на мелкие и большие участки для размещения маленьких и больших объектов. Сегменты каждой кучи связаны вместе. По крайней мере один сегмент для маленького объекта и один для большого резервируются при загрузке CLR.
В каждой куче маленьких объектов есть только один эфемерный сегмент, где находятся поколения gen0 и gen1. Этот сегмент может содержать или не содержать объекты поколения gen2. Кроме эфемерных сегментов, могут существовать один или более дополнительных сегментов, которые будут сегментами gen2, так как они содержат объекты поколения 2.
Куча больших объектов состоит из одного или более сегментов.
Сегмент кучи заполняется от младших адресов к старшим. Это означает, что объекты, расположенные по младшим адресам сегмента, старше чем те, что находятся по старшим. Здесь также есть исключения, которые описаны ниже.
Сегменты кучи выделяются по мере необходимости. Если они не содержат используемых объектов, сегменты удаляются. Однако начальный сегмент в куче всегда существует. За один раз для каждой кучи выделяется один сегмент. В случае маленьких объектов это происходит во время сборки мусора, а для больших – во время выделения памяти для них. Такая схема повышает производительность, поскольку большие объекты собираются только в поколении 2 (что требует много ресурсов).
Сегменты кучи соединены вместе в порядке выделения. Последний сегмент в цепочке всегда является эфемерным. Сегменты, в которых собраны все объекты, можно использовать заново, например в качестве эфемерных. Повторное использование сегментов применяется только для кучи маленьких объектов. Для размещения больших объектов каждый раз рассматривается вся куча больших объектов. Маленькие объекты размещаются только в эфемерных сегментах.
Пороговое значение объёма выделенной памяти
Это логическое понятие, связанное с размером каждого поколения. Если он превышен, в поколении начинается сборка мусора.
Пороговое значение для определённого поколения устанавливается в зависимости от количества выживающих в этом поколении объектов. Если это количество высоко, пороговое значение становится выше. При этом ожидается, что соотношение используемых и неиспользуемых объектов будет лучше во время следующей сессии сборки мусора в этом поколении.
Выбор поколения для сборки мусора
При активации сборщик должен определить, в каком поколении проводить сборку. Кроме порогового значения, на этот выбор влияют и другие факторы:
Процесс сборки мусора
Этап маркировки
Во время этого этапа CLR должна найти все живые объекты.
Преимущество сборщика с поддержкой поколений заключается в его способности убирать мусор только в части кучи, вместо того, чтобы всё время наблюдать за всеми объектами. Собирая мусор в эфемерных поколениях, сборщик должен получить у среды исполнения информацию о том, какие объекты в этих поколениях по-прежнему используются программой. Кроме того, объекты в старших поколениях могут использовать объекты в младших поколениях, ссылаясь на них.
Чтобы пометить старые объекты, ссылающиеся на новые, сборщик мусора использует специальные биты. Биты устанавливаются механизмом JIT-компилятора во время операций присвоения. Если объект принадлежит к эфемерному поколению, JIT-компилятор установит байт, содержащий бит, указывающий на исходное положение. Собирая мусор в эфемерных поколениях, сборщик может использовать эти биты для всей оставшейся кучи и просмотреть только те объекты, которым эти биты соответствуют.
Этап планирования
На этом этапе моделируется сжатие, чтобы определить его эффективность. Если результат оказывается продуктивным, сборщик начинает фактическое сжатие. В противном случае он просто производит уборку.
Этап перемещения
Если сборщик выполняет сжатие, это приведёт к перемещению объектов. В этом случае необходимо обновить ссылки на эти объекты. Во время этапа перемещения сборщик должен найти все ссылки, указывающие на объекты в тех поколениях, где проводится сборка мусора. Напротив, во время стадии маркировки сборщик помечает только живые объекты, поэтому ему не нужно рассматривать слабые ссылки.
Этап сжатия
Этот этап достаточно прост, поскольку сборщик уже определил новые адреса для перемещения объектов во время этапа планирования. При сжатии объекты будут скопированы по этим адресам.
Этап уборки
Во время этого этапа сборщик ищет неиспользуемое пространство между живыми объектами. Вместо этого пространства он создаёт свободные объекты. Неиспользуемые объекты, находящиеся рядом, становятся одним свободным объектом. Все свободные объекты помещаются в список свободных объектов.
Code flow
Функциональное поведение
WKS GC без параллельной сборки мусора
WKS GC с параллельной сборкой мусора
Этот алгоритм описывает фоновую сборку мусора.
SVR GC без параллельной сборки мусора
SVR GC с параллельной сборкой мусора
Алгоритм такой же, как и в случае с параллельной сборкой мусора в режиме рабочей станции, только нефоновая сборка выполняется в серверных потоках.
Физическая архитектура
Эта секция поможет понять code flow.
Если сборка мусора в режиме рабочей станции производится непараллельно, GarbageCollectGeneration выполняется в пользовательском потоке, который вызвал сборщик мусора. Поток кода выглядит следующим образом:
Если выполняется параллельная сборка мусора в режиме рабочей станции (по умолчанию), поток кода для фоновой сборки мусора выглядит так: