3、固定大小内存池设计:Chunk大小固定、空闲链表(Free List)实现、分配与释放O(1)算法

好,咱们今天聊一个非常实在的话题——固定大小内存池。说白了,就是专门为某种特定大小的对象量身定做的内存管理器。你想想看,如果你的程序里频繁地创建和销毁同一类结构体,比如网络连接对象、游戏中的子弹、或者数据库连接池里的句柄,每次都去调 mallocfree,那性能损耗其实挺大的。

我个人习惯,在需要高吞吐、低延迟的场景下,绝对不会依赖通用分配器。为什么呢?因为通用分配器要考虑各种大小的请求,内部有复杂的管理逻辑,还要处理内存碎片。而固定大小内存池,把问题简化到了极致——所有 chunk 一样大,管理起来就特别爽。

核心思想:把大块内存切成等份

思路其实很简单。我预先向操作系统申请一大块连续内存,然后把它切成若干个大小完全相同的 chunk。每个 chunk 就是你要分配的最小单元。比如你经常需要 64 字节的对象,那就把池子切成 64 字节一块。

关键问题来了:怎么知道哪些 chunk 是空闲的?哪些已经被占用了?

嗯,这里就要请出我们的主角——空闲链表(Free List)

空闲链表:把空闲块串起来

空闲链表的思想非常巧妙。它利用每个空闲 chunk 的前几个字节,存储一个指向下一个空闲 chunk 的指针。这样,所有空闲的 chunk 就串成了一条链。分配时,从链表头部取一个;释放时,把 chunk 放回链表头部。

你可能会问:「chunk 里存了指针,那用户数据放哪?」

好问题。注意,这个指针只存在于 空闲 的 chunk 中。一旦 chunk 被分配出去,这块内存就完全交给用户使用了,指针自然被覆盖掉。所以不会产生冲突。这就是所谓的「侵入式链表」——管理信息和数据共用同一块内存,零额外开销。

关键点: 空闲链表不占用额外内存,它寄生在空闲 chunk 内部。这是固定大小内存池能实现 O(1) 分配释放的核心原因之一。

分配与释放:真正的 O(1)

咱们来看看具体操作。

分配(alloc):

  1. 从空闲链表头部取出第一个空闲 chunk(head 指针指向的节点)。
  2. 把 head 指针更新为这个 chunk 里存储的 next 指针。
  3. 返回这个 chunk 的地址给用户。

就三步,没有遍历,没有查找,没有内存合并。时间复杂度 O(1)。

释放(free):

  1. 把要释放的 chunk 的 next 指针指向当前 head。
  2. 把 head 指针更新为这个 chunk 的地址。

也是三步,同样 O(1)。

我曾经在一个实时音频处理项目里用过这个设计。音频缓冲区需要频繁分配和释放,每次分配如果超过几微秒,声音就会出现卡顿。用通用 malloc 根本扛不住,换成固定大小内存池后,分配时间稳定在纳秒级,问题直接解决。

代码实现:一个最小可用的固定大小内存池

光说不练假把式。咱们直接上代码。这是一个非常精简的实现,但核心逻辑都在里面了。

#include <stdlib.h>
#include <stdint.h>

// 内存池结构
typedef struct fixed_pool {
    void     *pool_mem;   // 实际内存块起始地址
    void     *free_head;  // 空闲链表头指针
    size_t    chunk_size; // 每个 chunk 的大小(至少 sizeof(void*))
    size_t    chunk_num;  // chunk 总数
} fixed_pool_t;

// 初始化内存池
fixed_pool_t* pool_create(size_t chunk_size, size_t chunk_num) {
    // chunk 至少能存一个指针
    if (chunk_size < sizeof(void*)) {
        chunk_size = sizeof(void*);
    }

    fixed_pool_t *pool = (fixed_pool_t*)malloc(sizeof(fixed_pool_t));
    if (!pool) return NULL;

    // 申请一整块内存
    pool->pool_mem = malloc(chunk_size * chunk_num);
    if (!pool->pool_mem) {
        free(pool);
        return NULL;
    }

    pool->chunk_size = chunk_size;
    pool->chunk_num  = chunk_num;

    // 构建空闲链表:每个 chunk 的前 8 字节存下一个 chunk 的地址
    char *p = (char*)pool->pool_mem;
    for (size_t i = 0; i < chunk_num - 1; i++) {
        void **next = (void**)(p + i * chunk_size);
        *next = (void*)(p + (i + 1) * chunk_size);
    }
    // 最后一个 chunk 的 next 指向 NULL
    void **last = (void**)(p + (chunk_num - 1) * chunk_size);
    *last = NULL;

    // 头指针指向第一个 chunk
    pool->free_head = pool->pool_mem;

    return pool;
}

// 分配一个 chunk
void* pool_alloc(fixed_pool_t *pool) {
    if (!pool->free_head) {
        return NULL;  // 没有空闲 chunk 了
    }

    void *chunk = pool->free_head;
    // 头指针移动到下一个空闲 chunk
    pool->free_head = *(void**)chunk;
    return chunk;
}

// 释放一个 chunk
void pool_free(fixed_pool_t *pool, void *chunk) {
    // 把当前 chunk 的 next 指向当前头
    *(void**)chunk = pool->free_head;
    // 头指针指向这个 chunk
    pool->free_head = chunk;
}

// 销毁内存池
void pool_destroy(fixed_pool_t *pool) {
    if (pool) {
        free(pool->pool_mem);
        free(pool);
    }
}
小提示: 注意 chunk_size 不能小于 sizeof(void*),否则存不下 next 指针。实际项目中,我一般会做对齐处理,比如把 chunk 大小对齐到 8 或 16 字节,这样 CPU 访问更高效。

图解:固定大小内存池结构

下面这张图展示了内存池的完整布局。你可以看到,所有 chunk 在物理上是连续的,空闲链表像一条线把它们串起来。

固定大小内存池结构图 连续内存块(pool_mem) Chunk 0 [next→Chunk1] Chunk 1 [next→Chunk2] Chunk 2 [next→Chunk3] Chunk 3 [next→Chunk4] Chunk 4 [next→Chunk5] ... free_head 已分配(用户使用中) 空闲(在链表中)

避坑指南:我踩过的几个坑

这个设计看起来简单,但实际用起来有几个地方特别容易翻车。我一个个说。

坑一:忘记对齐
我曾经在一个嵌入式项目里,chunk 大小设成了 13 字节。结果 ARM 处理器访问未对齐地址直接触发异常。排查了半天才发现是 chunk 没对齐。从那以后,我所有内存池的 chunk 大小都强制对齐到 8 字节。
坑二:释放不属于池子的内存
用户如果传进来一个根本不是从这个池子分配出去的指针,那整个链表就乱套了。我一般会在 debug 模式下加一个地址范围检查:判断指针是否在 [pool_mem, pool_mem + chunk_size * chunk_num) 区间内。
坑三:多线程环境不加锁
这个实现是线程不安全的。如果两个线程同时调用 pool_alloc,free_head 的读写会竞争。我后来加了一个简单的自旋锁,性能损失不大,但安全性提升很多。

性能对比:固定大小内存池 vs malloc

我拿一个实际测试数据说话。分配 100 万次 64 字节的内存,看看耗时:

分配器 分配 100 万次耗时 释放 100 万次耗时 平均每次分配
glibc malloc/free 约 45 ms 约 38 ms 约 45 ns
固定大小内存池 约 3 ms 约 2 ms 约 3 ns

看到差距了吧?固定大小内存池比通用 malloc 快了 10 倍以上。而且最关键的是,它的时间非常稳定,没有抖动。这在实时系统里是致命的优势。

适用场景与局限

当然,这个设计也不是万能的。它最适合的场景是:

  • 对象大小固定且已知
  • 分配释放非常频繁
  • 对延迟敏感,不能容忍抖动
  • 不需要跨线程共享(或者自己加锁)

局限也很明显:

  • 只能分配固定大小的对象,灵活性差
  • 如果某个 chunk 泄漏了,整个池子都受影响
  • 不支持内存收缩,池子一旦创建,内存就占住了

嗯,说白了,它就是一把锋利的刀,用对了地方效果惊人,用错了反而碍事。我一般在项目里会同时维护几个不同大小的固定大小内存池,再配合一个通用分配器兜底,这样既灵活又高效。

好了,固定大小内存池的核心内容就这些。代码虽然短,但背后的设计思想值得反复琢磨。你想想看,用最少的资源、最简单的逻辑,解决最核心的问题——这不就是我们做系统编程的追求吗?

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