Автоматическое управление памятью в c
Работа с динамической памятью зачастую является узким местом во многих алгоритмах, если не применять специальные ухищрения.
В статье я рассмотрю парочку таких техник. Примеры в статье отличаются (например, от этого) тем, что используется перегрузка операторов new и delete и за счёт этого синтаксические конструкции будут минималистичными, а переделка программы — простой. Также описаны подводные камни, найденные в процессе (конечно, гуру, читавшие стандарт от корки до корки, не удивятся).
0. А нужна ли нам ручная работа с памятью?
В первую очередь проверим, насколько умный аллокатор может ускорить работу с памятью.
1. Пул объектов
Очевидное решение — забрать у ОС большой блок памяти и разбить его на равные блоки размера sizeof(Node), при выделении памяти брать блок из пула, при освобождении — возвращать в пул. Пул проще всего организовать с помощью односвязного списка (стека).
Поскольку стоит задача минимального вмешательства в программу, всё что можно будет сделать, это добавить примесь BlockAlloc к классу Node:
Прежде всего нам понадобится пул больших блоков (страниц), которые забираем у ОС или C-runtime. Его можно организовать поверх функций malloc и free, но для большей эффективности (чтобы пропустить лишний уровень абстракции), используем VirtualAlloc/VirtualFree. Эти функции выделяют память блоками, кратными 4K, а также резервируют адресное пространство процесса блоками, кратными 64K. Одновременно указывая опции commit и reserve, мы перескакиваем ещё один уровень абстракции, резервируя адресное пространство и выделяя страницы памяти одним вызовом.
Затем организуем пул блоков заданного размера
Комментарием // todo: lock(this) помечены места, которые требуют межпоточной синхронизации (например, используйте EnterCriticalSection или boost::mutex).
Объясню, почему при «форматировании» страницы не ипользуется абстракция FreeBlock для добавления блока в пул. Если бы было написано что-то вроде
То страница по принципу FIFO оказалась бы размеченной «наоборот»:
Несколько блоков, затребованных из пула подряд, имели бы убывающие адреса. А процессор не любит ходить назад, от этого у него ломается Prefetch (UPD: Не актуально для современных процессоров). Если же делать разметку в цикле
то цикл разметки ходил бы по адресам назад.
Теперь, когда приготовления сделаны, можно описать класс-примесь.
Объясню, зачем нужны проверки if (s != sizeof(T))
Когда они срабатывают? Тогда, когда создаётся/удаляется класс, отнаследованный от базового T.
Наследники будут пользоваться обычными new/delete, но к ним также можно примешать BlockAlloc. Таким образом, мы легко и безопасно определяем, какие классы должны пользоваться пулами, не боясь сломать что-то в программе. Множественное наследование также прекрасно работает с этой примесью.
2. Контейнер и его пёстрое содержимое
Часто ли попадаются классы, которые хранят в себе массу различных дочерних объектов, таких, что время жизни последних не дольше времени жизни родителя?
Например, это может быть класс XmlDocument, наполненный классами Node и Attribute, а также c-строками (char*), взятыми из текста внутри нод. Или список файлов и каталогов в файловом менеджере, загружаемых один раз при перечитывании каталога и больше не меняющихся.
Как было показано во введении, delete обходится дороже, чем new. Идея второй части статьи в том, чтобы память под дочерние объекты выделять в большом блоке, связанном с Parent-объектом. При удалении parent-объекта у дочерних будут, как обычно, вызваны деструкторы, но память возвращать не потребуется — она освободиться одним большим блоком.
Создадим класс PointerBumpAllocator, который умеет откусывать от большого блока куски разных размеров и выделять новый большой блок, когда старый будет исчерпан.
Наконец, опишем примесь ChildObject с перегруженными new и delete, обращающимися к заданному аллокатору:
new (… параметры для оператора… ) ChildObject (… параметры конструктора… )
Для удобства я задал два оператора new, принимающих A& или A*.
Если аллокатор добавлен в parent-класс как член, удобнее первый вариант:
Если аллокатор добавлен как предок (примесь), удобнее второй:
Понятно, что указатель и ссылка взаимно конвертируются, разделение этих случаев — избавления от лишних значков.
Для вызова delete не предусмотрен специальный синтаксис, компилятор вызовет стандартный delete (отмеченный *1), независимо от того, какой из операторов new был использован для создания объекта. То есть, синтаксис delete обычный:
Если же в конструкторе ChildObject (или его наследника) происходит исключение, вызывается delete с сигнатурой, соответствующей сигнатуре оператора new, использованном при создании этого объекта (первый параметр size_t будет заменён на void*).
Размешение оператора new в секции private защищает от вызова new без указания аллокатора.
Приведу законченный пример использования пары Allocator-ChildObject:
Заключение. Статья была написана 1.5 года назад для песочницы, но увы, не понравилась модератору.
Automatic memory management is one of the services that the Common Language Runtime provides during Managed Execution. The Common Language Runtime's garbage collector manages the allocation and release of memory for an application. For developers, this means that you do not have to write code to perform memory management tasks when you develop managed applications. Automatic memory management can eliminate common problems, such as forgetting to free an object and causing a memory leak, or attempting to access memory for an object that has already been freed. This section describes how the garbage collector allocates and releases memory.
Releasing Memory
The garbage collector's optimizing engine determines the best time to perform a collection based on the allocations being made. When the garbage collector performs a collection, it releases the memory for objects that are no longer being used by the application. It determines which objects are no longer being used by examining the application's roots. Every application has a set of roots. Each root either refers to an object on the managed heap or is set to null. An application's roots include static fields, local variables and parameters on a thread's stack, and CPU registers. The garbage collector has access to the list of active roots that the just-in-time (JIT) compiler and the runtime maintain. Using this list, it examines an application's roots, and in the process creates a graph that contains all the objects that are reachable from the roots.
Objects that are not in the graph are unreachable from the application's roots. The garbage collector considers unreachable objects garbage and will release the memory allocated for them. During a collection, the garbage collector examines the managed heap, looking for the blocks of address space occupied by unreachable objects. As it discovers each unreachable object, it uses a memory-copying function to compact the reachable objects in memory, freeing up the blocks of address spaces allocated to unreachable objects. Once the memory for the reachable objects has been compacted, the garbage collector makes the necessary pointer corrections so that the application's roots point to the objects in their new locations. It also positions the managed heap's pointer after the last reachable object. Note that memory is compacted only if a collection discovers a significant number of unreachable objects. If all the objects in the managed heap survive a collection, then there is no need for memory compaction.
To improve performance, the runtime allocates memory for large objects in a separate heap. The garbage collector automatically releases the memory for large objects. However, to avoid moving large objects in memory, this memory is not compacted.
Освобождение памяти для неуправляемых ресурсов
Для большинства объектов, созданных приложением, сборщик мусора автоматически выполнит необходимые задачи по управлению памятью. Однако для неуправляемых ресурсов требуется явная очистка. Основным типом неуправляемых ресурсов являются объекты, образующие упаковку для ресурсов операционной системы, такие как дескриптор файлов, дескриптор окна или сетевое подключение. Хотя сборщик мусора может отслеживать время жизни управляемого объекта, инкапсулирующего неуправляемые ресурсы, он не имеет определенных сведений о том, как освобождать эти ресурсы. При создании объекта, который инкапсулирует неуправляемый ресурс, рекомендуется включить код для очистки неуправляемого ресурса в общий метод Dispose. Метод Dispose позволяет явно освобождать память при завершении работы с объектом. При использовании объекта, который инкапсулирует неуправляемый ресурс, следует помнить о методе Dispose и при необходимости вызывать его. Дополнительные сведения об освобождении неуправляемых ресурсов и пример шаблона для реализации метода Dispose см. в разделе Сборка мусора.
Привет, Хабр! Представляю вашему вниманию перевод статьи «Demystifying memory management in modern programming languages» за авторством Deepu K Sasidharan.
В данной серии статей мне бы хотелось развеять завесу мистики над управлением памятью в программном обеспечении (далее по тексту — ПО) и подробно рассмотреть возможности, предоставляемые современными языками программирования. Надеюсь, что мои статьи помогут читателю заглянуть под капот этих языков и узнать для себя нечто новое.
Углублённое изучение концептов управления памятью позволяет писать более эффективное ПО, потому как стиль и практики кодирования оказывают большое влияние на принципы выделения памяти для нужд программы.
Ручное управление памятью
Язык не предоставляет механизмов для автоматического управления памятью. Выделение и освобождение памяти для создаваемых объектов остаётся полностью на совести разработчика. Пример такого языка — C. Он предоставляет ряд методов (malloc, realloc, calloc и free) для управления памятью — разработчик должен использовать их для выделения и освобождения памяти в своей программе. Этот подход требует большой аккуратности и внимательности. Так же он является в особенности сложным для новичков.
Выживание и переходы
Объекты, которые не уничтожаются при сборке мусора, называются выжившими объектами и переходят в следующее поколение.
- Объекты, оставшиеся после сборки мусора поколения 0, подвигаются в поколение 1.
- Объекты, оставшиеся после сборки мусора поколения 1, подвигаются в поколение 2.
- Объекты, оставшиеся после сборки мусора поколения 2, остаются в поколении 2.
Когда сборщик мусора обнаруживает высокую долю выживания в поколении, он повышает порог распределений для этого поколения. При следующей сборке мусора освобождается заметная часть занятой памяти. В среде CLR непрерывно контролируется равновесие двух приоритетов: не позволить рабочему набору приложения стать слишком большим, задерживая сборку мусора, и не позволить сборке мусора выполняться слишком часто.
Выделение памяти
При инициализации нового процесса среда выполнения резервирует для него непрерывную область адресного пространства. Это зарезервированное адресное пространство называется управляемой кучей. Эта управляемая куча содержит указатель адреса, с которого будет выделена память для следующего объекта в куче. Изначально этот указатель устанавливается в базовый адрес управляемой кучи. Все ссылочные типы размещаются в управляемой куче. Когда приложение создает первый ссылочный тип, память для него выделяется, начиная с базового адреса управляемой кучи. При создании приложением следующего объекта сборщик мусора выделяет для него память в адресном пространстве, непосредственно следующем за первым объектом. Пока имеется доступное адресное пространство, сборщик мусора продолжает выделять пространство для новых объектов по этой схеме.
Выделение памяти из управляемой кучи происходит быстрее, чем неуправляемое выделение памяти. Поскольку среда выполнения выделяет память для объекта путем добавления значения к указателю, это осуществляется почти так же быстро, как выделение памяти из стека. Кроме того, поскольку выделяемые последовательно новые объекты и располагаются последовательно в управляемой куче, приложение может получать доступ к объектам очень быстро.
Generations and Performance
To optimize the performance of the garbage collector, the managed heap is divided into three generations: 0, 1, and 2. The runtime's garbage collection algorithm is based on several generalizations that the computer software industry has discovered to be true by experimenting with garbage collection schemes. First, it is faster to compact the memory for a portion of the managed heap than for the entire managed heap. Secondly, newer objects will have shorter lifetimes and older objects will have longer lifetimes. Lastly, newer objects tend to be related to each other and accessed by the application around the same time.
The runtime's garbage collector stores new objects in generation 0. Objects created early in the application's lifetime that survive collections are promoted and stored in generations 1 and 2. The process of object promotion is described later in this topic. Because it is faster to compact a portion of the managed heap than the entire heap, this scheme allows the garbage collector to release the memory in a specific generation rather than release the memory for the entire managed heap each time it performs a collection.
In reality, the garbage collector performs a collection when generation 0 is full. If an application attempts to create a new object when generation 0 is full, the garbage collector discovers that there is no address space remaining in generation 0 to allocate for the object. The garbage collector performs a collection in an attempt to free address space in generation 0 for the object. The garbage collector starts by examining the objects in generation 0 rather than all objects in the managed heap. This is the most efficient approach, because new objects tend to have short lifetimes, and it is expected that many of the objects in generation 0 will no longer be in use by the application when a collection is performed. In addition, a collection of generation 0 alone often reclaims enough memory to allow the application to continue creating new objects.
After the garbage collector performs a collection of generation 0, it compacts the memory for the reachable objects as explained in Releasing Memory earlier in this topic. The garbage collector then promotes these objects and considers this portion of the managed heap generation 1. Because objects that survive collections tend to have longer lifetimes, it makes sense to promote them to a higher generation. As a result, the garbage collector does not have to reexamine the objects in generations 1 and 2 each time it performs a collection of generation 0.
After the garbage collector performs its first collection of generation 0 and promotes the reachable objects to generation 1, it considers the remainder of the managed heap generation 0. It continues to allocate memory for new objects in generation 0 until generation 0 is full and it is necessary to perform another collection. At this point, the garbage collector's optimizing engine determines whether it is necessary to examine the objects in older generations. For example, if a collection of generation 0 does not reclaim enough memory for the application to successfully complete its attempt to create a new object, the garbage collector can perform a collection of generation 1, then generation 2. If this does not reclaim enough memory, the garbage collector can perform a collection of generations 2, 1, and 0. After each collection, the garbage collector compacts the reachable objects in generation 0 and promotes them to generation 1. Objects in generation 1 that survive collections are promoted to generation 2. Because the garbage collector supports only three generations, objects in generation 2 that survive a collection remain in generation 2 until they are determined to be unreachable in a future collection.
Ссылки
Вы можете подписаться на автора статьи в Twitter и на LinkedIn.
За вычитку перевода отдельное спасибо Александру Максимовскому и Катерине Шибаковой
Условия для сборки мусора
Сборка мусора возникает при выполнении одного из следующих условий:
Объем памяти, используемой объектами, выделенными в управляемой куче, превышает допустимый порог. Этот порог непрерывно корректируется во время выполнения процесса.
вызывается метод GC.Collect . Практически во всех случаях вызов этого метода не потребуется, так как сборщик мусора работает непрерывно. Этот метод в основном используется для уникальных ситуаций и тестирования.
Для чего используется оперативная память?
Когда программа выполняется в операционный системе компьютера, она нуждается в доступе к оперативной памяти (RAM) для того, чтобы:
- загружать свой собственный байт-код для выполнения;
- хранить значения переменных и структуры данных, которые используются в процессе работы;
- загружать внешние модули, которые необходимы программе для выполнения задач.
Стек используется для статичного выделения памяти. Он организован по принципу «последним пришёл — первым вышел» (LIFO). Можно представить стек как стопку книг — разрешено взаимодействовать только с самой верхней книгой: прочитать её или положить на неё новую.
- благодаря упомянутому принципу, стек позволяет очень быстро выполнять операции с данными — все манипуляции производятся с «верхней книгой в стопке». Книга добавляется в самый верх, если нужно сохранить данные, либо берётся сверху, если данные требуется прочитать;
- существует ограничение в том, что данные, которые предполагается хранить в стеке, обязаны быть конечными и статичными — их размер должен быть известен ещё на этапе компиляции;
- в стековой памяти хранится стек вызовов — информация о ходе выполнения цепочек вызовов функций в виде стековых кадров. Каждый стековый кадр это набор блоков данных, в которых хранится информация, необходимая для работы функции на определённом шаге — её локальные переменные и аргументы, с которыми её вызывали. Например, каждый раз, когда функция объявляет новую переменную, она добавляет её в верхний блок стека. Затем, когда функция завершает свою работу, очищаются все блоки памяти в стеке, которые функция использовала — иными словами, очищаются все блоки ее стекового кадра;
- каждый поток многопоточного приложения имеет доступ к своему собственному стеку;
- управление стековой памятью простое и прямолинейное; оно выполняется операционной системой;
- в стеке обычно хранятся данные вроде локальных переменных и указателей;
- при работе со стеком есть вероятность получать ошибки переполнения стека (stack overflow), так как максимальный его размер строго ограничен. Например, ошибка при составлении граничного условия в рекурсивной функции совершенно точно приведёт к переполнению стека;
- в большинстве языков существует ограничение на размер значений, которые можно сохранить в стек;
Использование стека в JavaScript. Объекты хранятся в куче и доступны по ссылкам, которые хранятся в стеке. Тут можно посмотреть в видеоформате
Куча используется для динамического выделения памяти, однако, в отличие от стека, данные в куче первым делом требуется найти с помощью «оглавления». Можно представить, что куча это такая большая многоуровневая библиотека, в которой, следуя определённым инструкциям, можно найти необходимую книгу.
- операции на куче производятся несколько медленнее, чем на стеке, так как требуют дополнительного этапа для поиска данных;
- в куче хранятся данные динамических размеров, например, список, в который можно добавлять произвольное количество элементов;
- куча общая для всех потоков приложения;
- вследствие динамической природы, куча нетривиальна в управлении и с ней возникает большинство всех проблем и ошибок, связанных с памятью. Способы решения этих проблем предоставляются языками программирования;
- типичные структуры данных, которые хранятся в куче — это глобальные переменные (они должны быть доступны для разных потоков приложения, а куча как раз общая для всех потоков), ссылочные типы, такие как строки или ассоциативные массивы, а так же другие сложные структуры данных;
- при работе с кучей можно получить ошибки выхода за пределы памяти (out of memory), если приложение пытается использовать больше памяти, чем ему доступно;
- размер значений, которые могут храниться в куче, ограничен лишь общим объёмом памяти, который был выделен операционной системой для программы.
Поколения
Алгоритм сборки мусора учитывает следующее:
- Уплотнять память для части управляемой кучи быстрее, чем для всей кучи.
- У новых объектов время жизни меньше, а старых больше.
- Новые объекты теснее связаны друг с другом, и приложение обращается к ним приблизительно в одно и то же время.
Сборка мусора в основном сводится к уничтожению короткоживущих объектов с небольшим временем жизни. Для оптимизации производительности сборщика мусора управляемая куча делится на три поколения: 0, 1 и 2. Следовательно, объекты с большим и небольшим временем жизни обрабатываются отдельно. Сборщик мусора хранит новые объекты в поколении 0. Уровень объектов, созданных на раннем этапе работы приложения и оставшихся после сборок мусора, повышается, и они сохраняются в поколении 1 и 2. Так как сжать часть управляемой кучи быстрее, чем всю кучу, эта схема позволяет сборщику мусора освобождать память в определенном поколении, а не для всей кучи при каждой сборке мусора.
Поколение 0. Это самое молодое поколение содержит короткоживущие объекты. Примером короткоживущего объекта является временная переменная. Сборка мусора чаще всего выполняется в этом поколении.
Вновь распределенные объекты образуют новое поколение объектов и неявно являются сборками поколения 0. Однако если это большие объекты, то они попадают в кучу больших объектов, которая иногда называется поколением 3. Поколение 3 — это физическое поколение, которое логически собирается как часть поколения 2.
Большинство объектов уничтожается при сборке мусора для поколения 0 и не доживает до следующего поколения.
Если приложение пытается создать новый объект, когда поколение 0 заполнено, сборщик мусора выполняет сбор, чтобы попытаться освободить адресное пространство для объекта. Сборщик мусора начинает проверять объекты в поколении 0, а не все объекты в управляемой куче. Сборка мусора только в поколении 0 зачастую освобождает достаточно памяти для того, чтобы приложение могло и дальше создавать новые объекты.
Поколение 1. Это поколение содержит коротко живущие объекты и служит буфером между короткоживущими и долгоживущими объектами.
Когда сборщик мусора выполняет сборку для поколения 0, память уплотняется для достижимых объектов и они продвигаются в поколение 1. Так как объекты, оставшиеся после сборки, обычно склонны к долгой жизни, имеет смысл продвинуть их в поколение более высокого уровня. Сборщику мусора необязательно выполнять повторную проверку объектов поколений 1 и 2 при каждой сборке мусора поколения 0.
Если сборка поколения 0 не освобождает достаточно памяти, чтобы приложение могло создать новый объект, сборщик мусора может выполнить сборку мусора поколения 1, а затем поколения 2. Объекты в поколении 1, оставшиеся после сборок, продвигаются в поколение 2.
Поколение 2. Это поколение содержит долгоживущие объекты. Примером долгоживущих объектов служит объект в серверном приложении, содержащий статические данные, которые существуют в течение длительности процесса.
Объекты в поколении 2, оставшиеся после сборки, находятся там до тех пор, пока они не станут недостижимыми в следующей сборке.
Объекты в куче больших объектов (иногда называемой поколением 3) также собираются в поколении 2.
Сборки мусора выполняются для конкретных поколений при выполнении соответствующих условий. Сборка поколения означает сбор объектов в этом поколении и во всех соответствующих младших поколениях. Сборка мусора поколения 2 также называется полной сборкой, так как при этом уничтожаются объекты во всех поколениях (то есть все объекты в управляемой куче).
Владение
Это сочетание RAII с концепцией владения, когда каждое значение в памяти должно иметь только одну переменную-владельца. Когда владелец уходит из области выполнения, память сразу же освобождается. Можно сказать, что это примерно как подсчёт ссылок на этапе компиляции. Данный подход используется в Rust и при этом я не смог найти ни одного другого языка, который бы использовал подобный механизм.
В данной статье были рассмотрены основные концепции в сфере управления памятью. Каждый язык программирования использует собственные реализации этих подходов и оптимизированные для различных задач алгоритмы. В следующих частях, мы подробнее рассмотрим решения для управления памятью в популярных языках.
Читайте так же другие части серии:
Почему эффективное управление памятью важно?
В отличие от жёстких дисков, оперативная память весьма ограниченна (хотя и жёсткие диски, безусловно, тоже не безграничны). Если программа потребляет память не высвобождая её, то, в конечном итоге, она поглотит все доступные резервы и попытается выйти за пределы памяти. Тогда она просто упадет сама, или, что ещё драматичнее, обрушит операционную систему. Следовательно, весьма нежелательно относиться легкомысленно к манипуляциям с памятью при разработке ПО.
Сборщик мусора
Преимущества
Использование сборщика мусора обеспечивает следующие преимущества:
Разработчикам не нужно освобождать память вручную.
Эффективно выделяет память для объектов в управляемой куче.
Уничтожает объекты, которые больше не используются, очищает их память и сохраняет память доступной для будущих распределений. Управляемые объекты автоматически получают чистое содержимое, поэтому конструкторам не нужно инициализировать каждое поле данных.
Обеспечивает безопасность памяти, убедившись, что объект не может использовать для себя память, выделенную для другого объекта.
Различные подходы
Современные языки программирования стараются максимально упростить работу с памятью и снять с разработчиков часть головной боли. И хотя некоторые почтенные языки всё ещё требуют ручного управления, большинство всё же предоставляет более изящные автоматические подходы. Порой в языке используется сразу несколько подходов к управлению памятью, а иногда разработчику даже доступен выбор какой из вариантов будет эффективнее конкретно для его задач (хороший пример — C++). Перейдём к краткому обзору различных подходов.
Эфемерные поколения и сегменты
Так как объекты в поколениях 0 и 1 являются короткоживущими, эти поколения называются эфемерными поколениями.
Эфемерные поколения выделяются в сегменте памяти, который называется эфемерным сегментом. Каждый новый сегмент, полученный сборщиком мусора, становится новым эфемерным сегментом и содержит объекты, пережившие сборку мусора для поколения 0. Старый эфемерный сегмент становится новым сегментом поколения 2.
Размер эфемерного сегмента зависит от того, является ли система 32- или 64-разрядной, и от типа сборщика мусора (сборка мусора рабочей станции или сервера). В следующей таблице показаны размеры эфемерного сегмента по умолчанию.
Сборка мусора рабочей станции и сервера | 32-разрядная версия | 64-разрядная версия |
---|---|---|
Сборщик мусора рабочей станции | 16 МБ | 256 МБ |
Сборщик мусора сервера | 64 МБ | 4 Гбайт |
GC сервера с > 4 логическими процессорами | 32 МБ | 2 ГБ |
GC сервера с > 8 логическими ЦП | 16 МБ | 1 ГБ |
Этот эфемерный сегмент может содержать объекты поколения 2. Объекты поколения 2 могут использовать несколько сегментов (столько, сколько требуется процессу и сколько разрешает память).
Объем памяти, освобождаемой при эфемерной сборке мусора, ограничен размером эфемерного сегмента. Освобождаемый объем памяти пропорционален пространству, занятому неиспользуемыми объектами.
Управляемая куча
После инициализации средой CLR сборщик мусора выделяет сегмент памяти для хранения объектов и управления ими. Эта память называется управляемой кучей в отличие от собственной кучи операционной системы.
Управляемая куча создается для каждого управляемого процесса. Все потоки в процессе выделяют память для объектов в одной и той же куче.
Для резервирования памяти сборщик мусора вызывает функцию Windows VirtualAlloc и резервирует для управляемых приложений по одному сегменту памяти за раз. Сборщик мусора также резервирует сегменты по мере необходимости и возвращает операционной системе освобожденные сегменты (очистив их от всех объектов), вызывая функцию Windows VirtualFree.
Размер сегментов, выделенных сборщиком мусора, зависит от реализации и может быть изменен в любое время, в том числе при периодических обновлениях. Приложение не должно делать никаких допущений относительно размера определенного сегмента, полагаться на него или пытаться настроить объем памяти, доступный для выделения сегментов.
Чем меньше объектов распределено в куче, тем меньше придется работать сборщику мусора. При размещении объектов не используйте округленные значения, превышающие фактические потребности, например не выделяйте 32 байта, когда необходимо только 15 байтов.
Активированный процесс сборки мусора освобождает память, занятую неиспользуемыми объектами. Процесс освобождения сжимает используемые объекты, чтобы они перемещались вместе, и удаляет пространство, занятое неиспользуемыми объектами, уменьшая, таким образом, кучу. Это гарантирует, что объекты, распределенные совместно, останутся в управляемой куче рядом, чтобы сохранить локальность.
Степень вмешательства (частота и длительность) сборок мусора зависит от числа распределений и сохранившейся в управляемой куче памяти.
Кучу можно рассматривать как совокупность двух куч: куча больших объектов и куча маленьких объектов. Куча больших объектов содержит объекты размером от 85 000 байтов, обычно представленные массивами. Экземпляр объекта редко бывает очень большим.
Вы можете настроить пороговый размер для объектов, помещаемых в кучу больших объектов.
Основы работы с памятью
В следующем списке перечислены важные понятия памяти среды CLR.
Каждый процесс имеет свое собственное отдельное виртуальное адресное пространство. Все процессы на одном компьютере совместно используют одну и ту же физическую память и один файл подкачки, если он есть.
По умолчанию на 32-разрядных компьютерах каждому процессу выделяется 2 Гбайт виртуального адресного пространства в пользовательском режиме.
Разработчики приложений работают только с виртуальным адресным пространством и никогда не управляют физической памятью напрямую. Сборщик мусора выделяет и освобождает виртуальную память для разработчика в управляемой куче.
При написании машинного кода для работы с виртуальным адресным пространством используются функции Windows. Эти функции выделяют и освобождают виртуальную память для разработчика в собственных кучах.
Виртуальная память может находиться в трех состояниях.
Виртуальное адресное пространство может стать фрагментированным. Это означает, что в адресном пространстве находятся свободные блоки, также известные как пропуски. Когда производится запрос на выделение виртуальной памяти, диспетчер виртуальной памяти должен найти один свободный блок достаточного размера для выполнения этого запроса на выделение. Даже если в системе есть 2 ГБ свободного пространства, операция выделения 2 ГБ завершится неудачей, если это пространство не расположено в одном адресном блоке.
Память может закончиться, если будет недостаточно виртуального адресного пространства для резервирования или физического пространства для выделения.
Файл подкачки используется, даже если нехватка физической памяти (то есть потребность в физической памяти) невелика. При первом возникновении нехватки физической памяти операционная система должна освободить пространство в физической памяти для хранения данных, для чего она производит резервное копирование некоторых данных, находящихся в физической памяти, в файл подкачки. Эти данные не выгружаются, пока в этом нет необходимости, так что с подкачкой можно столкнуться в ситуациях с небольшой нехваткой физической памяти.
Часть 1: Введение в управление памятью
Управление памятью — это целый набор механизмов, которые позволяют контролировать доступ программы к оперативной памяти компьютера. Данная тема является очень важной при разработке ПО и, при этом, вызывает затруднения или же вовсе остаётся черным ящиком для многих программистов.
Неуправляемые ресурсы
Для большинства объектов, созданных приложением, сборщик мусора автоматически выполнит необходимые задачи по управлению памятью. Однако для неуправляемых ресурсов требуется явная очистка. Основным типом неуправляемых ресурсов являются объекты, образующие упаковку для ресурсов операционной системы, такие как дескриптор файлов, дескриптор окна или сетевое подключение. Хотя сборщик мусора может отслеживать время жизни управляемого объекта, инкапсулирующего неуправляемый ресурс, он не знает, как освобождать эти ресурсы.
При создании объекта, инкапсулирующего неуправляемый ресурс, рекомендуется предоставлять необходимый код для очистки неуправляемого ресурса в общем методе Dispose . Предоставление метода Dispose дает возможность пользователям объекта явно освобождать память при завершении работы с объектом. Когда используется объект, инкапсулирующий неуправляемый ресурс, вызовите Dispose при необходимости.
Кроме того, нужно предусмотреть способ освобождения неуправляемых ресурсов в случае, если потребитель типа не вызовет Dispose . Вы можете использовать защищенный обработчик для создания оболочки для неуправляемого ресурса или переопределить метод Object.Finalize().
Для автоматического управления памятью используется одна из служб, которые среда CLR предоставляет при управляемом выполнении. Сборщик мусора среды CLR управляет освобождением и выделением памяти для приложения. Для разработчиков это означает, что при разработке управляемого приложения не нужно писать код для управления памятью. Автоматическое управление памятью позволяет устранить распространенные проблемы, такие как не освобожденный по забывчивости объект, вызывающий утечку памяти, или попытки доступа к памяти для уже удаленного объекта. В этом разделе описано, каким образом сборщик мусора выделяет и освобождает память.
Получение ресурса есть инициализация (RAII)
RAII — это программная идиома в ООП, смысл которой заключается в том, что выделяемая для объекта область памяти строго привязывается к его времени существования. Память выделяется в конструкторе и освобождается в деструкторе. Данный подход был впервые реализован в C++, а так же используется в Ada и Rust.
Allocating Memory
When you initialize a new process, the runtime reserves a contiguous region of address space for the process. This reserved address space is called the managed heap. The managed heap maintains a pointer to the address where the next object in the heap will be allocated. Initially, this pointer is set to the managed heap's base address. All reference types are allocated on the managed heap. When an application creates the first reference type, memory is allocated for the type at the base address of the managed heap. When the application creates the next object, the garbage collector allocates memory for it in the address space immediately following the first object. As long as address space is available, the garbage collector continues to allocate space for new objects in this manner.
Allocating memory from the managed heap is faster than unmanaged memory allocation. Because the runtime allocates memory for an object by adding a value to a pointer, it is almost as fast as allocating memory from the stack. In addition, because new objects that are allocated consecutively are stored contiguously in the managed heap, an application can access the objects very quickly.
Releasing Memory for Unmanaged Resources
For the majority of the objects that your application creates, you can rely on the garbage collector to automatically perform the necessary memory management tasks. However, unmanaged resources require explicit cleanup. The most common type of unmanaged resource is an object that wraps an operating system resource, such as a file handle, window handle, or network connection. Although the garbage collector is able to track the lifetime of a managed object that encapsulates an unmanaged resource, it does not have specific knowledge about how to clean up the resource. When you create an object that encapsulates an unmanaged resource, it is recommended that you provide the necessary code to clean up the unmanaged resource in a public Dispose method. By providing a Dispose method, you enable users of your object to explicitly free its memory when they are finished with the object. When you use an object that encapsulates an unmanaged resource, you should be aware of Dispose and call it as necessary. For more information about cleaning up unmanaged resources and an example of a design pattern for implementing Dispose, see Garbage Collection.
В среде CLR сборщик мусора выполняет функции автоматического диспетчера памяти. Сборщик мусора управляет выделением и освобождением памяти для приложения. Следовательно, разработчикам, работающим с управляемым кодом, не нужно писать код для выполнения задач по управлению памятью. Автоматическое управление памятью позволяет устранить распространенные проблемы, которые связаны с утечкой памяти из-за того, что объект не был освобожден, или попыткой доступа к памяти для объекта, который был освобожден.
В этой статье описаны основные понятия сборки мусора.
Поколения и производительность
Для оптимизации производительности сборщика мусора управляемая куча делится на три поколения: 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 до тех пор, пока они не перестанут быть доступными в результате сборки мусора.
Выделение памяти
При инициализации нового процесса среда выполнения резервирует для него непрерывную область адресного пространства. Это зарезервированное адресное пространство называется управляемой кучей. Эта управляемая куча содержит указатель адреса, с которого будет выделена память для следующего объекта в куче. Изначально этот указатель устанавливается в базовый адрес управляемой кучи. Все ссылочные типы размещаются в управляемой куче. Когда приложение создает первый ссылочный тип, память для него выделяется, начиная с базового адреса управляемой кучи. При создании приложением следующего объекта сборщик мусора выделяет для него память в адресном пространстве, непосредственно следующем за первым объектом. Пока имеется доступное адресное пространство, сборщик мусора продолжает выделять пространство для новых объектов по этой схеме.
Выделение памяти из управляемой кучи происходит быстрее, чем неуправляемое выделение памяти. Так как среда выполнения выделяет память для объекта путем добавления значения к указателю, это осуществляется почти так же быстро, как выделение памяти из стека. Кроме того, поскольку выделяемые последовательно новые объекты располагаются в управляемой куче непрерывно, приложение может быстро получать доступ к ним.
Освобождение памяти
Механизм оптимизации сборщика мусора определяет наилучшее время для выполнения сбора, основываясь на произведенных выделениях памяти. Когда сборщик мусора выполняет очистку, он освобождает память, выделенную для объектов, которые больше не используются приложением. Он определяет, какие объекты больше не используются, анализируя корни приложения. Корни приложения содержат статические поля, локальные переменные в стеке потока, регистры процессора, дескрипторы сборки мусора и очередь завершения. Каждый корень либо ссылается на объект, находящийся в управляемой куче, либо имеет значение NULL. Сборщик мусора может запросить остальную часть среды выполнения для этих корней. С помощью этого списка он проверяет корни приложения и при этом создает граф, содержащий все объекты, к которым можно получить доступ из этих корней.
Объекты, не входящие в этот граф, являются недостижимыми из данных корней приложения. Сборщик мусора считает недостижимые объекты мусором и освобождает выделенную для них память. В процессе очистки сборщик мусора проверяет управляемую кучу, отыскивая блоки адресного пространства, занятые недостижимыми объектами. При обнаружении недостижимого объекта он использует функцию копирования памяти для уплотнения достижимых объектов в памяти, освобождая блоки адресного пространства, выделенные под недостижимые объекты. После уплотнения памяти, занимаемой достижимыми объектами, сборщик мусора вносит необходимые поправки в указатель, чтобы корни приложения указывали на новые расположения объектов. Он также устанавливает указатель управляемой кучи в положение после последнего достижимого объекта.
Память уплотняется, только если при очистке обнаруживается значительное число недостижимых объектов. Если после сборки мусора все объекты в управляемой куче остаются на месте, то уплотнение памяти не требуется.
Для повышения производительности среда выполнения выделяет память для больших объектов в отдельной куче. Сборщик мусора автоматически освобождает память, выделенную для больших объектов. Но для устранения перемещений в памяти больших объектов эта память обычно не сжимается.
Процесс сборки мусора
Сборка мусора состоит из следующих этапов:
Этап маркировки, выполняющий поиск всех используемых объектов и составляющий их перечень.
Этап перемещения, обновляющий ссылки на сжимаемые объекты.
Этап сжатия, освобождающий пространство, занятое неиспользуемыми объектами и сжимающий выжившие объекты. На этапе сжатия объекты, пережившие сборку мусора, перемещаются к более старому концу сегмента.
Так как сборки поколения 2 могут занимать несколько сегментов, объекты, перешедшие в поколение 2, могут быть перемещены в более старый сегмент. Выжившие объекты поколений 1 и 2 могут быть перемещены в другой сегмент, так как они перешли в поколение 2.
- Предельный объем памяти для контейнера.
- Параметры конфигурации среды выполнения гчеафардлимит или гчеафардлимитперцент .
Чтобы определить, являются ли объекты используемыми, сборщик мусора задействует следующие сведения.
Корни стека. Переменные стека, предоставленные JIT-компилятором и средством обхода стека. JIT-оптимизация позволяет уменьшить или увеличить области кода, в которых переменные стека сообщаются сборщику мусора.
Дескрипторы сборки мусора. Дескрипторы, которые указывают на управляемые объекты и которые могут быть выделены пользовательским кодом или средой CLR.
Статические данные. Статические объекты в доменах приложений, которые могут ссылаться на другие объекты. Каждый домен приложения следит за своими статическими объектами.
Перед запуском сборки мусора все управляемые потоки, кроме потока, запустившего сборку мусора, приостанавливаются.
На следующем рисунке показан поток, запускающий сборку мусора и вызывающий приостановку других потоков.
Освобождение памяти
Механизм оптимизации сборщика мусора определяет наилучшее время для выполнения сбора, основываясь на произведенных выделениях памяти. Когда сборщик мусора выполняет очистку, он освобождает память, выделенную для объектов, которые больше не используются приложением. Он определяет, какие объекты больше не используются, основываясь на корнях приложения. Каждое приложение имеет набор корней. Каждый корень либо ссылается на объект, находящийся в управляемой куче, либо имеет значение NULL. Корни приложения содержат статические поля, локальные переменные и параметры стека потока, а также регистры процессора. Сборщик мусора имеет доступ к списку активных корней, которые поддерживаются JIT-компилятором и средой выполнения. С помощью этого списка он проверяет корни приложения и в процессе проверки создает граф, содержащий все объекты, к которым можно получить доступ из этих корней.
Объекты, не входящие в этот граф, являются недостижимыми из данных корней приложения. Сборщик мусора считает недостижимые объекты мусором и будет освобождать выделенную для них память. В процессе очистки сборщик мусора проверяет управляемую кучу, отыскивая блоки адресного пространства, занятые недостижимыми объектами. При обнаружении недостижимого объекта он использует функцию копирования памяти для уплотнения достижимых объектов в памяти, освобождая блоки адресного пространства, выделенные под недостижимые объекты. После уплотнения памяти, занимаемой достижимыми объектами, сборщик мусора вносит необходимые поправки в указатель, чтобы корни приложения указывали на новые расположения объектов. Он также устанавливает указатель управляемой кучи в положение после последнего достижимого объекта. Обратите внимание, что память уплотняется, только если при очистке обнаруживается значительное число недостижимых объектов. Если после сборки мусора все объекты в управляемой куче остаются на месте, то уплотнение памяти не требуется.
Для повышения производительности среда выполнения выделяет память для больших объектов в отдельной куче. Сборщик мусора автоматически освобождает память, выделенную для больших объектов. Однако для устранения перемещений в памяти больших объектов эта память не сжимается.
Автоматический подсчёт ссылок (ARC)
Данный подход весьма похож на сборку мусора с подсчётом ссылок, однако, вместо запуска процесса подсчёта в определённые интервалы времени, инструкции выделения и освобождения памяти вставляются на этапе компиляции прямо в байт-код. Когда же счётчик ссылок достигает нуля, память освобождается как часть нормального потока выполнения программы.
Автоматический подсчёт ссылок всё так же не позволяет обрабатывать циклические ссылки и требует от разработчика использования специальных ключевых слов для дополнительной обработки таких ситуаций. ARC является одной из особенностей транслятора Clang, поэтому присутствует в языках Objective-C и Swift. Так же автоматический подсчет ссылок доступен для использования в Rust и новых стандартах C++ при помощи умных указателей.
Читайте также: