Автоматическое управление памятью в c
По управлению памятью современные языки программирования и средства разработки можно условно разделить на те, где программист ответственен за управление памятью и обязан вовремя освобождать неиспользуемые блоки, и на языки с так называемой сборкой мусора. Рассмотрим основополагающие принципы и тех и других. Кроме того, в этой статье я предложу способы автоматизации управления памятью в тех системах, где управлением памятью занимается программист, а также плюсы и минусы сборки мусора.
Управление памятью под ответственностью программиста
В языке C++ (именно на его примере рассмотрим системы, где ответственность за своевременное освобождение блоков памяти лежит на программисте) существуют предусмотренные стандартом C++, а также перешедшие "в наследство" от C средства выделения и освобождения памяти. Кроме того, каждая платформа предоставляет свои средства управления памятью (как правило, стандартные языковые средства как раз к ним и обращаются, но у программиста есть возможность обратиться к ним непосредственно). Проблема этих систем и языков (в том числе C++) состоит в том, что программист сам отвечает за своевременное освобождение блоков памяти после того, как в них отпадает необходимость - для этого тоже есть специальные функции. Если программист забудет освободить блок, впоследствии может возникнуть нехватка памяти - виртуальное адресное пространство процесса "забьется" и свободного места не останется, хотя в то же время в памяти будут присутствовать блоки, которые фактически не используются.
Рассмотрим коротко средства управления памятью.
Оператор new очень удобен - он не требует указания размера блока, который нужно выделить. Размер определяется компилятором автоматически исходя из типа, который указывается после ключевого слова new. Кроме вызова самой функции выделения памяти происходит еще и вызов конструктора объекта, если указан объектный тип. Синтаксис вызова оператора имеет несколько вариантов, рассмотрим наиболее часто употребляемый:
где Type - тип создаваемого объекта, на основе которого компилятор автоматически определит требуемый для него объем памяти;
parameters - необязательный список параметров, который будет передан конструктору объекта. В случае отсутствия списка параметров скобки указывать необязательно.
Функция malloc, доставшаяся языку C++ в наследство от C, требует указания необходимого количества байт и не производит кроме собственно выделения памяти никаких дополнительных действий.
Функции операционной системы Windows - LocalAlloc и GlobalAlloc - считаются устаревшими, хотя и поддерживаются в целях совместимости. Современным приложениям рекомендуется пользоваться HeapAlloc, а также VirtualAlloc, которая, помимо выделения памяти, поддерживает операцию резервирования памяти и выделения зарезервированной памяти.
Соответственно, средства освобождения памяти следующие.
Оператор delete освобождает занятый приложением блок, но перед этим вызывает деструктор объекта, если переменная объектного типа. Ничего, кроме параметра, содержащего адрес удаляемого блока, передавать не надо.
Функция free () освобождает выделенный с помощью malloc () блок. Следует передать адрес блока.
Функции Windows по освобождению памяти называются LocalFree, GlobalFree, HeapFree и VirtualFree.
Объясню суть проблемы такого управления памятью подробнее. Допустим, программисту нужно в цикле копировать файлы. Для этого он выделяет блок памяти необходимого размера, читает туда содержимое файла-источника, затем пишет содержимое блока в файл-приемник. Допустим теперь, что перед выходом из цикла человек забыл освободить выделенный блок. Начинается следующая итерация цикла, и происходит новое выделение памяти. Переменная, которая раньше содержала адрес первого блока, содержит теперь новый адрес - таким образом, адрес первого блока теряется! Значит, если вовремя не освободить выделенную память, есть вероятность, что адрес этого блока будет навсегда "забыт" приложением, и оно никогда не сможет его освободить.
Вообще-то, по завершении приложения операционная система просто полностью уничтожает виртуальное адресное пространство процесса, так что об этом, казалось бы, беспокоиться даже не стоит - память освободится автоматически при окончании работы программы. Однако, как упоминалось выше, неосвобожденные блоки будут накапливаться, а доступная для выделения свободная память будет заканчиваться, и это может привести к ситуации, когда система откажет вам в очередном выделении памяти. Особенно эта ситуация вероятна тогда, когда программист забывает об освобождении в цикле, тем более если тот имеет много итераций.
Сборщик мусора как средство автоматического контроля памяти
Как это происходит? На самом деле всё достаточно просто - достаточно к каждому объекту "привязать" счетчик ссылок, который увеличивается каждый раз, когда на выделенный блок памяти ссылается какая-то переменная, и уменьшается, когда в эту переменную записывается другое значение, т. е. ссылка на объект теряется. К примеру, мы объявили некоторую переменную и тут же создали объект, присвоив ссылку на него этой переменной. Тут же счетчик ссылок этого объекта становится равным 1, т. к. наша переменная ссылается на объект. Теперь мы вызываем некоторую функцию, в качестве одного из параметров которой передаем нашу переменную. Тут счетчик ссылок следует опять увеличить, и он станет равным 2. Естественно, в момент выхода из вызванной функции он вернется в состояние 1. Если мы объявим вторую переменную и присвоим ей значение первой - счетчик вновь увеличится. Как только счетчик ссылок достигает нуля, объект можно удалять, поскольку адрес объекта нигде, ни в какой переменной или блоке памяти не содержится, таким образом, программа "забыла" ее адрес и всё равно никак не сможет прочитать или изменить объект.
В некоторых системах удаление объектов производится именно в тот момент, когда счетчик ссылок становится нулевым. В некоторых момент освобождения памяти объекта откладывается, пока у процессора не появится свободное время (он не будет ничем особенно занят) или свободной памяти не останется.
Возможности по реализации автоматического управления памятью в C++
Несмотря на отсутствие сборки мусора в C++, его, в общем-то, несложно реализовать самому. Предлагаю один из примеров такой реализации.
1. Опишем базовый класс, который будет прародителем всех объектных классов. Назовем его, скажем, CUniObject. Единственным членом класса будет счетчик ссылок (unsigned int count). Счетчик ссылок будет приватным.
2. Описываем класс универсального указателя CUniPointer и вместо указателей на объекты будем создавать экземпляры этого класса. Для поддержки безопасности типов класс CUniPointer следует объявить шаблонным (например, так: template class CUniPointer<typename basetype>), где в качестве параметра будет использоваться тип, на который мы хотим указывать. Тип, очевидно, будет производным от CUniObject, поэтому класс CUniPointer может смело ссылаться на basetype::count. Собственно указатель будет являться членом этого класса: basetype *pointer.
3. Чтобы класс указателя мог иметь непосредственный доступ к счетчику ссылок своего типа-параметра, класс CUniPointer следует объявить дружественным в каждом производном классе от CUniObject (friend class CUniPointer<ЭтотКласс>).
4. Объявим два конструктора: первый - конструктор по умолчанию - предназначен для создания неинициализированных объектов-указателей (pointer = NULL), второй - для инициализированных. Последний будет принимать параметром ссылку на другой объект. Другая версия инициализирующего конструктора будет принимать параметр-указатель (для того, чтобы мы могли объявить переменную вот так: CUniPointer<SomeType> ptr = new SomeType). Причем конструктор по умолчанию инициализирует pointer значением NULL, счетчик ссылок при этом, конечно, не затрагивается. Остальные конструкторы увеличивают счетчик ссылок объекта, находящегося по адресу pointer, на 1: pointer->count++.
5. В момент присваивания объекту-указателю нового значения счетчики ссылок старого объекта и нового объекта следует также менять. Для этого перегрузим оператор = в классе CUniPointer примерно таким образом:
Таким образом, если раньше объект-указатель на что-то указывал, счетчик ссылок этого объекта уменьшим, поскольку теперь объект-указатель на него указывать не будет. Напротив, счетчик ссылок нового объекта увеличим, конечно, в том случае, если новый указатель не пуст.
6. Деструктор объекта-указателя должен также уменьшать счетчик ссылок, если pointer не пуст.
7. Остается добавить в оператор = и в деструктор специальный код, проверяющий счетчик ссылок объекта на равенство нулю, и уничтожающий объект в случае равенства. Таким образом, окончательный вид оператора = будет таков:
Дополнительно ко всему этому можно перегрузить оператор = с параметром типа CUniPointer<basetype> и реализовать оператор ->, который упростит доступ к членам объекта pointer, так, что объект CUniPointer будет работать как обычный указатель:
В результате этих действий мы, по сути, получим эмулятор сборщика мусора.
Замечу, что в стандарте C++ предусмотрен специальный класс - автоуказатель - который несколько похож на описанный выше CUniPointer и самостоятельно уничтожает содержащийся внутри него указатель в своём деструкторе. Это класс template <typename _Type> class autoptr, и находится он в пространстве имен std. Есть очень важное отличие: autoptr не содержит счетчика ссылок и освобождает память в любом случае. Значит, если вы опишете функцию, в которой объявите экземпляр переменной autoptr, и значение указателя, содержащегося в ней, вернёте этой функцией, вы, по сути, вернете указатель на свободную память, не занятую никаким объектом, т. к. при выходе из вашей функции объект autoptr будет уничтожен и вместе с ним будет уничтожен ваш указатель. Поэтому autoptr - это не сборщик мусора, а именно автоматически уничтожаемый указатель. Тем не менее, он находит применения.
Остается только отметить, что и класс autoptr, и CUniPtr обладают хорошим свойством: прекрасно работают в условиях обработки исключений. Другими словами, объект autoptr или CUniPtr автоматически будет уменьшать счетчик ссылок и уничтожать указатель при условии счетчик = 0 даже тогда, когда произойдет исключение.
В следующей статье я расскажу об управлении памятью "изнутри" - как устроена система управления памятью с точки зрения операционных систем, языков программирования, сред разработки.
Для автоматического управления памятью используется одна из служб, которые среда CLR предоставляет при управляемом выполнении. Сборщик мусора среды CLR управляет освобождением и выделением памяти для приложения. Для разработчиков это означает, что при разработке управляемого приложения не нужно писать код для управления памятью. Автоматическое управление памятью позволяет устранить распространенные проблемы, такие как не освобожденный по забывчивости объект, вызывающий утечку памяти, или попытки доступа к памяти для уже удаленного объекта. В этом разделе описано, каким образом сборщик мусора выделяет и освобождает память.
Выделение памяти
При инициализации нового процесса среда выполнения резервирует для него непрерывную область адресного пространства. Это зарезервированное адресное пространство называется управляемой кучей. Эта управляемая куча содержит указатель адреса, с которого будет выделена память для следующего объекта в куче. Изначально этот указатель устанавливается в базовый адрес управляемой кучи. Все ссылочные типы размещаются в управляемой куче. Когда приложение создает первый ссылочный тип, память для него выделяется, начиная с базового адреса управляемой кучи. При создании приложением следующего объекта сборщик мусора выделяет для него память в адресном пространстве, непосредственно следующем за первым объектом. Пока имеется доступное адресное пространство, сборщик мусора продолжает выделять пространство для новых объектов по этой схеме.
Выделение памяти из управляемой кучи происходит быстрее, чем неуправляемое выделение памяти. Поскольку среда выполнения выделяет память для объекта путем добавления значения к указателю, это осуществляется почти так же быстро, как выделение памяти из стека. Кроме того, поскольку выделяемые последовательно новые объекты и располагаются последовательно в управляемой куче, приложение может получать доступ к объектам очень быстро.
Освобождение памяти
Механизм оптимизации сборщика мусора определяет наилучшее время для выполнения сбора, основываясь на произведенных выделениях памяти. Когда сборщик мусора выполняет очистку, он освобождает память, выделенную для объектов, которые больше не используются приложением. Он определяет, какие объекты больше не используются, основываясь на корнях приложения. Каждое приложение имеет набор корней. Каждый корень либо ссылается на объект, находящийся в управляемой куче, либо имеет значение NULL. Корни приложения содержат статические поля, локальные переменные и параметры стека потока, а также регистры процессора. Сборщик мусора имеет доступ к списку активных корней, которые поддерживаются JIT-компилятором и средой выполнения. С помощью этого списка он проверяет корни приложения и в процессе проверки создает граф, содержащий все объекты, к которым можно получить доступ из этих корней.
Объекты, не входящие в этот граф, являются недостижимыми из данных корней приложения. Сборщик мусора считает недостижимые объекты мусором и будет освобождать выделенную для них память. В процессе очистки сборщик мусора проверяет управляемую кучу, отыскивая блоки адресного пространства, занятые недостижимыми объектами. При обнаружении недостижимого объекта он использует функцию копирования памяти для уплотнения достижимых объектов в памяти, освобождая блоки адресного пространства, выделенные под недостижимые объекты. После уплотнения памяти, занимаемой достижимыми объектами, сборщик мусора вносит необходимые поправки в указатель, чтобы корни приложения указывали на новые расположения объектов. Он также устанавливает указатель управляемой кучи в положение после последнего достижимого объекта. Обратите внимание, что память уплотняется, только если при очистке обнаруживается значительное число недостижимых объектов. Если после сборки мусора все объекты в управляемой куче остаются на месте, то уплотнение памяти не требуется.
Для повышения производительности среда выполнения выделяет память для больших объектов в отдельной куче. Сборщик мусора автоматически освобождает память, выделенную для больших объектов. Однако для устранения перемещений в памяти больших объектов эта память не сжимается.
Поколения и производительность
Для оптимизации производительности сборщика мусора управляемая куча делится на три поколения: 0, 1 и 2. Алгоритм сборки мусора в среде выполнения основан на ряде обобщений, к которым пришла программная индустрия в процессе экспериментов со схемами сборки мусора. Во-первых, уплотнять память для части управляемой кучи быстрее, чем для всей кучи. Во-вторых, более новые объекты имеют меньшее время жизни, а более старые объекты имеют большее время жизни. Наконец, более новые объекты теснее связаны друг с другом, и приложение обращается к ним приблизительно в одно и то же время.
Сборщик мусора среды выполнения хранит новые объекты в поколении 0. Уровень объектов, созданных на раннем этапе работы приложения и оставшихся после сборок мусора, повышается, и они сохраняются в поколении 1 и 2. Процесс продвижения объекта по уровням описан далее в этом разделе. Поскольку быстрее сжать часть управляемой кучи, чем всю кучу, эта схема позволяет сборщику мусора освобождать память в определенном поколении, а не освобождать память для всей кучи каждый раз при сборке мусора.
В действительности сборщик мусора выполняет очистку при заполнении поколения 0. Если приложение пытается создать новый объект, когда поколение 0 заполнено, сборщик мусора обнаруживает, что в поколении 0 не осталось свободного адресного пространства для объекта. Сборщик мусора выполняет сборку, пытаясь освободить для этого объекта адресное пространство в поколении 0. Сборщик мусора начинает проверять объекты в поколении 0, а не все объекты в управляемой куче. Это наиболее эффективный подход, поскольку, как правило, новые объекты имеют меньшее время жизни, и можно ожидать, что многие из объектов в поколении 0 к моменту проведения сборки мусора уже не используются приложением. Кроме того, сборка мусора только в поколении 0 зачастую освобождает достаточно памяти для того, чтобы приложение могло продолжить создавать новые объекты.
После того как сборщик мусора выполнит освобождение для поколения 0, он уплотняет память для достижимых объектов, как описано ранее в разделе Освобождение памяти. Затем сборщик мусора продвигает эти объекты и считает эту часть управляемой кучи поколением 1. Так как объекты, оставшиеся после сборки, обычно склонны к долгой жизни, имеет смысл продвинуть их в поколение более высокого уровня. В результате сборщику мусора не обязательно выполнять повторную проверку объектов поколений 1 и 2 при каждой сборке мусора в поколении 0.
После того как сборщик мусора выполнит первую сборку поколения 0 и продвинет доступные объекты в поколение 1, он считает оставшуюся часть управляемой кучи поколением 0. Он продолжает размещать память для новых объектов в поколении 0, до тех пор пока поколение 0 не заполнится и необходимо будет провести следующую сборку. В этот момент оптимизатор сборщика мусора определяет, есть ли необходимость проверки объектов в более старых поколениях. Например, если сборка поколения 0 не освобождает достаточно памяти, чтобы приложение могло успешно завершить создание объекта, сборщик мусора может выполнить сборку мусора поколения 1, а затем поколения 2. Если и это не действие не освободит достаточно памяти, сборщик мусора может выполнить сборку мусора поколений 2, 1, и 0. После каждой сборки сборщик мусора собирает доступные объекты в поколении 0 и продвигает их в поколение 1. Объекты в поколении 1, оставшиеся после сборок, продвигаются в поколение 2. Поскольку сборщик мусора поддерживает только три поколения, объекты в поколении 2, оставшиеся после сборки, остаются в поколении 2 до тех пор, пока они не перестанут быть доступными в результате сборки мусора.
Освобождение памяти для неуправляемых ресурсов
Для большинства объектов, созданных приложением, сборщик мусора автоматически выполнит необходимые задачи по управлению памятью. Однако для неуправляемых ресурсов требуется явная очистка. Основным типом неуправляемых ресурсов являются объекты, образующие упаковку для ресурсов операционной системы, такие как дескриптор файлов, дескриптор окна или сетевое подключение. Хотя сборщик мусора может отслеживать время жизни управляемого объекта, инкапсулирующего неуправляемые ресурсы, он не имеет определенных сведений о том, как освобождать эти ресурсы. При создании объекта, который инкапсулирует неуправляемый ресурс, рекомендуется включить код для очистки неуправляемого ресурса в общий метод Dispose. Метод Dispose позволяет явно освобождать память при завершении работы с объектом. При использовании объекта, который инкапсулирует неуправляемый ресурс, следует помнить о методе Dispose и при необходимости вызывать его. Дополнительные сведения об освобождении неуправляемых ресурсов и пример шаблона для реализации метода Dispose см. в разделе Сборка мусора.
Проблема управления ресурсами вообще и памятью в частности является одной из наиболее животрепещущих при разработке программного обеспечения. Сколько раз после успешного завершения работы над очередным шедевром ваше хорошее настроение портилось из-за того, что служба тестирования выявляла утечку памяти (кто её только просил)? Или вдруг заказчик начинал жаловаться на то, что последняя версия вашего редактора через час работы начинает рисовать не то, что ему хочется видеть, и вскоре вообще перестаёт подавать признаки жизни (подумаешь, всего-то забыл удалить кисточку и карандаш). Ещё хуже, когда приходится перелопачивать мегабайты исходного кода в поисках незакрытых соединений, т.к. с увеличением числа рабочих станций сервер базы данных начинает нещадно тормозить из-за нехватки ресурсов. В конце концов, выясняется, что с закрытыми соединениями программа местами вообще отказывается работать, и принимается «мудрое» решение переписать всё заново.
Безусловно, с опытом разработчики вырабатывают свои правила написания программ и приёмы борьбы с утечкой ресурсов. В таких языках, как C++ и Object Pascal, этому способствует наличие конструкторов и, главным образом, деструкторов объектов. Наиболее продвинутые программисты вообще не используют в своих программах выделение памяти или других ресурсов без использования специальных классов-обёрток. В частности, для работы с памятью используются так называемые «умные» указатели (smart pointers), которые управляют временем жизни объектов, созданных в динамической памяти.
ПАМЯТЬ БОЛЬШЕ НЕ РЕСУРС.
Сборщик мусора
Для хранения объектов CLR использует хип, подобный хипу C++, за тем важным исключением, что хип CLR не фрагментирован. Выделение объектов производится всегда один за другим в последовательных адресах памяти, что позволяет весьма существенно повысить производительность всего приложения (рис. 1-1).
Рис.1. Начальное состояние хипа.
Когда память заканчивается, в дело вступает процесс, который мы опишем ниже. Фактически, основная задача этого процесса сводится к тому, чтобы освободить место в хипе путём дефрагментации неиспользуемой памяти.
Рис. 2. Хип после завершения работы с некоторыми объектами.
Первое, что делает GC во время сборки мусора – это принимает решение о том, что все выделенные блоки памяти в вашей программе - это как раз и есть мусор, и они вам больше не нужны ;o) К счастью, на этом GC не прекращает свою работу. Далее начинается утомительный поиск «живых» указателей на объекты по всем закоулкам приложения. Microsoft называет эти указатели «roots». В процессе поиска GC сканирует глобальную память программы (на рисунках обозначено как Global), стек (Stack – локальные переменные) и даже регистры процессора (CPU). Как мы знаем, каждый поток в программе имеет свой собственный стек, и CLR приходится сканировать их все. Кроме того, интересен тот факт, что CLR умеет работать даже с потоками, которые создавались в неуправляемом коде с использованием функций WinAPI. По крайней мере, следующий тест на MC++ отработал правильно, выдав на консоль результат – «212».
Если бы CLR не знал ничего о созданном без его участия потоке и не просканировал бы его стек, то указатель ptr1 был бы не найден и не расценен как «живой». В этом случае деструктор объекта, на который указывает ptr1, так же был бы вызван во время сборки мусора, то есть до вывода на консоль единицы.
Найдя «живой» указатель, сборщик мусора помечает объект, на который этот указатель указывает, как всё ещё используемый программой и запрашивает информацию о его структуре, чтобы в свою очередь просканировать уже этот объект на наличие указателей на другие объекты. И так далее, пока все указатели в программе не будут обработаны.
Теперь GC знает, какие объекты можно удалить, а какие пока не стоит. Сама по себе задача дефрагментации памяти является тривиальной, и все сложности начинаются позже, когда приходит время для корректировки всех указателей, которые существуют в программе, и присвоения им новых значений, получившихся после дефрагментации. Ну что же, GC только что просканировал всю память программы, почему бы не сделать это ещё раз.
Рис. 3. Хип после сборки мусора.
Тест производительности
Наверняка у вас возникли сомнения в эффективности подобного решения, по крайней мере, у меня они точно были, в связи с чем я решил провести небольшой эксперимент и проверить, как будет себя вести GC в сравнении с хипом C++ при выделении большого числа объектов.
Здесь мы создаём 10 миллионов объектов, каждый из которых создаёт в свою очередь массив байт заданного (maxObjSize) размера, и ещё один объект, который также создаёт массив размером 20 байт. В общей сложности мы имеем 40 миллионов создаваемых объектов. Число одновременно живущих базовых (содержащих в своём составе ещё три) объектов задаётся значением переменной maxListSize, и в нашем тесте будет равняться 1,000, 10,000 и 100,000 элементов. Значением maxObjSize мы будем регулировать размер создаваемого в первом объекте массива.
Кроме того, практический интерес представляет только первая диаграмма, так как именно на ней протекает реальная жизнь 99% всех программ. Создание же такого количества одновременно живущих объектов и таких размеров, как на последующих диаграммах вряд ли имеет место в реальной жизни, поэтому эти результаты для нас будут представлять больше познавательный интерес.
Кроме CLR (GC) в тесте будут участвовать хип C++ (C++), хип Windows (Win) и QuickHeap (QH).
Далее приведены результаты тестов для различного количества одновременно живущих объектов. По горизонтали отмечен размер создаваемого в первом объекте массива (maxObjSize), по вертикали – время выполнения теста.
Рис. 4. Тест для 1,000 объектов.
На первой диаграмме хорошо видно, что автоматическое управление памятью в несколько раз опережает хип Windows и C++ при выделении небольших фрагментов. На отметке 700 байт это опережение заканчивается, и далее уверенно лидируют хипы.
ПРИМЕЧАНИЕ
Безусловный лидер QuickHeap в наших тестах учитываться не будет ввиду своей специфики, и приведён здесь лишь для сравнения.
Рис. 5. Тест для 10,000 объектов.
Увеличим число одновременно живущих объектов в 10 раз. В принципе, картина изменилась не сильно, за исключением того, что на отметке в 400 байт GC начал резко сдавать. И это вполне объяснимо.
Дело в том, что в некоторых случаях сборщик мусора может быть не вызван ни разу за всё время работы программы. В том числе и при её завершении. Посудите сами, зачем убирать мусор в доме, предназначенном на снос. Мы в нашем тесте подбирали число создаваемых объектов таким образом, чтобы сборщик мусора вызывался минимум 2-4 раза.
Аномалия на второй диаграмме обусловлена тем, что число вызовов GC резко возрастает почти до 400! на отметке 400 байт, и более чем до 900 раз при размере массива в 1000 байт.
Размер объекта | Время теста | Вызовы GC |
10 | 0:00:06 | 3 |
100 | 0:00:11 | 3 |
200 | 0:00:16 | 3 |
300 | 0:00:22 | 4 |
400 | 0:00:40 | 394 |
500 | 0:00:53 | 498 |
600 | 0:01:03 | 584 |
700 | 0:01:08 | 529 |
800 | 0:01:19 | 597 |
900 | 0:01:31 | 665 |
1000 | 0:01:57 | 932 |
Продолжим наш тест.
Рис. 6. Тест для 100,000 объектов.
При увеличении числа одновременно живущих объектов ещё на порядок ситуация стала ещё более печальной для GC. Теперь выигрыш заканчивается на 75 байтах, и далее GC начинает отставать, проигрывая в десятки раз, а при размере объектов в 5000 байт GC вообще впал в анабиоз часа на три, в то время как хипы уверенно справились с задачей за несколько минут. Хотя, конечно, этот случай уже совсем клинический. Посудите сами, программа за время своей работы выделяет более 50 гигабайт памяти мелкими кусочками, причём в один и тот же момент времени ей необходимо 400,000 объектов с суммарным размером более 500 мегабайт. Возможно, такие задачи и встречаются в жизни, но наверняка в них используется другой, менее универсальный, но более эффективный механизм работы с памятью. В общем, реально это или не реально, но мы теперь знаем, что нас ожидает в подобном случае.
В тоже время, это совсем не означает, что CLR не может работать с объектами большого размера. Очень даже может, вопрос лишь в том, как много таких объектов в вашей программе. Более того, работа с большими объектами в CLR специально оптимизирована. Для объектов размером более 20 килобайт используется отдельный хип, который отличается от обычного тем, что он никогда не дефрагментируется.
Вообще, алгоритмы работы GC построены во многом исходя из правил, полученных статистическим и опытным путём. В частности, одно из таких правил утверждает, что только что созданные «молодые» объекты имеют наиболее короткое время жизни, а живущие уже давно будут жить ещё долго. Именно в соответствии с этим правилом в CLR существуют понятие поколений (generations). Объекты, выжившие после первой сборки мусора и дефрагментации, объявляются первым поколением. В следующий раз, те объекты из первого поколения, которые опять смогли выжить, перемещаются во второе поколение и уже больше не трогаются, выжившие объекты из нулевого поколения перемещаются в первое. Другими словами, объекты, пережившие две сборки мусора, остаются в хипе навсегда и GC не занимается их дефрагментацией .
Теперь, зная этот факт, мы можем легко объяснить причину резкого падения производительности GC на второй и третьей диаграммах. Всё дело в том, что хип просто был перенаселён вторым поколением и для новых объектов оставалось совсем немного места, из-за чего сборщик мусора вызывался всё чаще и чаще.
Освобождение ресурсов в CLR
Давайте теперь разберёмся с тем, как мы подсчитывали количество вызовов GC, а заодно рассмотрим природу деструкторов и механизм освобождения ресурсов, принятый в CLR.
Для подсчёта вызовов GC мы воспользовались следующим классом, объект которого необходимо создать только один раз:
Для работы с объектами, имеющими метод Finalize, CLR использует следующий механизм. При создании объекта, если он содержит метод Finalize, ссылка на него помещается в специальный список, называемый Finalization Queue (рис. 7).
Рис. 7. Управляемый хип и Finalization Queue.
Рис. 8. Хип после завершения работы с объектами A, D, E, H.
После того, как GC определяет, что какой-либо объект можно удалить, ссылка на этот объект ищется в Finalization Queue, и если находится, то объект оставляется в покое до следующей сборки мусора, а ссылка на него из Finalization Queue удаляется и добавляется в другой список, называемый F-reachable Queue.
Рис. 9. Хип после первой сборки мусора.
Далее этим списком занимается специально созданный для этого поток, который по очереди вызывает методы Finalize для объектов из F-reachable Queue, а затем удаляет их и из этого списка.
Рис. 10. GC вызвал методы Finalize для объектов D и H и теперь они просто мусор.
Рис. 11. Хип после второй сборки мусора.
Зачем нужны такие сложности? Дело в том, что по идее деструктор вызывается после того, как объект уже никто не использует, но нам никто не мешает прямо в деструкторе сохранить указатель на наш объект в какой-нибудь глобальной переменной и использовать его в дальнейшем без особых угрызений совести. При описанной выше схеме с объектом ничего не случится, и им можно будет спокойно пользоваться сколь угодно долго. Единственное отличие заключается в том, что после того, как наш объект станет не нужен, метод Finalize для него вызван уже не будет, так как ссылка на наш объект уже отсутствует в Finalization Queue. Но, как вы уже догадались, и эта ситуация исправима. Метод ReRegisterForFinalize, который мы используем в нашем примере, как раз и позволяет вернуть наш объект обратно в Finalization Queue.
Как видите, наличие деструкторов у объектов не предвещает ничего хорошего для вашей программы. Такие объекты не удаляются первой же сборкой мусора и у них больше шансов попасть во второе поколение и остаться в хипе навсегда, бесполезно занимая память, даже если они уже никому больше не нужны.
Всё это, конечно, не означает, что деструкторы – это зло, специально добавленное в CLR, чтобы усложнить нам жизнь. Ими можно и нужно пользоваться для освобождения unmanaged-ресурсов, таких, как файловые дескрипторы, соединения с БД и COM-объекты, но в тоже время нужно чётко понимать, что за этим стоит, и уж тем более не следует добавлять их к классам «просто так на всякий случай».
Интерфейс IDisposable
Думаю, исключение System.IO.IOException вам обеспечено по причине «The process cannot access the file "test.txt" because it is being used by another process.» (Процесс не может достучаться до файла, потому что до него уже достучался другой процесс). В общем-то, ничего удивительного, файлы за собой нужно закрывать, но в данном случае это ещё не всё. Даже написав код закрытия файла, нет никакой гарантии, что он точно будет выполнен. Что будет, если между открытием и закрытием файла произойдёт исключение? Даже если мы и обработаем это исключение позже, то наш файл опять не будет вовремя закрыт. Чтобы в данном случае обеспечить корректную работу, нам нужен примерно следующий код:
Вот теперь всё будет работать как надо. Для этого нам всего лишь понадобилось добавить ещё девять строчек кода к исходным двум. Не много ли для такой простой задачи? Тем более что на C++ такого же результата вполне можно добиться всё теми же двумя строчками, где и закрывать ничего не надо, и никакие исключения не страшны:
Похоже, using появилась в языке перед самым его выходом, по крайней мере, ничего подобного нет больше ни в одном другом языке, поддерживающем CLR. Да и в мегабайтах исходного кода, который предоставила Microsoft как CLI, эта конструкция встречается всего пару раз, в то время как её аналог в виде try/finally сплошь и рядом. К тому же в статьях Джефри Рихтера об алгоритмах работы GC, которые были опубликованы полтора года назад в MSDN Magazine и которые затем аккуратно в полном объёме перекочевали в его книгу, нет никакого упоминания о using. В самой же книге этому уже посвящён целый небольшой раздел. Вряд ли человек, который имеет неограниченный «доступ к телу», включая самые интимные места, не знал об этом ранее.
- Всё это вселяет определённую надежду на то, что в будущем проблема освобождения ресурсов если и не будет решена так же изящно, как в C++, то, по крайней мере, работы в этом направлении будут вестись.
- Перепишем наш пример с использованием using:
- Это уже гораздо лучше. Конструкция using генерирует код, аналогичный нашему примеру с try/finally, за тем исключением, что вместо метода Close она вызывает Dispose. Вполне логично предположить, что объект должен иметь в своём составе такой метод. Так и есть, мы можем использовать с этой конструкцией только те объекты, которые реализуют интерфейс IDisposable, содержащий в своём составе один единственный метод:
Теперь можно просто наследоваться от RSDN.DisposableType и всего лишь перекрывать метод Dispose(bool), в котором можно освобождать ресурсы:
Тем не менее, и этот способ недостаточно хорош для настоящих пуристов от C++. Так в процессе обсуждения этой темы на форумах RSDN родилась идея использовать какой-либо атрибут, с помощью которого можно было бы помечать нужные члены класса и свести процедуру очистки ресурсов к следующему виду:
Это вполне осуществимо. Добавим в наш класс реализацию этой возможности:
Было бы совсем здорово, если бы этот способ был встроен в сам компилятор, который мог бы при необходимости автоматически добавлять наследование от IDisposable и генерировать метод Dispose. В этом случае можно было бы использовать всё тот же атрибут или ключевое слово using:
Слабые ссылки
Следующий пример демонстрирует возможный вариант применения «слабых» ссылок:
Класс System.GC
В заключение разберём класс System.GC, который предоставляет интерфейс к подсистеме сборки мусора. Мы не можем создавать экземпляры этого класса и не можем от него наследоваться, да это и не нужно, так как все методы и свойства этого класса статические и доступны для использования без создания объекта. Рассмотрим их все по порядку.
MaxGeneration
Это свойство возвращает максимальный номер поколения, который поддерживается системой. В настоящий момент это значение равно 2.
Метод Collect
Принудительный вызов сборщика мусора.
Параметр generation задаёт максимальный номер поколения, которое будет обработано при сборке мусора. Первый метод использует в качестве номера поколения максимальный номер и фактически соответсвует следующему коду:
GetGeneration
Возвращает номер поколения, в котором находится заданный объект.
GetTotalMemory
Возвращает число байт, занимаемых в данный момент управляемым хипом.
Если параметр forceFullCollection равен true , то функция возвращает занимаемую хипом память после вызова сборщика мусора. Как мы уже знаем, GC не гарантирует освобождение абсолютно всей возможной памяти. Данная функция вызывает сборщик мусора несколько (до 20) раз до тех пор пока разница в занимаемой памяти до и после сборки не будет составлять 5%.
KeepAlive
Предотвращает удаление объекта при сборке мусора, если даже на него нет уже ссылок.
Подобная возможность может понадобитсься, например, в случае если ссылки на ваш объект уже отсутствуют в управляемом коде, но ваш объект всё ещё необходим unmanaged коду.
ReRegisterForFinalize
Добавляет объект в Finalization Queue.
Мы уже пользовались этим методом для подсчёта числа вызовов GC.
SuppressFinalize
Удаляет объект из Finalization Queue.
WaitForPendingFinalizers
Останавливает текущий поток до завершения выполнения методов Finalize для всех объектов из F-reachable Queue (рис. 9).
Ранее в теме Типы значений и ссылочные типы мы рассматривали отдельные типы данных и как они располагаются в памяти. Так, при использовании переменных типов значений в методе, все значения этих переменных попадают в стек. После завершения работы метода стек очищается.
При использовании же ссылочных типов, например, объектов классов, для них также будет отводиться место в стеке, только там будет храниться не значение, а адрес на участок памяти в хипе или куче, в котором и будут находиться сами значения данного объекта. И если объект класса перестает использоваться, то при очистке стека ссылка на участок памяти также очищается, однако это не приводит к немедленной очистке самого участка памяти в куче. Впоследствии сборщик мусора (garbage collector) увидит, что на данный участок памяти больше нет ссылок, и очистит его.
В методе Test создается объект Country. С помощью оператора new в куче для хранения объекта CLR выделяет участок памяти. А в стек добавляет адрес на этот участок памяти. В главном методе Main мы вызываем метод Test. И после того, как Test отработает, место в стеке очищается, а сборщик мусора очищает ранее выделенный под хранение объекта country участок памяти.
Сборщик мусора не запускается сразу после удаления из стека ссылки на объект, размещенный в куче. Он запускается в то время, когда среда CLR обнаружит в этом потребность, например, когда программе требуется дополнительная память.
Как правило, объекты в куче располагаются неупорядочено, между ними могут иметься пустоты. Куча довольно сильно фрагментирована. Поэтому после очистки памяти в результате очередной сборки мусора оставшиеся объекты перемещаются в один непрерывный блок памяти. Вместе с этим происходит обновление ссылок, чтобы они правильно указывали на новые адреса объектов.
Так же надо отметить, что для крупных объектов существует своя куча - Large Object Heap . В эту кучу помещаются объекты, размер которых больше 85 000 байт. Особенность этой кучи состоит в том, что при сборке мусора сжатие памяти не проводится по причине больших издержек, связанных с размером объектов.
Несмотря на то что, на сжатие занятого пространства требуется время, да и приложение не сможет продолжать работу, пока не отработает сборщик мусора, однако благодаря подобному подходу также происходит оптимизация приложения. Теперь чтобы найти свободное место в куче среде CLR не надо искать островки пустого пространства среди занятых блоков. Ей достаточно обратиться к указателю кучи, который указывает на свободный участок памяти, что уменьшает количество обращений к памяти.
Кроме того, чтобы снизить издержки от работы сборщика мусора, все объекты в куче разделяются по поколениям. Всего существует три поколения объектов: 0, 1 и 2-е.
К поколению 0 относятся новые объекты, которые еще ни разу не подвергались сборке мусора. К поколению 1 относятся объекты, которые пережили одну сборку, а к поколению 2 - объекты, прошедшие более одной сборки мусора.
Когда сборщик мусора приступает к работе, он сначала анализирует объекты из поколению 0. Те объекты, которые остаются актуальными после очистки, повышаются до поколения 1.
Если после обработки объектов поколения 0 все еще необходима дополнительная память, то сборщик мусора приступает к объектам из поколения 1. Те объекты, на которые уже нет ссылок, уничтожаются, а те, которые по-прежнему актуальны, повышаются до поколения 2.
Поскольку объекты из поколения 0 являются более молодыми и нередко находятся в адресном пространстве памяти рядом друг с другом, то их удаление проходит с наименьшими издержками.
Класс System.GC
Рассмотрим некоторые методы и свойства класса System.GC:
Метод AddMemoryPressure информирует среду CLR о выделении большого объема неуправляемой памяти, которую надо учесть при планировании сборки мусора. В связке с этим методом используется метод RemoveMemoryPressure , который указывает CLR, что ранее выделенная память освобождена, и ее не надо учитывать при сборке мусора.
Метод Collect приводит в действие механизм сборки мусора. Перегруженные версии метода позволяют указать поколение объектов, вплоть до которого надо произвести сборку мусора
Метод GetGeneration(Object) позволяет определить номер поколения, к которому относится переданый в качестве параметра объект
Метод GetTotalMemory возвращает объем памяти в байтах, которое занято в управляемой куче
Метод WaitForPendingFinalizers приостанавливает работу текущего потока до освобождения всех объектов, для которых производится сборка мусора
Работать с методами System.GC очень просто:
С помощью перегруженных версий метода GC.Collect можно выполнить более точную настройку сборки мусора. Так, его перегруженная версия принимает в качестве параметра число - номер поколения, вплоть до которого надо выполнить очистку. Например, GC.Collect(0) - удаляются только объекты поколения 0.
Еще одна перегруженная версия принимает еще и второй параметр - перечисление GCCollectionMode . Это перечисление может принимать три значения:
Default : значение по умолчанию для данного перечисления (Forced)
Forced : вызывает немедленное выполнение сборки мусора
Optimized : позволяет сборщику мусора определить, является ли текущий момент оптимальным для сборки мусора
Читайте также: