Привет, Хабр!
Сегодня рассмотрим, почему free()
не всегда освобождает память, как работает malloc()
, когда glibc действительно возвращает память в ОС, и как избежать фрагментации хипа. А так же напишем кастомный аллокатор.
malloc
Вызываете malloc(42), думаете, что получили 42 байта и живёте счастливо. Но всё гораздо сложнее. malloc() — это не просто «дай мне N байтов», а целая система управления памятью, которая старается минимизировать фрагментацию, уменьшить системные вызовы и ускорить выделение памяти.
malloc(size_t size) — это стандартная функция из stdlib.h, которая выделяет size байтов в хипе и возвращает указатель на начало блока.
#include <stdlib.h>
void *ptr = malloc(42); // Запрашиваем 42 байта
Что будет происходить?
malloc(42)
не выделит ровно 42 байта — система округлит размер до удобного для CPU значения (обычно 8 или 16 байт). Если в уже выделенной памяти есть подходящий свободный блок — он будет использован.
Если свободного блока нет malloc() запрашивает новый блок памяти у ОС через sbrk()
(для маленьких аллокаций) или mmap()
(для больших).
malloc() возвращает указатель на данные, но в памяти перед ним хранится метаинформация (размер блока, флаги и т. д.).
Рассмотрим упрощённый вариант стандартной реализации malloc()
(из glibc):
void *malloc(size_t size) {
if (size == 0) return NULL; // Нельзя выделять 0 байт
size = align_size(size); // Выравнивание по 8 или 16 байтам
chunk_t *chunk = find_free_chunk(size);
if (chunk == NULL) {
chunk = request_memory_from_os(size);
}
mark_chunk_used(chunk);
return (void *)(chunk + 1);
}
align_size(size)
округляет запрошенный размер до 8 или 16 байт (для скорости работы с памятью). Например, malloc(42)
→ выделит 48 байт, а malloc(25)
→ 32 байта.
Если есть освобождённые ранее блоки, malloc()
попробует переиспользовать их, чтобы не делать лишний системный вызов.
Если нет свободного блока — запрос памяти у ОС (request_memory_from_os()):
Для маленьких аллокаций используется
sbrk()
(растягивает хип).Для больших аллокаций используется
mmap()
(выделяет страницы памяти напрямую).
Отмечаем блок как занятый (mark_chunk_used())
и возвращаем указатель на данные, но перед ним есть заголовок с метаинформацией.
Как malloc() запрашивает память у ОС?
Когда свободной памяти в хипе нет, malloc()
вызывает один из двух системных вызовов:
sbrk(): двигает границу хипа (маленькие аллокации)
Если запрошенный размер маленький (например, malloc(16))
, malloc()
растягивает границу хипа через sbrk()
:
#include <stdio.h>
#include <stdlib.h>
int main() {
void *ptr = malloc(64); // Выделяем 64 байта
printf("malloc(64) = %p\n", ptr);
free(ptr);
return 0;
}
Посмотрим системные вызовы через strace
:
strace ./a.out
Вывод:
brk(0x561a4b5e2000) = 0x561a4b5e2000
Здесь brk() сдвинул конец хипа, добавив новую память.
mmap(): выделяет страницы памяти (большие аллокации)
Если размер большой (>128KB), malloc()
использует mmap()
, чтобы выделить страницу памяти напрямую.
Проверим это на коде:
#include <stdio.h>
#include <stdlib.h>
int main() {
void *ptr = malloc(2 * 1024 * 1024); // Выделяем 2MB
printf("malloc(2MB) = %p\n", ptr);
free(ptr);
return 0;
}
Запускаем strace
:
strace ./a.out
Вывод:
mmap(NULL, 2097152, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4e2c000000
mmap() выделяет большие блоки памяти напрямую из адресного пространства процесса, минуя хип.
Почему так?
Потому что mmap() выделяет страницы памяти (обычно 4KB+). Для больших объектов так проще избежать фрагментации хипа. ОС легче освободить такие блоки, чем sbrk()‑память.
Где хранится информация о выделенной памяти?
Когда вы вызываете malloc(), в памяти перед вашим указателем хранится метаинформация:
[ Метаинформация | Ваша память ]
Структура заголовка блока может выглядеть так:
typedef struct chunk {
size_t size; // Размер блока
struct chunk *next; // Следующий свободный блок
int free; // Флаг занятости
} chunk_t;
Когда вы вызываете malloc(64)
, реально выделяется больше памяти:
[ Заголовок (16 байт) | Ваши 64 байта ]
Когда вы вызываете free(), malloc() ищет заголовок перед указателем, чтобы понять, сколько памяти освобождать. Именно поэтому нельзя free()‑ить указатели, которые не были получены через malloc().
free(): почему память не всегда освобождается?
free(void *ptr)
— стандартная функция из stdlib.h, предназначенная для освобождения памяти, выделенной malloc()
, calloc()
или realloc()
.
#include <stdlib.h>
int *ptr = malloc(128);
free(ptr); // Освобождаем память
Что здесь будет происходить?
free будет искать заголовок блока перед ptr, чтобы узнать размер выделенной памяти. Помечает блок как свободный. Объединяет его с соседними свободными блоками, чтобы уменьшить фрагментацию. Если освобождённый блок находится в конце хипа, то malloc()
может сдвинуть границу (brk())
и вернуть память ОС.
Почему free() НЕ возвращает память ОС?
Когда вы вызываете free(), память не сразу возвращается в операционную систему. Она остаётся внутри кучи процесса, в пуле malloc()
, чтобы ускорить последующие аллокации.
Создадим два блока памяти, освободим их и посмотрим, как ведёт себя процесс:
#include <stdio.h>
#include <stdlib.h>
int main() {
void *p1 = malloc(1024);
void *p2 = malloc(1024);
free(p1);
free(p2);
getchar(); // Ждём, чтобы посмотреть в /proc
return 0;
}
Теперь откроем в другом терминале:
cat /proc/$(pgrep a.out)/maps | grep heap
Вывод будет примерно таким:
55a4c2d4e000-55a4c2d6f000 rw-p 00000000 00:00 0 [heap]
Хотя мы освободили p1 и p2, размер хипа не изменился.
Почему?
free()
не вызываетbrk()
сразу.Память остаётся внутри
glibc malloc()
, чтобы ускорить последующие аллокации.ОС не видит, что память «свободна», пока
malloc()
явно не решит вернуть её черезsbrk(-size)
илиmunmap()
.
Если блок маленький (<128KB), он не возвращается в ОС, а попадает в fastbins (специальный список для быстрого переиспользования).
Когда free() освобождает память?
Если освобождаемый блок находится в конце хипа и
malloc()
решает сдвинутьbrk()
.Если это большая аллокация (>128KB), тогда glibc использует
mmap()
и освобождает её черезmunmap()
.Если вызвать
malloc_trim(0)
— он заставит glibc попытаться вернуть неиспользуемую память.
Пример того, как free()
не освобождает память:
#include <stdio.h>
#include <stdlib.h>
int main() {
void *p1 = malloc(100000);
void *p2 = malloc(100000);
free(p1);
free(p2);
getchar();
return 0;
}
Запустим htop и увидим, что размер процесса не изменился.
Пример того, как malloc_trim()
помогает:
#include <stdio.h>
#include <stdlib.h>
#include <malloc.h>
int main() {
void *p1 = malloc(100000);
void *p2 = malloc(100000);
free(p1);
free(p2);
malloc_trim(0); // Принудительно возвращаем память ОС
getchar();
return 0;
}
Теперь после вызова malloc_trim(0)
процесс реально освободит память.
Как free() управляет фрагментацией?
Если вы вызываете free()
хаотично, память фрагментируется, и куча превращается в мусорку:
[ 128B used ] [ 256B free ] [ 512B used ] [ 128B free ]
Такой хип плохо переиспользуется, поэтому glibc malloc() использует алгоритм слияния блоков.
Слияние блоков
Если два соседних блока свободны, free()
их объединяет:
До:
[ 128B used ] [ 256B free ] [ 512B free ]
После:
[ 128B used ] [ 768B free ]
Освобождаются ли mmap()-аллокации?
Если malloc()
использует mmap()
, то при вызове free()
оно реально освобождает память через munmap()
.
Пример того, как mmap()
реально освобождает память:
#include <stdio.h>
#include <stdlib.h>
int main() {
void *ptr = malloc(2 * 1024 * 1024); // 2MB (больше 128KB)
free(ptr);
getchar();
return 0;
}
Запустим:
cat /proc/$(pgrep a.out)/maps | grep heap
После free(ptr) блок исчезнет. Большие блоки памяти (>128KB) действительно освобождаются в ОС.
Пишем кастомный аллокатор
Стандартные аллокаторы (например, glibc malloc) работают с фри‑листами — специальной структурой, которая хранит освобождённые блоки памяти. Cделаем упрощённую версию, где каждый блок будет содержать небольшой заголовок с метаинформацией (размер и флаг занятости).
Код:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <stdint.h>
#define POOL_SIZE 1024 * 1024 // 1MB
typedef struct Block {
size_t size;
int free;
struct Block *next;
} Block;
static char memory_pool[POOL_SIZE]; // Наша память
static Block *free_list = (Block *)memory_pool; // Указатель на первый свободный блок
// Инициализация памяти
void my_init() {
free_list->size = POOL_SIZE - sizeof(Block);
free_list->free = 1;
free_list->next = NULL;
}
// Функция поиска подходящего блока
Block *find_free_block(size_t size) {
Block *current = free_list;
while (current) {
if (current->free && current->size >= size) {
return current;
}
current = current->next;
}
return NULL;
}
// Разделение блока, если запрашиваемый размер меньше доступного
void split_block(Block *block, size_t size) {
if (block->size >= size + sizeof(Block) + 8) { // Минимальный размер для нового блока
Block *new_block = (Block *)((char *)block + sizeof(Block) + size);
new_block->size = block->size - size - sizeof(Block);
new_block->free = 1;
new_block->next = block->next;
block->size = size;
block->next = new_block;
}
}
// Аллоцируем память
void *my_malloc(size_t size) {
if (size <= 0) return NULL;
Block *block = find_free_block(size);
if (!block) return NULL; // Нет памяти
block->free = 0;
split_block(block, size);
return (void *)(block + 1); // Пропускаем заголовок
}
// Освобождаем память
void my_free(void *ptr) {
if (!ptr) return;
Block *block = (Block *)ptr - 1;
block->free = 1;
// Попытка слить с последующим блоком
if (block->next && block->next->free) {
block->size += block->next->size + sizeof(Block);
block->next = block->next->next;
}
}
// Отладочный вывод состояния памяти
void my_dump() {
Block *current = free_list;
printf("Состояние памяти:\n");
while (current) {
printf("[Адрес: %p | Размер: %zu | %s]\n", (void *)current, current->size, current->free ? "Свободно" : "Занято");
current = current->next;
}
}
int main() {
my_init();
void *p1 = my_malloc(128);
void *p2 = my_malloc(256);
my_dump();
my_free(p1);
my_free(p2);
my_dump();
return 0;
}
Создаём структуру Block, которая содержит:
size
— размер блокаfree
— флаг занятостиnext
— указатель на следующий блок
Все выделенные участки памяти будут содержать заголовок с этими данными перед реальным адресом, который отдаётся пользователю.
Метод find_free_block(size_t size)
просто пробегает список и ищет первый свободный блок подходящего размера.
Но что делать, если блок слишком большой?
Допустим, есть 512B свободной памяти, а пользователь запросил 128B. Чтобы не тратить впустую 512B, разделяем блок:
Оставляем 128B занятыми.
Остаток (512B — 128B — sizeof(Block)) делаем новым свободным блоком.
Этим занимается функция split_block()
.
Как освобождаем память?
Когда пользователь вызывает my_free()
, просто помечаем блок как свободный. Но если следующий блок тоже свободен — сливаем их, чтобы уменьшить фрагментацию.
Как видим состояние памяти?
Функция my_dump()
проходит по всей памяти и выводит блоки:
Состояние памяти:
[Адрес: 0x55d8af345000 | Размер: 128 | Занято]
[Адрес: 0x55d8af3450a0 | Размер: 256 | Занято]
После освобождения:
[Адрес: 0x55d8af345000 | Размер: 384 | Свободно]
Фрагментация минимальна, а свободные блоки сразу сливаются.
Итоги
malloc
не просто «выделяет память», а управляет целым пулом страниц. free
не всегда освобождает память сразу, а оставляет её для будущих вызовов. mmap()
используется для больших блоков, sbrk()
— для маленьких.
Можно писать свои аллокаторы, но делать это нужно правильно.
Научиться применять шаблоны проектирования и SOLID в разработке можно под руководством экспертов на онлайн-курсе в Otus. Переходите на страницу курса, чтобы записаться на открытые уроки.