28、自定义分配器:实现一个简单的基于mmap的分配器
内存分配这事儿,平时我们用 malloc 和 free 用得挺顺手。但你想过没有——malloc 底层到底怎么跟操作系统要内存的?
我个人习惯是,遇到这种「黑盒」问题,就自己动手写一个。今天我们就来实现一个基于 mmap 的简单分配器。别怕,代码量不大,但背后的思路很重要。
为什么用 mmap?
传统 malloc 用 brk / sbrk 来扩展堆空间。但 mmap 有个好处——它直接从操作系统映射一块内存,用完 munmap 就彻底归还了。不会造成堆碎片累积。
我在项目中遇到过一个大坑:一个长期运行的服务,频繁分配释放小对象,堆空间越涨越高,最后 OOM 被 kill 了。后来改成大块内存用 mmap 分配,问题就解决了。
mmap 映射一大块内存(比如 4KB 对齐),然后自己管理这块内存内部的空闲链表。释放时,如果整个块都空闲了,就 munmap 还给系统。
分配器的数据结构
我们先定义两个结构体:
// 每个内存块头部
typedef struct block_header {
size_t size; // 数据区大小
int free; // 1=空闲, 0=已分配
struct block_header *next; // 指向下一个块
} block_header_t;
// 整个 mmap 区域的管理结构
typedef struct mmap_pool {
void *addr; // mmap 返回的地址
size_t total_size; // 总大小
block_header_t *first; // 第一个块
struct mmap_pool *next; // 链接到下一个 pool
} mmap_pool_t;
嗯,这里要注意:block_header 紧挨着数据区存放。也就是说,用户拿到的指针是 header + 1 的位置。
分配核心:mmap_alloc
分配逻辑其实不复杂:
- 遍历当前 pool 的空闲块,找到第一个大小足够的
- 如果块太大,可以分裂成两个块
- 如果没找到,就
mmap一个新的 pool
void *mmap_alloc(size_t size) {
// 对齐到 8 字节
size = (size + 7) & ~7;
// 遍历所有 pool
mmap_pool_t *pool = pool_list;
while (pool) {
block_header_t *block = pool->first;
while (block) {
if (block->free && block->size >= size) {
// 分裂:如果剩余空间够放一个 header + 最小块
if (block->size > size + sizeof(block_header_t) + 32) {
block_header_t *new_block = (block_header_t*)((char*)(block+1) + size);
new_block->size = block->size - size - sizeof(block_header_t);
new_block->free = 1;
new_block->next = block->next;
block->size = size;
block->next = new_block;
}
block->free = 0;
return (void*)(block + 1);
}
block = block->next;
}
pool = pool->next;
}
// 没有合适的块,创建新 pool
return create_new_pool(size);
}
这段代码里有个细节:分裂时我留了 32 字节的余量。为什么?因为如果剩余空间太小,分裂反而浪费,不如直接给用户用。
释放核心:mmap_free
释放相对简单:
void mmap_free(void *ptr) {
if (!ptr) return;
block_header_t *block = (block_header_t*)ptr - 1;
block->free = 1;
// 合并相邻空闲块
block_header_t *curr = find_block_start(block);
while (curr && curr->next) {
if (curr->free && curr->next->free) {
curr->size += sizeof(block_header_t) + curr->next->size;
curr->next = curr->next->next;
} else {
curr = curr->next;
}
}
// 检查整个 pool 是否全空闲,是则 munmap
try_release_pool(block);
}
我曾经犯过一个错:释放后没有合并相邻空闲块,结果分配器用着用着就碎成渣了。大块分配请求老是失败,排查了半天才发现是碎片问题。
完整流程示意图
下面这张图展示了分配器的工作流程:
性能对比:mmap vs brk
| 特性 | mmap 分配器 | 传统 brk 分配器 |
|---|---|---|
| 系统调用开销 | 每次分配/释放都可能触发 | 只在扩展堆时触发 |
| 碎片问题 | 大块分配时碎片少 | 小对象频繁分配容易碎片 |
| 内存归还 | 完全归还给操作系统 | 堆空间只增不减 |
| 适用场景 | 大对象、长期运行服务 | 小对象、高频分配 |
| 线程安全 | 需要自己加锁 | 需要自己加锁 |
mmap 每次分配至少映射一个页面(通常 4KB)。如果你频繁分配几十字节的小对象,用 mmap 会非常浪费。我一般建议:大于 128KB 的分配用 mmap,小于这个值用传统堆分配。
实际项目中的取舍
说实话,上面这个分配器只是个教学示例。真正生产环境用的分配器(比如 jemalloc、tcmalloc)要复杂得多。但核心思想是一样的:
- 内存池化:一次向系统要一大块,自己慢慢分
- 空闲链表管理:用链表串联空闲块,分配时遍历
- 合并与分裂:减少碎片,提高利用率
我记得有一次给嵌入式设备写内存管理模块,硬件只有 64KB RAM。用 mmap 肯定不行(没有 MMU),但思路是一样的——预先划一块静态数组,然后自己管理空闲链表。说白了,分配器的本质就是「把大块切成小块,再把小块拼回去」。
嗯,关于 mmap 分配器就聊这么多。代码虽然简单,但背后的内存管理思想是通用的。下次你遇到内存问题,不妨想想:如果是这个分配器,它会怎么处理?