Какой объем памяти выделяется под объект
Как вы уже знаете, при объявлении переменной необходимо указать тип данных, а для массива дополнительно задать точное количество элементов. На основе этой информации при запуске программы автоматически выделяется необходимый объем памяти. После завершения программы память автоматически освобождается. Иными словами, объем памяти необходимо знать до выполнения программы. Во время выполнения программы создать новую переменную или увеличить размер существующего массива нельзя.
Чтобы произвести увеличение массива во время выполнения программы необходимо выделить достаточный объем динамической памяти с помощью оператора new , перенести существующие элементы, а лишь затем добавить новые элементы. Управление динамической памятью полностью лежит на плечах программиста, поэтому после завершения работы с памятью необходимо самим возвратить память операционной системе с помощью оператора delete . Если память не возвратить операционной системе, то участок памяти станет недоступным для дальнейшего использования. Подобные ситуации приводят к утечке памяти.
2 ответа 2
Функция calloc()
Вместо функции malloc() можно воспользоваться функцией calloc() . Прототип функции:
В первом параметре функция calloc() принимает количество элементов, а во втором — размер одного элемента. Если память выделить не удалось, то функция возвращает нулевой указатель. Все элементы будут иметь значение 0 .
Используя функцию calloc() , следующую инструкцию из листинга 3.18:
мы можем записать так:
В качестве примера использования функции calloc() создадим двумерный массив (листинг 3.19). Для этого нам нужно создать массив указателей и в каждом элементе массива сохранить адрес строки. Память для каждой строки нужно выделить дополнительно.
Листинг 3.19. Динамическое выделение памяти под двумерный массив
Обратите внимание: при возвращении памяти вначале освобождается память, выделенная ранее под строки, а лишь затем освобождается память, выделенная ранее под массив указателей.
Так как мы сохраняем в массиве указателей лишь адрес строки, а не саму строку, количество элементов в строке может быть произвольным. Это обстоятельство позволяет создавать так называемые «зубчатые» двумерные массивы.
Строки в памяти могут быть расположены в разных местах, что не позволяет эффективно получать доступ к элементам двумерного массива. Чтобы доступ к элементам сделать максимально быстрым, можно представить двумерный массив в виде одномерного массива (листинг 3.20).
Листинг 3.20. Представление двумерного массива в виде одномерного
Так как в этом случае все элементы двумерного массива расположены в смежных ячейках, мы можем получить доступ к элементам с помощью указателя и адресной арифметики. Например, пронумеруем все элементы:
Ручное управление памятью
Язык не предоставляет механизмов для автоматического управления памятью. Выделение и освобождение памяти для создаваемых объектов остаётся полностью на совести разработчика. Пример такого языка — C. Он предоставляет ряд методов (malloc, realloc, calloc и free) для управления памятью — разработчик должен использовать их для выделения и освобождения памяти в своей программе. Этот подход требует большой аккуратности и внимательности. Так же он является в особенности сложным для новичков.
Ссылки
Вы можете подписаться на автора статьи в Twitter и на LinkedIn.
За вычитку перевода отдельное спасибо Александру Максимовскому и Катерине Шибаковой
Всё началось, как и многие другие расследования, с баг-репорта.
Это просто замечательный воспроизводимый сценарий, потому что он совершенно чётко указывает на стек Requests. Здесь не выполняется пользовательский (user-supplied) код: это часть библиотеки Requests или одной из его зависимостей; нет вероятности, что это пользователь написал дурацкий низкопроизводительный код. Настоящая фантастика. Ещё более фантастично использование публичного URL. Я мог выполнить сценарий! И, сделав это, я столкнулся с багом. При каждом выполнении.
Здесь была ещё одна прелестная подробность:
При 10 Мб не отмечается какого-то роста нагрузки на процессор и влияния на пропускную способность. При 1 Гб процессор загружается на 100 %, как и при 100 Мб, но пропускная способность падает ниже 100 Кб/с, в отличие от 1 Мб/с при 100 Мб.
Это очень интересный момент: он намекает на то, что литеральное значение (literal value) размера чанка влияет на рабочую нагрузку. Если учесть, что это происходит только при использовании PyOpenSSL, а также что большую часть времени стек обслуживает вышеприведённый код, проблема становится ясна:
Расследование показало, что стандартное поведение CFFI относительно FFI.new заключается в возвращении обнулённой памяти. Это означало линейное увеличение избыточности в зависимости от размера выделяемой памяти: более крупные объёмы приходилось обнулять дольше. Следовательно, плохое поведение связано с выделением больших объёмов. Мы воспользовались возможностью CFFI отключить обнуление этих буферов, и проблема ушла 1 . Так она решена, верно?
Шутки в сторону: это действительно позволило решить проблему. Но несколько дней спустя мне задали очень глубокомысленный вопрос: зачем вообще память активно обнулялась? Чтобы понять суть вопроса, давайте отвлечёмся и поговорим о выделении памяти в POSIX-системах.
Выделение памяти под массив
Выделение памяти под массив производится следующим образом:
Освободить выделенную память можно так:
Обратите внимание на то, что при освобождении памяти количество элементов не указывается. Пример выделения памяти под массив приведен в листинге 3.15.
Листинг 3.15. Динамическое выделение памяти под массив
Выделение памяти без возбуждения исключения
В ранних версиях C++ при нехватке памяти возвращался нулевой указатель. Такая же ситуация возникает в языке C при использовании функции malloc() . Чтобы оператор new , возвращал нулевой указатель, а не возбуждал исключение используется следующий синтаксис:
Для использования nothrow требуется подключить файл new . После выделения памяти следует проверить указатель на отсутствие нулевого значения. Пример выделения памяти без возбуждения исключения приведен в листинге 3.16.
Листинг 3.16. Динамическое выделение памяти без возбуждения исключения
Выделение памяти под один объект
Для выделения памяти под один объект предназначен следующий синтаксис:
Оператор new выделяет объем памяти, необходимый для хранения значения указанного типа, записывает в эту память начальное значение (если оно задано) и возвращает адрес. Работать в дальнейшем с этим участком памяти можно с помощью указателя. Пример выделения памяти:
При выделении памяти может возникнуть ситуация нехватки памяти. В случае ошибки оператор new возбуждает исключение bad_alloc (класс исключения объявлен в файле new ). Обработать это исключение можно с помощью конструкции try. catch . Пример выделения памяти с обработкой исключения:
Обратите внимание на то, что объявление указателя производится вне блока try . Если объявление разместить внутри блока, то область видимости переменной будет ограничена этим блоком. После выхода из блока переменная автоматически уничтожается, а выделенная память операционной системе не возвращается. Поэтому, объявление указателя должно находиться перед блоком, а не внутри него.
Возвратить ранее выделенную память операционной системе позволяет оператор delete . Оператор имеет следующий формат:
После использования оператора delete указатель по-прежнему будет содержать прежний адрес. Поэтому после использования оператора delete указатель принято обнулять. Пример выделения памяти под один объект приведен в листинге 3.14.
Листинг 3.14. Динамическое выделение памяти под один объект
Сборщик мусора
Функция realloc()
Функция realloc() выполняет перераспределение памяти. Прототип функции:
В первом параметре функция realloc() принимает указатель на ранее выделенную динамическую память, а во втором — новый требуемый размер в байтах. Функция выделит динамическую память длиной newSize , скопирует в нее элементы из старой области памяти, освободит старую память и вернет указатель на новую область памяти. Новые элементы будут иметь произвольные значения, так называемый «мусор». Если новая длина меньше старой длины, то лишние элементы будут удалены. Если память не может быть выделена, то функция вернет нулевой указатель, при этом старая область памяти не изменяется (в этом случае возможны утечки памяти, если значение присваивается прежнему указателю).
Если в первом параметре указать значение NULL , то будет выделена динамическая память и функция вернет указатель на нее. Если во втором параметре указано значение 0 , то ранее выделенная динамическая память освобождается и функция вернет нулевой указатель.
Пример использования функции realloc() приведен в листинге 3.21.
Листинг 3.21. Функция realloc()
В языке C++ вместо функций malloc() , calloc() и realloc() лучше использовать класс vector , который реализует динамический массив. Следить за размерами динамического массива нет необходимости, т. к. управление динамической памятью осуществляется автоматически:
Учебник C++ (Qt Creator и MinGW) в формате PDF
Помощь сайту
ПАО Сбербанк:
Счет: 40817810855006152256
Реквизиты банка:
Наименование: СЕВЕРО-ЗАПАДНЫЙ БАНК ПАО СБЕРБАНК
Корреспондентский счет: 30101810500000000653
БИК: 044030653
КПП: 784243001
ОКПО: 09171401
ОКОНХ: 96130
Скриншот реквизитов
Когда мы создаем переменную int var = 5 , все понятно, компьютер берет (выделяет) память 32 бита и записывает туда значение 5 в двоичном виде.
Но что происходит когда мы создаем переменную типа класс? class a = 5 . Что происходит? Сколько байт выделяется под эту переменную?
В первом случае ничего не понятно, так как размер типа int и количество реально выделяемой под него памяти определяется компилятором. Во втором случае ошибка синтаксиса.
class a = 5 - такого в С++ не бывает. class - это ключевое слово и оно не может быть использовано таким образом .
@VTT почему пишут такое? "myVar1 = 25; Указывает компьютеру, что нужно выделить память для переменной myVar1 типа int. Размер памяти выделяемой для нее зависит от самого компьютера. Например на 32-х разрядном компьютере он равен 4 байтам(32 бит)."
myVar1 = 25; можно написать только для уже ранее объявленной переменной. Поэтому не не ясно о каком "указывает компьютеру, что нужно выделить память" вы ведете речь. Нет, ничего подобного это не указывает.
При создании экземпляра класса автоматически запускается конструктор, который может делать с памятью что угодно, хоть всю память зарезервировать.
Объект в куче
При динамическом создании объекта, то есть, выделении памяти в куче, помимо размера самого экземпляра класса можно учесть также и размер указателя на него:
Получается даже, что размер указателя может быть больше самого объекта:
В первом случае - int var = 5; - компилятор выделяет sizeof(int) байтов памяти под переменную var . Это совсем не обязательно 32 бита.
Во втором случае - Class a = 5; - компилятор точно таким же образом выделяет sizeof(Class) байтов под переменную a . Все совершенно единообразно.
Выделение памяти в таких примерах никоим образом не зависит правой части данного объявления, т.е. = 5 никак не влияет на размер выделяемой памяти.
А затем, когда память уже выделена, значение 5 используется в качестве инициализатора для нового объекта. Как именно оно используется - зависит от конкретного типа. В первом случае оно просто заносится в переменную var . А что произойдет во втором случае уже зависит от деталей типа Class . Инициализация в С++ - процесс, описываемый целым набором весьма запутанных правил.
Integer vs int
Все мы знаем, что в java — everything is an object. Кроме, пожалуй, примитивов и ссылок на сами объекты. Давайте рассмотрим две типичных ситуации:
В этих простых строках разница просто огромна, как для JVM так и для ООП. В первом случае, все что у нас есть — это 4-х байтная переменная, которая содержит значение из стека. Во втором случае у нас есть ссылочная переменная и сам объект, на который эта переменная ссылается. Следовательно, если в первом случае мы определено знаем, что занимаемый размер равен:
Забегая вперед скажу — во втором случае количество потребляемой памяти приблизительно в 5 раз больше и зависит от JVM. А теперь давайте разберемся, почему разница настолько огромна.
Из чего же состоит объект?
- Заголовок объекта;
- Память для примитивных типов;
- Память для ссылочных типов;
- Смещение/выравнивание — по сути, это несколько неиспользуемых байт, что размещаются после данных самого объекта. Это сделано для того, чтобы адрес в памяти всегда был кратным машинному слову, для ускорения чтения из памяти + уменьшения количества бит для указателя на объект + предположительно для уменьшения фрагментации памяти. Стоит также отметить, что в java размер любого объекта кратен 8 байтам!
Структура заголовка объекта
- Маркировочное слово (mark word) — к сожалению мне так и не удалось найти назначение этой информации, подозреваю что это просто зарезервированная на будущее часть заголовка.
- Hash Code — каждый объект имеет хеш код. По умолчанию результат вызова метода Object.hashCode() вернет адрес объекта в памяти, тем не менее некоторые сборщики мусора могут перемещать объекты в памяти, но хеш код всегда остается одним и тем же, так как место в заголовке объекта как раз может быть использовано для хранения оригинального значения хеш кода.
- Garbage Collection Information — каждый java объект содержит информацию нужную для системы управления памятью. Зачастую это один или два бита-флага, но также это может быть, например, некая комбинация битов для хранения количества ссылок на объект.
- Type Information Block Pointer — содержит информацию о типе объекта. Этот блок включает информацию о таблице виртуальных методов, указатель на объект, который представляет тип и указатели на некоторые дополнительные структуры, для более эффективных вызовов интерфейсов и динамической проверки типов.
- Lock — каждый объект содержит информацию о состоянии блокировки. Это может быть указатель на объект блокировки или прямое представление блокировки.
- Array Length — если объект — массив, то заголовок расширяется 4 байтами для хранения длины массива.
Спецификация Java
Известно, что примитивные типы в Java имеют предопределенный размер, этого требует спецификация для переносимости кода. Поэтому не будем останавливаться на примитивах, так как все прекрасно описано по ссылке выше. А что же говорит спецификация для объектов? Ничего, кроме того, что у каждого объекта есть заголовок. Иными словами, размеры экземпляров Ваших классов могут отличатся от одной JVM к другой. Собственно, для простоты изложения я буду приводить примеры на 32-х разрядной Oracle HotSpot JVM. А теперь давайте разберем самые используемые классы Integer и String.
Integer и String
Итак, давайте попробуем подсчитать сколько же будет занимать объект класса Integer в нашей 32-х разрядной HotSpot JVM. Для этого нужно будет заглянуть в сам класс, нам интересны все поля, которые не объявлены как static. Из таких видим только одно — int value. Теперь исходя из информации выше получаем:
Теперь заглянем в класс строки:
И подсчитаем размер:
Ну и это еще не все… Так как строка содержит ссылку на массив символов, то, по сути, мы имеем дело с двумя разными объектами — объектом класса String и самим массивом, который хранит строку. Это, как бы, верно с точки зрения ООП, но если посмотреть на это со стороны памяти, то к полученному размеру нужно добавить и размер выделенного для символов массива. А это еще 12 байт на сам объект массива + 2 байта на каждый символ строки. Ну и, конечно же, не забываем добавлять выравнивание для кратности 8 байтам. Итого в конечном итоге простая, казалось бы, строка new String(«a») выливается в:
Важно отметить, что new String(«a») и new String(«aa») будут занимать одинаковое количество памяти. Это важно понимать. Типичный пример использования этого факта в свою пользу — поле hash в классе String. Если бы его не было, то объект строки так или иначе занимал бы 24 байта, за счет выравнивания. А так получается что для этих 4-х байтов нашлось очень достойное применение. Гениальное решение, не правда ли?
Размер ссылки
Немножко хотел бы оговорится о ссылочных переменных. В принципе, размер ссылки в JVM зависит от ее разрядности, подозреваю, что для оптимизации. Поэтому в 32-х разрядных JVM размер ссылки обычно 4 байта, а в 64-х разрядных — 8 байт. Хотя это условие и не обязательно.
Группировка полей
Зачем все это?
Иногда возникает ситуация в которой Вам необходимо прикинуть приблизительный объем памяти для хранения тех или иных объектов, например словаря, эта маленькая справка поможет быстро сориентироваться. Также, это потенциально возможный способ оптимизации, особенно в том окружении, где доступ к его настройкам не доступен.
Выводы
Тема памяти в java очень интересна и обширна, когда я начинал писать эту статью, то думал что уложусь в пару примеров с выводами. Но чем дальше и глубже копаешь, тем больше и интересней становится. Вообще, знать как выделяется память для объектов очень полезная вещь, так как поможет Вам сэкономить память, предотвратить подобные проблемы или оптимизировать вашу программу в местах, где это казалось невозможным. Конечно, места где можно использовать такие оптимизации — очень редки, но все же… Надеюсь статья была Вам интересной.
Привет, Хабр! Представляю вашему вниманию перевод статьи «Demystifying memory management in modern programming languages» за авторством Deepu K Sasidharan.
В данной серии статей мне бы хотелось развеять завесу мистики над управлением памятью в программном обеспечении (далее по тексту — ПО) и подробно рассмотреть возможности, предоставляемые современными языками программирования. Надеюсь, что мои статьи помогут читателю заглянуть под капот этих языков и узнать для себя нечто новое.
Углублённое изучение концептов управления памятью позволяет писать более эффективное ПО, потому как стиль и практики кодирования оказывают большое влияние на принципы выделения памяти для нужд программы.
Получение ресурса есть инициализация (RAII)
RAII — это программная идиома в ООП, смысл которой заключается в том, что выделяемая для объекта область памяти строго привязывается к его времени существования. Память выделяется в конструкторе и освобождается в деструкторе. Данный подход был впервые реализован в C++, а так же используется в Ada и Rust.
Автоматический подсчёт ссылок (ARC)
Данный подход весьма похож на сборку мусора с подсчётом ссылок, однако, вместо запуска процесса подсчёта в определённые интервалы времени, инструкции выделения и освобождения памяти вставляются на этапе компиляции прямо в байт-код. Когда же счётчик ссылок достигает нуля, память освобождается как часть нормального потока выполнения программы.
Автоматический подсчёт ссылок всё так же не позволяет обрабатывать циклические ссылки и требует от разработчика использования специальных ключевых слов для дополнительной обработки таких ситуаций. ARC является одной из особенностей транслятора Clang, поэтому присутствует в языках Objective-C и Swift. Так же автоматический подсчет ссылок доступен для использования в Rust и новых стандартах C++ при помощи умных указателей.
Объект в стеке
В программе выделяется столько байт, сколько требуется для хранения данных экземпляра данного класса. Например, объект пустого класс займёт один байт, если в нём хранится int , то его размер прибавится к размеру объекта класса. Вот интересный код для исследования этих свойств:
Результат выполнения с моим компилятором:
Меняя число переменных можно заметить, что поля с модификатором static не влияют на размер выделенной памяти, что и логично, ведь она не относится к конкретным экземплярам класса.
Владение
Это сочетание RAII с концепцией владения, когда каждое значение в памяти должно иметь только одну переменную-владельца. Когда владелец уходит из области выполнения, память сразу же освобождается. Можно сказать, что это примерно как подсчёт ссылок на этапе компиляции. Данный подход используется в Rust и при этом я не смог найти ни одного другого языка, который бы использовал подобный механизм.
В данной статье были рассмотрены основные концепции в сфере управления памятью. Каждый язык программирования использует собственные реализации этих подходов и оптимизированные для различных задач алгоритмы. В следующих частях, мы подробнее рассмотрим решения для управления памятью в популярных языках.
Читайте так же другие части серии:
Для чего используется оперативная память?
Когда программа выполняется в операционный системе компьютера, она нуждается в доступе к оперативной памяти (RAM) для того, чтобы:
- загружать свой собственный байт-код для выполнения;
- хранить значения переменных и структуры данных, которые используются в процессе работы;
- загружать внешние модули, которые необходимы программе для выполнения задач.
Стек используется для статичного выделения памяти. Он организован по принципу «последним пришёл — первым вышел» (LIFO). Можно представить стек как стопку книг — разрешено взаимодействовать только с самой верхней книгой: прочитать её или положить на неё новую.
- благодаря упомянутому принципу, стек позволяет очень быстро выполнять операции с данными — все манипуляции производятся с «верхней книгой в стопке». Книга добавляется в самый верх, если нужно сохранить данные, либо берётся сверху, если данные требуется прочитать;
- существует ограничение в том, что данные, которые предполагается хранить в стеке, обязаны быть конечными и статичными — их размер должен быть известен ещё на этапе компиляции;
- в стековой памяти хранится стек вызовов — информация о ходе выполнения цепочек вызовов функций в виде стековых кадров. Каждый стековый кадр это набор блоков данных, в которых хранится информация, необходимая для работы функции на определённом шаге — её локальные переменные и аргументы, с которыми её вызывали. Например, каждый раз, когда функция объявляет новую переменную, она добавляет её в верхний блок стека. Затем, когда функция завершает свою работу, очищаются все блоки памяти в стеке, которые функция использовала — иными словами, очищаются все блоки ее стекового кадра;
- каждый поток многопоточного приложения имеет доступ к своему собственному стеку;
- управление стековой памятью простое и прямолинейное; оно выполняется операционной системой;
- в стеке обычно хранятся данные вроде локальных переменных и указателей;
- при работе со стеком есть вероятность получать ошибки переполнения стека (stack overflow), так как максимальный его размер строго ограничен. Например, ошибка при составлении граничного условия в рекурсивной функции совершенно точно приведёт к переполнению стека;
- в большинстве языков существует ограничение на размер значений, которые можно сохранить в стек;
Использование стека в JavaScript. Объекты хранятся в куче и доступны по ссылкам, которые хранятся в стеке. Тут можно посмотреть в видеоформате
Куча используется для динамического выделения памяти, однако, в отличие от стека, данные в куче первым делом требуется найти с помощью «оглавления». Можно представить, что куча это такая большая многоуровневая библиотека, в которой, следуя определённым инструкциям, можно найти необходимую книгу.
- операции на куче производятся несколько медленнее, чем на стеке, так как требуют дополнительного этапа для поиска данных;
- в куче хранятся данные динамических размеров, например, список, в который можно добавлять произвольное количество элементов;
- куча общая для всех потоков приложения;
- вследствие динамической природы, куча нетривиальна в управлении и с ней возникает большинство всех проблем и ошибок, связанных с памятью. Способы решения этих проблем предоставляются языками программирования;
- типичные структуры данных, которые хранятся в куче — это глобальные переменные (они должны быть доступны для разных потоков приложения, а куча как раз общая для всех потоков), ссылочные типы, такие как строки или ассоциативные массивы, а так же другие сложные структуры данных;
- при работе с кучей можно получить ошибки выхода за пределы памяти (out of memory), если приложение пытается использовать больше памяти, чем ему доступно;
- размер значений, которые могут храниться в куче, ограничен лишь общим объёмом памяти, который был выделен операционной системой для программы.
Динамическое выделение памяти в языке C
Язык C++ поддерживает также функции malloc() , calloc() , realloc() и free() , позволяющие управлять динамической памятью в языке C. Хотя эти функции можно использовать и в языке C++, тем не менее стоит отдать предпочтение оператору new и явной обработке исключения. Описание этих функций приведено в книге лишь для того, чтобы вы могли разобраться в чужом коде.
malloc и calloc и vmalloc, ох ты ж!
Многим программистам известен стандартный способ запроса памяти у оперативной системы. В этом механизме задействована функция malloc из стандартной библиотеки С (можете почитать документацию о ней для вашей ОС, введя в мануале в поиск man 3 malloc ). Эта функция берёт один аргумент — количество байтов памяти, которое нужно выделить. Стандартная библиотека С выделяет память по одной из нескольких разных методик, но так или иначе она возвращает указатель на участок памяти, по крайней мере такой же большой, как и запрошенный вами объём.
По умолчанию malloc возвращает неинициализированную память. То есть стандартная библиотека С выделяет какой-то объём и немедленно передаёт его вашей программе, без изменения данных, которые уже там находятся. То есть при использовании malloc вашей программе будет возвращаться буфер, в который она уже записывала данные. Это распространённая причина багов в не безопасных по памяти (memory-unsafe) языках, например С. В целом читать из неинициализированной памяти очень рискованно.
Однако у malloc есть друг, задокументированный на той же странице мануала: calloc . Его главное отличие заключается в том, что он принимает два аргумента — счётчик и размер. Используя malloc , вы просите стандартную библиотеку С: «Пожалуйста, выдели мне не менее n байтов». А при вызове calloc вы просите её: «Пожалуйста, выдели достаточно памяти для n объектов размером m байтов». Очевидно, что первичная идея вызова calloc заключалась в безопасном выделении кучи для массивов объектов 2 .
Но у calloc есть побочный эффект, связанный с его исходным предназначением для размещения массивов в памяти. О нём очень скромно упоминается в мануале.
Это идёт рука об руку с предназначением calloc . К примеру, если вы размещаете в памяти массив значений, то зачастую будет очень полезно, чтобы он изначально имел состояние по умолчанию. В некоторых современных безопасных по памяти языках это уже стало стандартным поведением при создании массивов и структур. Скажем, когда в Go инициализируешь структуру, то все её члены по умолчанию приведены к своим так называемым «нулевым» значениям, эквивалентным «таким значениям, которые были бы, если бы всё сбросили в ноль». Это можно считать обещанием, что все Go-структуры размещаются в памяти с помощью calloc 3 .
Такое поведение означает, что malloc возвращает неинициализированную память, а calloc — инициализированную. А раз так, да ещё и в свете вышеупомянутых строгих обещаний, то операционная система может оптимизировать выделяемую память. И действительно, многие современные ОС так делают.
Конечно, простейший способ внедрить calloc — это написать что-то вроде:
Стоимость подобной функции изменяется примерно линейно по отношению к размеру выделяемой памяти: чем больше байтов, тем дороже их все обнулить. Сегодня большинство ОС по факту содержат стандартные библиотеки С, в которых прописаны оптимизированные пути для memset (обычно используются специализированные процессорные векторные инструкции, позволяющие одной инструкцией обнулять сразу большое количество байтов). Тем не менее стоимость этой процедуры меняется линейно.
Для выделения больших объёмов в ОС используется ещё один трюк, имеющий отношение к виртуальной памяти.
Здесь мы не будем разбирать всю структуру и работу виртуальной памяти, но я очень рекомендую об этом почитать (тема крайне интересная!). Вкратце: виртуальная память — это ложь ядра ОС процессам о доступной памяти. У каждого выполняемого процесса есть своё представление о памяти, принадлежащей ему и только ему. Это представление косвенно «отображается» (mapped) на физическую память.
В результате ОС может прокручивать всевозможные хитрые трюки. Чаще всего она выдаёт за память специальные файлы, отображаемые в неё (memory-mapped file). Они используются для выгрузки содержимого памяти на диск, а также для отображения в них памяти. В последнем случае программа просит ОС: «Пожалуйста, выдели мне n байтов памяти и сохрани их в файле на диске, так, чтобы, когда я буду писать в память, все записи выполнялись бы в этот файл, а когда считываю из памяти, то данные считывались бы из него же».
На уровне ядра это работает так: когда процесс пытается считать из такой памяти, процессор уведомляет, что память не существует, ставит процесс на паузу и бросает «ошибку страницы» (page fault). Ядро помещает в память актуальные данные, чтобы приложение могло их считать. Затем процесс снимается с паузы и находит в соответствующем месте волшебным образом появившиеся данные. С точки зрения процесса всё произошло мгновенно, без паузы.
Этот механизм можно использовать для выполнения других тонких трюков. Один из них заключается в «бесплатности» выделения очень больших объёмов памяти. Или, точнее, в том, чтобы сделать их стоимость пропорциональной степени использования этой памяти, а не выделяемому размеру.
Исторически сложилось так, что многие программы, которым во время выполнения нужны порядочные куски памяти, при запуске создают большой буфер, который потом может распределяться внутри программы в ходе её жизненного цикла. Так делалось потому, что программы писались для окружений, не использовавших виртуальную память; программам приходилось сразу занимать какие-то объёмы памяти, чтобы потом не испытывать в ней недостатка. Но после внедрения виртуальной памяти такое поведение стало ненужным: каждая программа может выделить себе столько памяти, сколько надо, не вырывая у других кусок изо рта 4 .
Чтобы избежать очень больших затрат при запуске приложений, операционные системы начали врать приложениям. В большинстве ОС, если вы попытаетесь выделить более 128 Кб в рамках одного вызова, стандартная библиотека С напрямую попросит у ОС совершенно новые страницы виртуальной памяти, которые покроют запрошенные объёмы. Но главное: такое выделение почти ничего не стоит. Ведь на самом деле ОС ничего не делает: она лишь перенастраивает схему виртуальной памяти. Так что при использовании malloc расходы получаются мизерными.
Память не была «приписана» к процессу, и, как только приложение пытается использовать её на самом деле, возникает ошибка страницы памяти. Здесь вмешивается ОС, находит нужную страницу и помещает её туда, куда обращается процесс, так же, как и в случае с ошибкой памяти и файлом отображаемой памяти. Только разница в том, что виртуальная память обеспечивается памятью физической, а не файлом.
В результате, если вызвать malloc(1024 * 1024 * 1024) для выделения 1 Гб памяти, это произойдёт почти мгновенно, потому что на самом деле процессу память не выделяется. Зато программы могут моментально «выделять» для себя многие гигабайты, хотя в реальности это происходило бы далеко не быстро.
Но ещё удивительнее то, что такая же оптимизация доступна и для calloc . ОС может отображать совершенно новую страницу на так называемую «нулевую страницу»: это страница памяти, доступная только для чтения, причём считываются из неё одни лишь нули. Изначально такое отображение представляет собой копирование при записи (copy-on-write): когда ваш процесс пытается записать данные в эту новую память — вмешивается ядро, копирует все нули в новую страницу, а затем разрешает вам выполнить запись.
Благодаря такому ухищрению со стороны ОС calloc может при выделении больших объёмов делать то же самое, что и malloc , запрашивая новые страницы виртуальной памяти. Это будет происходить бесплатно до тех пор, пока память не начнёт использоваться. Подобная оптимизация означает, что стоимость calloc(1024 * 1024 * 1024, 1) будет равна вызову malloc для такого же объёма памяти, несмотря на то что calloc ещё и обещает заполнять память нулями. Умно!
Итак: если CFFI использовал calloc , то почему память обнулялась?
Для начала: calloc использовался не всегда. Но я подозревал, что в данном случае могу воспроизвести замедление напрямую с помощью calloc , поэтому снова накидал программу:
Очень простая программа на C, выделяющая и освобождающая 100 Мб посредством вызова calloc десять тысяч раз. Затем выполняется выход. Далее — два варианта 5 :
- calloc может использовать вышеописанную хитрость с виртуальной памятью. В таком случае программа должна работать быстро: выделяемая память на самом деле не используется, не разбивается на страницы, а страницы не становятся «грязными» (dirty). ОС врёт нам насчёт выделения, а мы не ловим её за руку, так что всё работает прекрасно.
- calloc может привлечь malloc и вручную обнулить память с помощью memset . Это должно делаться очень-очень медленно: в сумме нам надо обнулить терабайт памяти (десять тысяч циклов по 100 Мб), что очень непросто.
Более того, если увеличить ALLOCATION_SIZE (например, 1000 * 1024 * 1024 ), то на MacOS эта программа станет работать почти мгновенно! Что за чертовщина?
Что тут вообще происходит?
В MacOS есть утилита sample (см. man 1 sample ), которая может многое рассказать о выполняемом процессе, регистрируя его состояние. Для нашего кода sample выдаёт такое:
Здесь мы ясно видим, что куча времени тратится на метод _platform_bzero$VARIANT$Haswell . Он используется для обнуления буферов. То есть MacOS их обнуляет. Почему?
Похоже, вся магия происходит в large_malloc. Эта ветка нужна для выделения памяти крупнее 127 Кб, она использует трюк с виртуальной памятью. Так почему у нас всё медленно работает?
Однако это тот случай для calloc , когда нужно обнулить байты. И если MacOS находит страницу, которую можно использовать повторно и которая была вызвана из calloc , то память обнулится. Вся. И так каждый раз.
В этом есть свой резон: обнулённые страницы — ограниченный ресурс, особенно в условиях скромного железа (смотрю на Apple Watch). Так что если страницу можно использовать повторно, то это может дать хорошую экономию.
Однако кеш страницы полностью лишает нас преимуществ использования calloc для предоставления обнулённых страниц памяти. Это было бы не так уж плохо, если бы делалось только для «грязных» страниц. Если приложение записывает в обнуляемую страницу, то та, вероятно, не будет обнулена. Но MacOS выполняет это безоговорочно. Это значит, что даже если вызвать alloc , free и calloc , вообще не трогая память, то при втором вызове calloc будут взяты страницы, выделенные во время первого вызова и ни разу не поддержанные физической памятью. Поэтому ОС приходится загрузить (page-in) всю эту память, чтобы обнулить её, хотя она уже была обнулена. Этого мы и хотим избежать с помощью средства распределения на базе виртуальной памяти, когда доходит до выделения больших объёмов: ни разу не использовавшаяся память становится использованной «списком свободных» страниц.
В результате на MacOS стоимость calloc линейно возрастает в зависимости от размера выделяемой памяти вплоть до 125 Мб, несмотря на то что другие ОС демонстрируют поведение O(1) начиная со 127 Кб. После 125 Мб MacOS перестаёт кешировать страницы, так что скорость волшебным образом взлетает.
Я не ожидал найти такой баг из программы на Python, и у меня возник ряд вопросов. Например, сколько процессорных циклов теряется на обнуление памяти, которая уже обнулена? Сколько переключений контекста уходит на то, чтобы заставлять приложения загружать (page-in) память, которую они не использовали (и не собираются), чтобы ОС могла бессмысленно её обнулить?
Мне кажется, всё это подтверждает верность старой поговорки: утечки есть во всех абстракциях (all abstractions are leaky). Вы не можете забывать об этом лишь потому, что программируете на Python. Ваша программа выполняется на машине, использующей память и всякие трюки для её управления. Однажды, совершенно неожиданно, написанный вами код станет работать очень медленно. И разобраться с этим удастся, лишь разобравшись во внутренностях ОС.
Этот баг был описан как Radar 29508271. Один из самых странных багов, что мне встречались.
Функции malloc() и free()
Для выделения динамической памяти в языке C предназначена функция malloc() . Прототип функции:
Функция malloc() принимает в качестве параметра размер памяти в байтах и возвращает указатель, имеющий тип void * . Если память выделить не удалось, то функция возвращает нулевой указатель. Все элементы будут иметь произвольное значение, так называемый «мусор». В языке C указатель типа void * неявно приводится к другому типу, поэтому использовать явное приведение не нужно. В языке C++ перед присвоением значения указателю необходимо выполнить явное приведение к используемому типу. Кроме того, чтобы программа была машинонезависимой следует применять оператор sizeof для вычисления размера памяти, требуемого для определенного типа.
Освободить ранее выделенную динамическую память позволяет функция free() . Функция принимает в качестве параметра указатель на ранее выделенную память и освобождает ее. Прототип функции:
Пример выделения памяти для одного объекта приведен в листинге 3.17.
Листинг 3.17. Динамическое выделение памяти для одного объекта
Пример выделения памяти под массив приведен в листинге 3.18.
Листинг 3.18. Динамическое выделение памяти под массив
Выделение памяти под один объект
Для выделения памяти под один объект предназначен следующий синтаксис:
Оператор new выделяет объем памяти, необходимый для хранения значения указанного типа, записывает в эту память начальное значение (если оно задано) и возвращает адрес. Работать в дальнейшем с этим участком памяти можно с помощью указателя. Пример выделения памяти:
При выделении памяти может возникнуть ситуация нехватки памяти. В случае ошибки оператор new возбуждает исключение bad_alloc (класс исключения объявлен в файле new ). Обработать это исключение можно с помощью конструкции try. catch . Пример выделения памяти с обработкой исключения:
Обратите внимание на то, что объявление указателя производится вне блока try . Если объявление разместить внутри блока, то область видимости переменной будет ограничена этим блоком. После выхода из блока переменная автоматически уничтожается, а выделенная память операционной системе не возвращается. Поэтому, объявление указателя должно находиться перед блоком, а не внутри него.
Возвратить ранее выделенную память операционной системе позволяет оператор delete . Оператор имеет следующий формат:
После использования оператора delete указатель по-прежнему будет содержать прежний адрес. Поэтому после использования оператора delete указатель принято обнулять. Пример выделения памяти под один объект приведен в листинге 3.14.
Листинг 3.14. Динамическое выделение памяти под один объект
Почему эффективное управление памятью важно?
В отличие от жёстких дисков, оперативная память весьма ограниченна (хотя и жёсткие диски, безусловно, тоже не безграничны). Если программа потребляет память не высвобождая её, то, в конечном итоге, она поглотит все доступные резервы и попытается выйти за пределы памяти. Тогда она просто упадет сама, или, что ещё драматичнее, обрушит операционную систему. Следовательно, весьма нежелательно относиться легкомысленно к манипуляциям с памятью при разработке ПО.
Часть 1: Введение в управление памятью
Управление памятью — это целый набор механизмов, которые позволяют контролировать доступ программы к оперативной памяти компьютера. Данная тема является очень важной при разработке ПО и, при этом, вызывает затруднения или же вовсе остаётся черным ящиком для многих программистов.
Различные подходы
Современные языки программирования стараются максимально упростить работу с памятью и снять с разработчиков часть головной боли. И хотя некоторые почтенные языки всё ещё требуют ручного управления, большинство всё же предоставляет более изящные автоматические подходы. Порой в языке используется сразу несколько подходов к управлению памятью, а иногда разработчику даже доступен выбор какой из вариантов будет эффективнее конкретно для его задач (хороший пример — C++). Перейдём к краткому обзору различных подходов.
Читайте также: