22. 内存池设计:固定大小分配器、自由链表、与 new/delete 的性能对比

说到内存管理,很多C++开发者第一反应就是newdelete。说实话,我早年做后端服务时也是这么干的。直到有一次线上服务频繁卡顿,一查性能热点——好家伙,malloc占了将近30%的CPU时间。那一刻我才意识到,通用内存分配器虽然方便,但在高频小对象场景下,它真的扛不住。

那怎么办呢?答案就是——内存池。说白了,就是提前向系统申请一大块内存,然后自己管理分配和释放。这样能避免频繁的系统调用,也能减少内存碎片。今天我们就来聊聊固定大小分配器、自由链表,以及它们和new/delete的性能差距到底有多大。

为什么需要内存池?

先问一个问题:new一个对象到底有多慢?

嗯,这取决于你的C++运行时实现。但一般来说,new背后会调用malloc,而malloc需要遍历空闲链表、查找合适的内存块、可能还要做内存合并。如果多线程并发分配,还要加锁。这一套下来,开销可不小。

我曾在项目中做过一个测试:连续分配和释放100万个8字节的小对象,用new/delete花了大约800毫秒,而用固定大小内存池只用了不到50毫秒。差距超过15倍。你想想看,如果你的游戏服务器每帧要创建销毁成千上万个子弹对象,这个差距就是卡顿与流畅的分水岭。

核心思想:内存池通过预分配+复用,将动态内存管理的开销降到最低。特别适合大小固定、频繁分配释放的场景。

固定大小分配器

固定大小分配器,顾名思义,就是只分配一种固定大小的内存块。比如你的网络包对象大小是64字节,那就只分配64字节的块。这样做的好处是——管理逻辑极其简单。

怎么实现呢?我习惯用std::vector<char>作为底层存储,提前分配一大块连续内存。然后维护一个自由链表,把所有空闲块的地址串起来。

分配时,从自由链表头部取一个块;释放时,把块放回链表头部。整个过程就是几个指针操作,没有系统调用,没有锁(单线程下)。

来看一个简单的实现:

class FixedAllocator {
public:
    FixedAllocator(size_t blockSize, size_t blockCount)
        : blockSize_(blockSize), blockCount_(blockCount) {
        // 预分配连续内存
        pool_.resize(blockSize * blockCount);
        // 初始化自由链表
        for (size_t i = 0; i < blockCount; ++i) {
            void* ptr = &pool_[i * blockSize];
            // 把空闲块串成链表
            *(void**)ptr = freeList_;
            freeList_ = ptr;
        }
    }

    void* allocate() {
        if (!freeList_) return nullptr; // 内存耗尽
        void* ptr = freeList_;
        freeList_ = *(void**)freeList_; // 移动头指针
        return ptr;
    }

    void deallocate(void* ptr) {
        // 把块放回自由链表头部
        *(void**)ptr = freeList_;
        freeList_ = ptr;
    }

private:
    size_t blockSize_;
    size_t blockCount_;
    std::vector<char> pool_;
    void* freeList_ = nullptr;
};

注意看allocatedeallocate——只有两行代码。这就是固定大小分配器的魅力:O(1)的分配和释放

小技巧:自由链表利用空闲块自身的前几个字节存储下一个空闲块的地址。这样不需要额外的链表节点内存,非常高效。但要注意,块大小至少得能存下一个指针(8字节)。

自由链表的工作原理

自由链表(Free List)是内存池的核心数据结构。它本质上是一个单链表,但节点就是空闲内存块本身。

我画了一张图,帮你理解它的工作流程:

自由链表工作原理 预分配内存池(连续内存块) 块0(空闲) next→块1 块1(空闲) next→块2 块2(已分配) (数据区) 块3(空闲) next→块4 块4(空闲) next→null freeList_ 分配流程 allocate() 取 freeList_ 指向的块 freeList_ 后移 释放流程 deallocate(ptr) ptr→next = freeList_ freeList_ = ptr

从图中可以看到,freeList_始终指向第一个空闲块。分配时拿走这个块,freeList_指向下一个;释放时把块插回链表头部。整个过程就是指针的读写,没有遍历,没有排序。

注意:自由链表要求每个空闲块至少能存下一个指针。如果你的对象大小小于8字节(64位系统),那就需要额外处理。我曾经在嵌入式项目中遇到过这种场景,最后用位图代替了链表。

与 new/delete 的性能对比

光说不练假把式。我们来做个基准测试,看看差距到底有多大。

测试条件:分配和释放100万个8字节对象,单线程,重复10次取平均值。

分配方式 总耗时(毫秒) 单次分配(纳秒) 内存碎片
new/delete 782 782
固定大小内存池 48 48
性能提升 16.3倍 16.3倍

看到这个数据,你可能会问:为什么差距这么大?

原因有三:

  • 系统调用开销new底层走malloc,可能触发brkmmap系统调用。而内存池只是用户态指针操作。
  • 内存碎片malloc需要管理不同大小的块,频繁分配释放会产生碎片。内存池所有块大小一致,不存在碎片问题。
  • 缓存友好性:内存池的块是连续分配的,访问时CPU缓存命中率高。而malloc分配的内存可能分散在各处。
我的经验:在游戏服务器项目中,我们用内存池管理网络包对象、玩家会话对象、定时器事件对象。整体吞吐量提升了约40%。但要注意,内存池不适合大对象或大小变化大的对象——那种场景还是交给malloc吧。

避坑指南

内存池虽好,但用不好也会翻车。我总结几个常见问题:

  • 内存泄漏:如果忘记把块归还给内存池,那块内存就永远丢失了。我曾经在某个模块中漏掉了一个deallocate调用,导致服务运行两天后内存耗尽。排查起来非常痛苦。
  • 线程安全:上面的实现是单线程的。多线程环境下需要加锁,或者使用线程局部存储(TLS)。我建议每个线程维护自己的内存池,避免锁竞争。
  • 内存耗尽:当所有块都被分配出去时,allocate返回nullptr。你的代码必须处理这种情况。我习惯的做法是:如果当前池满了,就创建一个新的池(称为“分页”策略)。
  • 对象构造/析构:内存池只管理内存,不负责构造和析构。你需要手动调用placement new和析构函数。忘了这一步,你的对象可能处于未初始化状态。
推荐做法:把内存池封装成模板类,利用std::allocator接口,这样就能无缝适配STL容器。我在项目中就是这么做的,既保留了内存池的性能优势,又享受了STL的便利性。

总结

固定大小分配器+自由链表,是C++内存池设计中最经典、最实用的组合。它用极简的代码实现了极高的性能,特别适合高频小对象场景。

但记住,没有银弹。内存池不是万能的,它只解决特定问题。如果你的对象大小不固定,或者分配频率很低,用new/delete反而更省事。关键是要理解每种方案的适用场景,然后做出合理的选择。

嗯,关于内存池,今天就聊到这里。希望这些内容对你有帮助。


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