28、自定义分配器:实现一个简单的基于mmap的分配器

内存分配这事儿,平时我们用 mallocfree 用得挺顺手。但你想过没有——malloc 底层到底怎么跟操作系统要内存的?

我个人习惯是,遇到这种「黑盒」问题,就自己动手写一个。今天我们就来实现一个基于 mmap 的简单分配器。别怕,代码量不大,但背后的思路很重要。

为什么用 mmap?

传统 mallocbrk / 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

分配逻辑其实不复杂:

  1. 遍历当前 pool 的空闲块,找到第一个大小足够的
  2. 如果块太大,可以分裂成两个块
  3. 如果没找到,就 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 的分配器工作流程 用户请求分配 size 遍历所有 mmap_pool 的空闲块 找到合适空闲块 分裂(如果剩余够大)→ 标记已分配 没有合适块 mmap 新 pool → 插入链表 返回用户指针 释放时:标记空闲 → 合并相邻 → 全空则 munmap

性能对比:mmap vs brk

特性 mmap 分配器 传统 brk 分配器
系统调用开销 每次分配/释放都可能触发 只在扩展堆时触发
碎片问题 大块分配时碎片少 小对象频繁分配容易碎片
内存归还 完全归还给操作系统 堆空间只增不减
适用场景 大对象、长期运行服务 小对象、高频分配
线程安全 需要自己加锁 需要自己加锁
注意: mmap 每次分配至少映射一个页面(通常 4KB)。如果你频繁分配几十字节的小对象,用 mmap 会非常浪费。我一般建议:大于 128KB 的分配用 mmap,小于这个值用传统堆分配。

实际项目中的取舍

说实话,上面这个分配器只是个教学示例。真正生产环境用的分配器(比如 jemalloc、tcmalloc)要复杂得多。但核心思想是一样的:

  • 内存池化:一次向系统要一大块,自己慢慢分
  • 空闲链表管理:用链表串联空闲块,分配时遍历
  • 合并与分裂:减少碎片,提高利用率

我记得有一次给嵌入式设备写内存管理模块,硬件只有 64KB RAM。用 mmap 肯定不行(没有 MMU),但思路是一样的——预先划一块静态数组,然后自己管理空闲链表。说白了,分配器的本质就是「把大块切成小块,再把小块拼回去」。

给你的建议: 如果你只是想理解原理,写一个简单的分配器就够了。但如果你要在项目里用,建议直接用 jemalloc 或者 tcmalloc——它们经过了数亿次分配的考验,比你手写的要稳定得多。

嗯,关于 mmap 分配器就聊这么多。代码虽然简单,但背后的内存管理思想是通用的。下次你遇到内存问题,不妨想想:如果是这个分配器,它会怎么处理?


公众号:蓝海资料掘金营,微信 deep3321