Как malloc() и free() управляют памятью в C
- вторник, 11 марта 2025 г. в 00:00:10
Привет, Хабр!
Сегодня рассмотрим, почему free() не всегда освобождает память, как работает malloc(), когда glibc действительно возвращает память в ОС, и как избежать фрагментации хипа. А так же напишем кастомный аллокатор.
Вызываете 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() вызывает один из двух системных вызовов:
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) = 0x7f4e2c000000mmap() выделяет большие блоки памяти напрямую из адресного пространства процесса, минуя хип.
Почему так?
Потому что 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(void *ptr) — стандартная функция из stdlib.h, предназначенная для освобождения памяти, выделенной malloc(), calloc() или realloc().
#include <stdlib.h>
int *ptr = malloc(128);
free(ptr); // Освобождаем памятьЧто здесь будет происходить?
free будет искать заголовок блока перед ptr, чтобы узнать размер выделенной памяти. Помечает блок как свободный. Объединяет его с соседними свободными блоками, чтобы уменьшить фрагментацию. Если освобождённый блок находится в конце хипа, то malloc() может сдвинуть границу (brk()) и вернуть память ОС.
Когда вы вызываете 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 (специальный список для быстрого переиспользования).
Если освобождаемый блок находится в конце хипа и 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() хаотично, память фрагментируется, и куча превращается в мусорку:
[ 128B used ] [ 256B free ] [ 512B used ] [ 128B free ]Такой хип плохо переиспользуется, поэтому glibc malloc() использует алгоритм слияния блоков.
Если два соседних блока свободны, free() их объединяет:
До:
[ 128B used ] [ 256B free ] [ 512B free ]
После:
[ 128B used ] [ 768B free ]Если 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. Переходите на страницу курса, чтобы записаться на открытые уроки.