Skip to the content.

Динамическая память


Зачем же она нужна?

Все статические переменные храняться на стеке, который не бесконечный, и не предназначен для хранения больших объемов данных. Объём стека, который выделяется (зависит от реализаций, обычно 1-8 Мб) может стать проблемой, особенно в программах связанных с графикой. Вторая проблема связана с потерей памяти: мы не знаем какой длины пользователь введет строку и нам остается только угадать возможный максимальный размер, надеясь что этого будет достаточно (ведь размер статических переменных должен заведомо быть известен во время компиляции). Однако это не лучшее решение, поскольку если мы выделили память под 30 символов, а пользователь в среднем вводит только 15, то остальная половина памяти простаивает без дела.

Попробуем выделить 80 Мб (заведомо больше чем объем стека) на статическую переменную:

// ошибка компиляции (зависит от компилятора)
dobule m[10 000 000] = {};

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

Управление динамической памятью

Динамическая память выделяется в сегменте данных (.data), для выделения сегмента кода есть другой механизм - динамическая библиотека.

Куча - структура, отвечающая за выделение дополнительной памяти.

size_t - целочисленный беззнаковый тип, который гарантировано может вместить в себя размер любого типа в байтах. Он используется для указания размеров типов данных, для индексации массивов и прочего. В некоторых ситуациях он лучше чем тип int: типа int, занимающего 4 байта на 64 битной платформе, может оказаться недостаточно что бы проинициализировать огромный массив который при этом будет влезать в ОЗУ (т.е. огромный массив в ОЗУ то влезает, но что бы записать его размер типа int не хватает).

void * - создать переменную типа void нельзя, однако указатель на значение типа void будет означать что он указывает на нетипизированную область памяти (раньше использовали char *).

sizeof - оператор, который по типу либо выражению возвращает количество байт, которое оно занимает.

Существует два стиля управления динамической памятью:

Выделение памяти в стиле С

Стандартная библиотека __cstdlib (#include )__ предоставляет 4 функции для управления памятью:

1. void * malloc (size_t size);

memory allocation - выделение памяти.

Выделить область памяти размера как минимум size (почему как минимум? на самом деле возможно чуть больше, поскольку она старается округлять до кратности 16, так проще для реализации кучи. Так же указатель который возвращается из malloc указывает не на начало области памяти, а немного дальше, поскольку вначале лежит служебная информация (например размер области)). Данные в выделенной области не инициализируются.

2. void free (void * ptr);

Освобождает ранее выделенную память по указателю. Стоит обратить внимание, что нам не требуется передавать размер области как раз из-за служебной информации, которая лежит вначале области.

3. void * calloc (size_t nmemb, size_t size);

clear allocation - чистое выделение.

Выделяет массив из nmemb количества элементов размера size (размер каждого элемента). Данные инициализируются нулями.

4. void * realloc (void * ptr, size_t size);

reallocation - перераспределение памяти.

Позволяет изменять размер области памяти по указателю ptr до size. Eсли это возможно, то старается увеличить размер не прибегая к копированию данных (если окажется что после области есть достаточное количество свободного места). Соответственно либо возвращает тот же указатель который получила на вход (если удалось увеличить размер без копирования), либо указатель на новую область памяти (если пришлось копировать, старая область памяти при этом освобождается).

Примеры:

Создание массива из 1000 int. Стоит обратить внимание, что malloc возвращает указатель void *, который нужно привести к определенному типу явно: (int *) (В С++ не требуется, приведение происходит неявно):

int * m = (int *)malloc (1000 * sizeof(int));
m[10] = 10;

Изменить размер массива до 2000:

m = (int *)realloc(m, 2000 * sizeof(int));

Освободить место, стоит отметить что преобразование int * -> void * тут происходит автоматически. Хорошим тоном является зануление указателя после освобождения памяти, что бы показать что он ни на что не указывает:

free(m);
m = 0;

Создать массив нулей. Помним, что calloc “зануляет” область памяти:

int * m = (int *)calloc(3000, sizeof(int));

Выделение памяти в стиле С++

ЯП С++ предоставляет два набора операторов для выделения памяти:

new возвращает уже типизированный указатель и нам не требуется его явно приводить к определенному типу.

Примеры:

Выделение памяти под int с значением 5, изменение значения и освобождение:

int * m = new int (5); // прямая инициализация
// int * m = new int {5}; // uniform инициализация (С++11)
*m = 8;
delete m; 

Создание массива значений типа int и освобождение:

int * m = new int[1000];
delete [] m;

ВНИМАНИЕ. Выделять память очень дорогое удовольствие (ниже подробнее), поэтому обычно выделяют сразу большую область (буфер), которую по заполнению увеличивают в 1.5 либо 2 раза. Увеличивать область в 1.5 раза выгоднее чем в 2, по такой причине:

Увеличиваем в 2 раза:

Выделили 16 байт
Выделили 32 байта, освободили 16
Выделили 64 байта, освободили 48 (при условии что освободившиеся области смежные)
Выделили 128, освободили 112
Выделили 256, освободили 240
...
Никогда не будет момента времени, когда освободившейся памяти станет достаточно для повторного использования.

Увеличиваем в 1.5 раза:

Выделили 16 байт
Выделили 24 байта, освободили 16
Выделили 36, освободили 40
Выделили 54, освободили 76
Выделили 81, освободили 130
Выделили 122 байта из ранее освободившихся 130

Более того, есть два схемы перевыделения памяти:

Проблемы при работе с памятью:

Производительность: создание переменной на стеке гораздо дешевле чем в куче (сдвинуть ESP на размер переменной против системного вызова (обращение к ОС), поиска свободной области нужного размера, выделения и освобождения динамической памяти). Пока ОС ищет свободную область памяти нашей программе (позже вернет указатель на свободное место), программа впадает в так называемый kernel space, который хоть и не заметен, но длится куда дольше обычной операции.

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

Утечка памяти:

int * m = new int[1000]; // создали массив, на который указывает m
m = new int[2000];       // теперь m указывает на другой массив, утечка памяти

Утечка поскольку память в которой лежал прошлый массив не была освобождена, и внешних указателей на эту область больше нет, т.е. она освободиться только по завершению программы (ОС держит эту память для нашей программы). Еще не был вызван delete (обычно перед выходом из функции где была инициализирована динамическая переменная) - тоже утечка памяти.

Так же бывают утечки когда освобождение памяти есть, но оно некоректно:

int * m1 = new int [1000];
delete m1; // delete вместо delete [], неопределенное поведение

int * p = new int(0);
free(p); // совмещение С и С++ стилей

int * q1 = (int *)malloc(sizeof(int));
free(q1);
free(q1); // двойное удаление, ошибка: эта память уже отмечена в куче как свободная

// решение проблемы с двойным удалением
int * q2 = (int *)malloc(sizeof(int));
free(q2);
q2 = 0; // обнулили указатель
free(q2); // ничего не делает если передать нулевой указатель

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

int * value = new (std::nothrow) int;

Вспомогательные и очень небезопасные функции для работы с памятью


memcpy

void * memcpy(void * destptr, const void * srcptr, size_t n);

memcpy копирует n байтов из первого блока памяти, на который указывает srcptr во второй блок памяти, на который указывает destptr.

Функция возвращает указатель на массив, в который были скопированы данные.

Стоит отметить, что тип данных объектов, на которые указывают как srcptr так и destptr не имеют никакого значения - эта функция работает с бинарными данными.

Чтобы избежать переполнения блока памяти destptr, его размер должен быть не менее n байтов. Однако, может возникнуть ситуация, когда destptr и srcptr пересекутся, в такой ситуации функция memmove является более безопасной.

memmove

void * memmove(void * destptr, const void * srcptr, size_t n);

memmove копирует n байтов из блока памяти источника, на который указывает srcptr, в блок памяти назначения, на который указывает destptr. Копирование происходит через промежуточный буфер, что решает проблему memcpy.

Функция возвращает указатель на массив, в который скопированы данные.

memset

void * memset (void * ptr, int value, size_t n);

Присваивает значение value первым n байтам блока памяти, на который указывает ptr.

Стоит отметить, что value неявно приводиться при этом к unsigned char.

Функция возвращает ptr.