C ручное управление памятью

Обновлено: 04.07.2024

В информатике , ручное управление памятью относится к использованию ручных инструкций программиста , чтобы выявить и освободить неиспользуемые объекты или мусор . Вплоть до середины 1990-х годов большинство языков программирования, используемых в промышленности, поддерживали ручное управление памятью, хотя сборка мусора существует с 1959 года, когда она была представлена ​​вместе с Lisp . Однако сегодня языки со сборкой мусора, такие как Java, становятся все более популярными, а языки Objective-C и Swift предоставляют аналогичные функции за счет автоматического подсчета ссылок.. Основные вручную управляемые языки все еще широко используются сегодня являются C и C ++ - см распределение C динамической памяти .

СОДЕРЖАНИЕ

Описание

Все языки программирования используют ручные методы, чтобы определить, когда выделить новый объект из бесплатного хранилища. C использует malloc функцию; C ++ и Java используют new оператор; и многие другие языки (например, Python) выделяют все объекты из бесплатного хранилища. Определение того, когда должен быть создан объект ( создание объекта ), обычно тривиально и не вызывает проблем, хотя такие методы, как пулы объектов, означают, что объект может быть создан перед немедленным использованием. Настоящая проблема - это разрушение объекта - определение того, когда объект больше не нужен (то есть мусор), и организация возврата его базового хранилища в свободное хранилище для повторного использования. При ручном выделении памяти это также указывается вручную программистом; через функции, такие как free() C, или delete оператор в C ++ - это контрастирует с автоматическим уничтожением объектов, содержащихся в автоматических переменных , в частности (нестатических) локальных переменных функций, которые уничтожаются в конце своей области видимости в C и C ++.

Методы ручного управления памятью

  • malloc / бесплатно
  • скретч-буфер
  • .

Ручное управление и правильность

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

  • Когда неиспользуемый объект никогда не возвращается в свободное хранилище, это называется утечкой памяти . В некоторых случаях утечки памяти могут быть допустимыми, например программа, которая «утекает» ограниченный объем памяти в течение своего времени жизни, или краткосрочная программа, которая полагается на операционную систему для освобождения своих ресурсов при завершении работы. Однако во многих случаях утечки памяти происходят в долго работающих программах, и в таких случаях происходит утечка неограниченного объема памяти. Когда это происходит, размер доступного бесплатного магазина со временем продолжает уменьшаться; когда он, наконец, исчерпан, программа вылетает.
  • Катастрофический отказ системы управления динамической памятью может произойти, если резервная память объекта будет удалена из-под нее более одного раза; объект явно уничтожается более одного раза; когда при использовании указателя для управления объектом, не выделенным в свободном хранилище, программист пытается освободить поддерживающую память указанного целевого объекта указателя; или когда, манипулируя объектом через указатель на другую произвольную область памяти, управляемую неизвестной внешней задачей, потоком или процессом, программист искажает состояние этого объекта, возможно, таким образом, чтобы писать за его пределы и повредить данные управления памятью. Результатом таких действий может быть повреждение кучи , преждевременное уничтожение другого (и вновь созданного) объекта, который занимает то же место в памяти, что и многократно удаленный объект, сбой программы из-за ошибки сегментации (нарушение защиты памяти ). и другие формы неопределенного поведения .
  • Указатели на удаленные объекты становятся дикими указателями, если используются после удаления; Попытка использовать такие указатели может привести к ошибкам, которые трудно диагностировать.

Известно, что языки, использующие исключительно сборку мусора , избегают двух последних классов дефектов. Утечки памяти все еще могут происходить (и ограниченные утечки часто возникают при поколенческой или консервативной сборке мусора), но обычно менее серьезны, чем утечки памяти в ручных системах.

Приобретение ресурсов - это инициализация

У ручного управления памятью есть одно преимущество правильности, которое заключается в том, что оно позволяет автоматическое управление ресурсами с помощью парадигмы Resource Acquisition Is Initialization (RAII).

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

Это также можно использовать с детерминированным подсчетом ссылок . В C ++ эта возможность используется для дальнейшего использования для автоматизации освобождения памяти в рамках ручной среды, использование shared_ptr шаблона в стандартной библиотеке языка для управления памятью является распространенной парадигмой. shared_ptr это не подходит для всех моделей использования объекта, однако.

Представление

Многие сторонники ручного управления памятью утверждают, что оно обеспечивает лучшую производительность по сравнению с автоматическими методами, такими как сборка мусора . Традиционно задержка была самым большим преимуществом, но теперь это не так. Ручное выделение часто имеет лучшую адресную ссылку .

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

Ручное управление имеет ряд задокументированных недостатков производительности :

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

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

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

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

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

При использовании же ссылочных типов, например, объектов классов, для них также будет отводиться место в стеке, только там будет храниться не значение, а адрес на участок памяти в хипе или куче, в котором и будут находиться сами значения данного объекта. И если объект класса перестает использоваться, то при очистке стека ссылка на участок памяти также очищается, однако это не приводит к немедленной очистке самого участка памяти в куче. Впоследствии сборщик мусора (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 : позволяет сборщику мусора определить, является ли текущий момент оптимальным для сборки мусора

хранятся на стеке. Как следует из названия, стек работает с переменными по схеме FILO (first in last out). Управление стеком происходит автоматически. При выходе переменной из области видимости, соответствующая ей в стеке память освобождается. Этот механизм позволяет разработчику не следить за удалением автоматических переменных. Стек работает очень быстро, но имеет ограниченный размер, который обычно не превосходит нескольких мегабайт.

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

Кучу имеет смысл использовать в двух случаях:

  • Необходимо хранить большой объект. Хранение больших объектов на стеке может привести к его переполнению (stack overflow).
  • Автоматическое управление памятью в стеке не соответствует логике программы. Чаще всего такая ситуация возникает, когда созданный объект должен продолжать свое существование после выхода из блока, в котором он был создан. Ниже мы рассмотрим пример.

Динамическое выделение памяти означает работу с кучей и является предметом данного раздела.

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

Начнем с обзора низкоуровневых инструментов, которые обычно не используются при разработке на современном C++. Знание эти инструментов, однако, может пригодиться при чтении старого кода и при работе со старыми компиляторами.

Создать объект в куче можно с помощью оператора new :

Оператор new возвращает указатель на область памяти в куче, в которой был создан объект. Создав объект с помощью оператора new , разработчик становится ответственными за его удаление. Освободить выделенную память и удалить объект можно с помощью оператора delete :

Вернемся к примеру из раздела про наследование, в котором мы строили модель символов в графическом текстовом редакторе. Напомним, что мы создали абстрактный базовый класс Character и два его наследника Letter и Digit . Допустим, нам надо реализовать функцию, которая возвращает полиморфный список символов (текст документа). Без динамического выделения нам будет сложно решить эту задачу. Например:

Объекты l1 , l2 , d1 и d2 в функции create_document созданы на стеке. При выходе из функции create_document для каждого объекта будет вызван деструктор и освобождена память на стеке. В этом виде функция возвращает список указателей на освобожденную память, что приводит к неопределенному поведению. Следующее изменение сделает код корректным:

Теперь память для объектов выделяется в куче, объекты продолжают существовать после выхода из функции. Использовать функцию create_document необходимо с учетом особенностей работы с динамической памятью. Напишем функцию print_document , которая вызывает create_document и выводит символы в стандартный поток вывода:

Использование функции print_document приводит к утечке памяти: при каждом ее вызове в куче выделяется память, которая никогда не освобождается. Более аккуратная реализация выглядит так:

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

Существуют версии операторов new и delete для создания и удаления массивов объектов:

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

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

Владение ресурсами и идиома RAII

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

Ясно организовать владение ресурсами практически в любой программе можно, следую идиоме RAII (resource acquisition is initialization, получение ресурса есть инициализация), которая (в несколько упрощенном виде) состоит в следующем:

  • Каждый ресурс следует инкапсулировать в класс, при этом
    • Конструктор выполняет выделение ресурса
    • Деструктор выполняет освобождение ресурса

    Мы уже видели пример RAII-объекта в C++, когда говорили про работу с файлами. Объект fstream владеет ресурсом - файловым дескриптором - и отвечает за его освобождение, а вся работа с файлом происходит через этот объект.

    Умные указатели

    В рамках идиомы RAII в современном C++ решены сложности работы с динамическим выделением памяти. Логика работы с динамической памятью инкапсулирована в специальных классах std::unique_ptr и std::shared_ptr , которые называют умными указателями. При конструировании такого объекта происходит выделение памяти, а при вызове деструктора - освобождение. Например:

    При выходе из функции main выделенная в куче память корректно будет освобождена. Объекты std::unique_ptr и std::shared_ptr различаются с точки зрения владения объектом. Уникальный указатель std::unique_ptr единолично владеет ресурсом. Это означает, что не может быть два разных объекта std::unique_ptr не могут быть связаны с одним и тем же ресурсом. Это, например, означает, что объект std::unique_ptr не имеет копирующего конструктора и копирующего оператора присваивания. Вместо этого возможно использование перемещающего конструктор и перемещающего оператора присваивания. Например:

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

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

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

    Сложность обращения с длинными названиями типов в C++ вроде std::list<std::unique_ptr<Character>> (и это не самый плохой случай) может быть преодолена с помощью псевдонимов. Например:

    Виртуальный деструктор

    В заключение этого раздела обсудим один тонкий момент, связанный с полиморфизмом и освобождением ресурсов в C++. Функция create_document корректно работает с динамической памятью. Однако, если использовать классы Character , Letter и Digit в том виде, в каком мы их оставили в разделе про наследование, то освобождение памяти при удалении объекта Document будет выполнено неверно. Контейнер std::list работает с (умными) указателями на объекты абстрактного класса Character . При удалении объекта std::list происходит удаление всех объектов типа std::unique_ptr<Character> , которые в свою очередь вызывают деструкторы объектов Character . Вместо этого мы хотим, чтобы для каждого объекта вызывался деструктор нужного класса-наследника. Вызов только деструктора базового класса снова может привести к утечке памяти.

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

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

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

    Резюме

    В этом разделе мы обсудили основы работы с динамической памятью в C++. Рекомендуемыми инструментами работы с динамической памятью являются умные указатели std::unique_ptr и std::shared_ptr . Не забывайте объявлять деструктор базового класса виртуальным, если возможна работа с объектами классов-потомков через указатель на объект базового класса (а такая возможность есть всегда).

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

    Рисунок 19 Подсчет ссылок на объект

    В Objective-C есть два взаимоисключающих способа управления ссылками на объекты:

    1. Вручную отправьте методы для увеличения / уменьшения количества ссылок на объект.
    2. Пусть Xcode 4.2 (и позже) новая схема автоматического подсчета ссылок (ARC) сделает всю работу за вас.

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

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

    Чтобы поэкспериментировать с любым кодом из этого раздела, вам нужно отключить автоматический подсчет ссылок. Вы можете сделать это, щелкнув проект HelloObjectiveC на панели навигации Xcode:

    Рисунок 20 Проект HelloObjectiveC на панели навигации

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

    Рисунок 21 Отключение автоматического подсчета ссылок

    Нажмите стрелки рядом с « Yes и измените его на « No чтобы отключить ARC для этого проекта. Это позволит вам использовать методы управления памятью, описанные в следующих параграфах.

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

    Для каждого метода alloc или retain который вы вызываете, вам нужно вызывать release или autorelease в какой-то момент в autorelease . Количество раз, когда вы заявляете, что объект должен равняться числу раз, когда вы его отпускаете . Вызов дополнительного alloc / retain приведет к утечке памяти, а вызов дополнительного release / autorelease попытается получить доступ к объекту, который не существует, что приведет к сбою вашей программы.

    Все ваши взаимодействия с объектами, независимо от того, используете ли вы их в методе экземпляра, в методе getter / setter или в отдельной функции, должны следовать шаблону request / use / free, как показано в следующем примере:

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