22. 内存池设计:固定大小分配器、自由链表、与 new/delete 的性能对比
说到内存管理,很多C++开发者第一反应就是new和delete。说实话,我早年做后端服务时也是这么干的。直到有一次线上服务频繁卡顿,一查性能热点——好家伙,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;
};
注意看allocate和deallocate——只有两行代码。这就是固定大小分配器的魅力:O(1)的分配和释放。
自由链表的工作原理
自由链表(Free List)是内存池的核心数据结构。它本质上是一个单链表,但节点就是空闲内存块本身。
我画了一张图,帮你理解它的工作流程:
从图中可以看到,freeList_始终指向第一个空闲块。分配时拿走这个块,freeList_指向下一个;释放时把块插回链表头部。整个过程就是指针的读写,没有遍历,没有排序。
与 new/delete 的性能对比
光说不练假把式。我们来做个基准测试,看看差距到底有多大。
测试条件:分配和释放100万个8字节对象,单线程,重复10次取平均值。
| 分配方式 | 总耗时(毫秒) | 单次分配(纳秒) | 内存碎片 |
|---|---|---|---|
| new/delete | 782 | 782 | 高 |
| 固定大小内存池 | 48 | 48 | 无 |
| 性能提升 | 16.3倍 | 16.3倍 | — |
看到这个数据,你可能会问:为什么差距这么大?
原因有三:
- 系统调用开销:
new底层走malloc,可能触发brk或mmap系统调用。而内存池只是用户态指针操作。 - 内存碎片:
malloc需要管理不同大小的块,频繁分配释放会产生碎片。内存池所有块大小一致,不存在碎片问题。 - 缓存友好性:内存池的块是连续分配的,访问时CPU缓存命中率高。而
malloc分配的内存可能分散在各处。
malloc吧。
避坑指南
内存池虽好,但用不好也会翻车。我总结几个常见问题:
- 内存泄漏:如果忘记把块归还给内存池,那块内存就永远丢失了。我曾经在某个模块中漏掉了一个
deallocate调用,导致服务运行两天后内存耗尽。排查起来非常痛苦。 - 线程安全:上面的实现是单线程的。多线程环境下需要加锁,或者使用线程局部存储(TLS)。我建议每个线程维护自己的内存池,避免锁竞争。
- 内存耗尽:当所有块都被分配出去时,
allocate返回nullptr。你的代码必须处理这种情况。我习惯的做法是:如果当前池满了,就创建一个新的池(称为“分页”策略)。 - 对象构造/析构:内存池只管理内存,不负责构造和析构。你需要手动调用
placement new和析构函数。忘了这一步,你的对象可能处于未初始化状态。
std::allocator接口,这样就能无缝适配STL容器。我在项目中就是这么做的,既保留了内存池的性能优势,又享受了STL的便利性。
总结
固定大小分配器+自由链表,是C++内存池设计中最经典、最实用的组合。它用极简的代码实现了极高的性能,特别适合高频小对象场景。
但记住,没有银弹。内存池不是万能的,它只解决特定问题。如果你的对象大小不固定,或者分配频率很低,用new/delete反而更省事。关键是要理解每种方案的适用场景,然后做出合理的选择。
嗯,关于内存池,今天就聊到这里。希望这些内容对你有帮助。