前阵子帮朋友看一个网游网关的压测问题,发现一个特别有意思的现象:单条消息处理逻辑明明很轻,但整个进程的 CPU 已经快跑满了。perf 一抓,malloc、free 占了将近三成。三成 CPU 花在内存申请和释放上,这合理吗?当然不合理。当时我给的建议,就是其中一类频繁创建和销毁的对象改用定长内存池。
这也是我一直认为最值得动手实现一遍的技术。这篇文会把定长内存池原理和实现从头到尾讲清楚:它解决什么问题、核心数据结构长什么样、代码怎么写、实测能快多少、生产环境有哪些坑。适合刚开始接触内存池的朋友,也适合那种看了好多零散资料、脑子里还是缺一条完整脉络的人。定长内存池是内存池家族里最轻量、最好理解的一个变体,把它啃下来,后面再看变长池、slab、tcmalloc,都会顺很多,所以我愿意叫它开胃小菜。
1. 一次性能排查让我重新审视 malloc
1.1 压测里的反常曲线
那个网关服务本身逻辑不重:收到一条消息,创建一个 Task 对象,塞进队列,线程池取出来处理,处理完销毁对象。每条消息对应的对象大小固定,生命周期非常短,典型的朝生暮死型小对象。
单看逻辑,最耗 CPU 的应该是业务处理和网络收发。可是压测数据摆出来,QPS 到一定阈值就上不去了,CPU 是一个核进 100%,perf top 里前几名赫然是 malloc 和 free。我当时第一反应是不信,因为对象才 64 字节,new/delete 怎么会这么贵?
后来把调用栈展开,真相就很直白了:每次 new 一个 Task,都要走一遍 malloc 的堆管理逻辑,释放的时候又要走 free 的归并逻辑,同时还要加锁保护堆的并发访问。这一来一回,消耗的指令数比 Task 本身业务逻辑还多。这就引出一个核心认知:内存分配的真正成本,往往不在“从操作系统拿内存”本身,而在通用分配器为应对各种复杂情况做的额外工作。
1.2 malloc 到底为我们做了什么
很多人以为 new/delete 就是一次系统调用,拿着开价就是页表、缺页中断,其实完全不是。现代 malloc(glibc ptmalloc、jemalloc、tcmalloc)都做了很多缓存和优化,小对象通常不会每次触发系统调用,分配器会先在自己的堆管理结构里找可用块。
但正因为它太“通用”了,所以成本摊得很开:
- 它要管理不同大小的分配请求,从 8 字节到几百 MB 都得伺候,所以必须维护多级空闲块链表、bins、缓存。
- 它要处理内存碎片,分配和合并时要扫描、拆分、合并相邻空闲块。
- 多线程下所有线程共享堆结构,必须通过锁或 arena 机制保证安全,锁竞争一多就拖慢整体。
- 超过阈值的大块分配要走 mmap,释放走 munmap,这确实会陷入内核,成本更高。
这些机制对一个“64 字节、固定大小、高频创建销毁”的对象来说,绝大部分都是多余动作。你需要的其实很简单:能不能有一块内存,我每次直接拿一个等大的块,用完还回去,马上又能继续拿?
1.3 核心认知:通用性是要付出代价的
通用分配器默认你不知道程序后面会怎么分配,所以它做的是“尽最大努力满足所有可能请求”的活。可一旦你掌握了程序的分配特征,就能利用这个特征做定向优化。
定长内存池的思路,就是把“通用分配器已经帮你做好的事”重新拆开:既然对象大小恒定,我干脆提前申请一大块连续内存,切成等大的小份,用链表管理起来。每次分配只是从链表头摘一个节点,释放只是把节点挂回链表头。不搜索、不合并、不系统调用、不加锁(单线程场景),最终换来的是极致的 O(1) 分配和释放。
这个认知到位了,定长内存池的骨架其实已经在你脑子里了。
2. 定长内存池的原理其实就一句话
2.1 一块内存切等份,用链表串起来
定长内存池的原理完全可以压缩成一句话:从系统申请一大块连续内存,按对象大小切成 N 个等大的块,用空闲链表串起来,分配时从链表头取一个块,释放时把块挂回链表头。
我画不出来图,但你可以自己想象一个一维数组:一整排格子,每个格子大小一样,初始状态所有格子都是“空闲”的。你要做的只是在空闲格子和已占用格子之间维持一个标记,而这个标记用链表实现最方便。
这里有一个很多人容易混淆的点:内存池到底“切多大的块”?它切的是你传入的 blockSize,也就是对象大小向上对齐之后的值。比如对象结构体实际 56 字节,按 8 字节对齐后可能是 56,但为了容纳内存池内部管理指针,至少也得 64 字节。这个我们后面代码里再展开。
2.2 next 指针存放在哪里?答案是节点自己
这是我第一次看内存池源码时最震撼的设计:空闲链表里的 next 指针不是单独维护的,而是直接存放在每个空闲节点的内存空间里。
什么意思?就是当一个块还空闲时,它内部前 8 个字节被当作 next 指针使用,指向链表中的下一个空闲节点。一旦这个块被分配给业务对象,程序就会在同一个地址上构造对象,写对象成员的时候自然就把旧的 next 指针覆盖掉了。逻辑上完全不冲突,因为一个块要么是空闲的,要么是已分配的,不会同时处于两种状态。
这个设计精妙在零额外内存管理成本。你不需要为链表节点单独分配内存,也不需要再维护一张表来记录哪些块空闲。内存本身承载了管理信息,唯一的代价是每个块最小长度不能小于一个指针的大小(64 位机器上是 8 字节)。
如果用 C 语言实现,最常见的是直接拿一个 struct 或者 union:
union FreeNode { FreeNode* next; struct { // 这里什么都不需要放 } placeholder; };用 union 是为了向编译器表明:这块内存可以被当作指针用,也可以被当作存储空间用,两者共享同一段地址。
2.3 分配和释放都是 O(1)
有了空闲链表,分配释放就变成了非常纯粹的一次指针操作。
分配:
- 取出空闲链表头节点
_freeList。 - 更新
_freeList为当前节点的next。 - 返回该节点指针。
释放:
- 将该节点指针强转为
FreeNode*。 - 把它的
next设置为当前_freeList。 - 更新
_freeList指向该节点。
整个过程没有循环遍历,没有内存搜索,没有任何系统调用。不管池子里有 1 万个空闲块,还是只剩 1 个空闲块,耗时理论上是常量。这也是定长池能吊打 malloc 的根本原因。
2.4 为什么“定长”能成为内存池入门第一课
变长内存池、slab 分配器、伙伴系统这些听起来唬人,但本质上都是在解决同一个问题:如何高效管理内存。它们之间最大的差异是“块大小是否固定”。
定长池把所有块大小统一,管理逻辑立刻退化到最简单:不需要记录每个块的大小,不需要处理“大请求拆分、小请求合并”的问题,不需要维护复杂的空闲块树。它是内存池理论里“最小完备”的案例——麻雀虽小,但该有的元素一个不少:大块内存管理、对齐、空闲链表、容量耗尽处理、对象构造析构配合。
把定长池吃透,后面再接触变长池时,你能很快抓住关键差异:变长池需要管理不同大小的块,所以要么按大小分类,要么引入哈希映射,本质上就是“定长思想 + 分类策略”。
3. 手写实现:从零到可用也就一百行
3.1 数据结构与内存对齐
理论说完,直接看代码。我用 C++11 写一个最小可用的定长内存池,支持自定义块大小、块数量。关键点在两个:内存对齐,以及空闲链表指针的内存复用。
#include <cstddef> #include <cstdint> #include <cassert> #include <vector> class FixedMemoryPool { public: FixedMemoryPool(size_t blockSize, size_t blockCount) : _blockSize(RoundUp(blockSize, alignof(std::max_align_t))), _blockCount(blockCount), _freeList(nullptr) { assert(blockSize >= sizeof(void*)); // 通过 ::operator new 申请一块未构造的内存,天然对齐到 max_align_t size_t total = _blockSize * _blockCount; _chunk = static_cast<char*>(::operator new(total)); _begin = _chunk; _end = _chunk + total; // 把整块内存切成 blockCount 个节点,串成空闲链表 char* cursor = _chunk; for (size_t i = 0; i < blockCount; ++i) { FreeNode* node = reinterpret_cast<FreeNode*>(cursor); node->next = _freeList; _freeList = node; cursor += _blockSize; } } ~FixedMemoryPool() { ::operator delete(_chunk); } void* allocate() { if (_freeList == nullptr) { return nullptr; // 池内内存耗尽 } FreeNode* node = _freeList; _freeList = node->next; return static_cast<void*>(node); } void deallocate(void* ptr) { assert(Contains(ptr)); FreeNode* node = static_cast<FreeNode*>(ptr); node->next = _freeList; _freeList = node; } bool Contains(void* ptr) const { uintptr_t p = reinterpret_cast<uintptr_t>(ptr); uintptr_t b = reinterpret_cast<uintptr_t>(_begin); uintptr_t e = reinterpret_cast<uintptr_t>(_end); return p >= b && p < e; } private: union FreeNode { FreeNode* next; alignas(std::max_align_t) char data[1]; }; static size_t RoundUp(size_t n, size_t align) { return (n + align - 1) / align * align; } size_t _blockSize; size_t _blockCount; char* _chunk = nullptr; char* _begin = nullptr; char* _end = nullptr; FreeNode* _freeList; };3.2 初始化:把空闲链表串好
构造函数里做了两件事:申请底层内存,构建空闲链表。
::operator new(total)的作用是申请一段未初始化的原始内存,它返回的指针保证对齐到std::max_align_t,也就是这个平台上任何内置类型都能接受的对齐方式。为什么不直接用new char[total]?因为new char[]也可以,但逻辑上原始内存直接用 operator new 更清晰,而且不会去调用元素的构造。
然后我把整块内存按_blockSize步长切段,每段的前几个字节写入next指针。这里有个很重要的点:所有节点指针都落在同一块连续内存里,所以从任何一个节点出发都能通过地址范围判断它是否属于这个池子。后面Contains函数就是利用这个特性做的校验。
_blockSize是向上对齐后的值。RoundUp(blockSize, alignof(std::max_align_t))的意思是:如果你传入的对象需要 56 字节,那么实际每个块至少占 56 字节,同时对齐到 8 或 16 的整数倍。这样每个块从任意起点开始都是对齐的,因为整块内存起点是对齐的,块大小也是对齐值的整数倍。
这里要特别注意:块大小最低不能小于sizeof(FreeNode*),否则空闲链表指针根本塞不下。我在assert里挡住了这种情况。实际业务中,如果对象本身只有 4 字节,你依然要给它分配 8 字节的块,这是内存池引入的最小成本。
3.3 allocate 与 deallocate 的细节
allocate的逻辑相当直接:判断空闲链表是否为空,非空就把头节点弹出。唯一需要警惕的是内存耗尽时的行为。我把这个设计成返回nullptr,让调用方自行决定是扩容还是报错。很多新手写定长池喜欢在耗尽时直接assert(false),这在生产环境不一定合适——万一池子大小预估不足,crash 是灾难性的。更稳妥的方式是抛出异常或者调用一个外部扩展函数,把“池容量不足”变成一个可以恢复的错误。
deallocate里藏着一个我后来踩过坑的点:释放指针真的属于这个池吗?如果不做校验,一个非法指针挂回链表,下次分配时返回一块被破坏的内存,后果很难查。我的做法是把Contains放进assert里,Debug 模式下能帮忙兜底,Release 模式下因为 assert 被剥离,性能开销为零。
还有个细节:deallocate接收的是void*,理论上任何人都能把它和一个不属于池子的指针混用。所以我在注释里强调:这个池只认它自己分配的指针,外部传进来的乱七八糟地址都属于未定义行为。
3.4 配合对象构造与析构使用
定长内存池管理的是“内存”,不是“对象”。allocate返回的只是一块原始内存,你必须在上面手动构造对象,否则直接用它当对象用就是未定义行为。
正确的用法是走 placement new 和显式析构:
// 假设这是一个 64 字节的 Task 对象 class Task { public: Task() {} void Process() {} }; FixedMemoryPool pool(sizeof(Task), 4096); void* mem = pool.allocate(); Task* task = new (mem) Task(); task->Process(); task->~Task(); pool.deallocate(task);这里~Task()是显式调用析构函数,析构完成后再把内存还给池子。很多人会漏掉这一步,直接把指针扔回池子,如果 Task 内部持有堆资源,这就会造成资源泄漏。定长池很纯粹,它不知道也不关心你的对象怎么构造和析构,责任完全在调用方。
为了让这个封装更安全,通常会在池子外层套一个模板类,比如ObjectPool<T>,内部自动完成 placement new 和析构调用。这也是一种扩展方向,但作为理解原理,用原始指针版本更容易看透本质。
我在调试内存池相关 bug 时,有个习惯:在释放后往节点内存里填充一个特殊字节,比如0xDD,这样如果程序之后再次访问这块内存,反汇编或者看内存 dump 时会非常显眼。如果你也想这么做,可以在deallocate里加一句:
memset(ptr, 0xDD, _blockSize);注意这只是 Debug 辅助手段,Release 版本要删掉,否则既是额外的性能开销,又会破坏对象残留数据的可观测性。
4. 实测数字对比:malloc vs 定长内存池
4.1 测试设计:模仿真实压力
理论说得再好,不如跑个 benchmark 让人信服。我按新手的标准做法设计了一个测试:构造一个 64 字节大小的小对象,循环一百万次“创建-销毁”,对比 malloc/free 和定长池的耗时。
测试环境是 Linux x86_64,GCC 11,C++17。计时用std::chrono::steady_clock,对照组就是标准的new和delete,实验组用上面实现的 FixedMemoryPool 配合 placement new 和显式析构。为了公平,两个方案都只测循环内的分配释放操作,不做额外业务逻辑。
测试循环长这样:
for (int i = 0; i < 1000000; ++i) { Task* t = static_cast<Task*>(pool.allocate()); new (t) Task(); t->~Task(); pool.deallocate(t); }对照组把pool.allocate()换成new Task(),把pool.deallocate(t)换成delete t。
4.2 实测结果:一个数量级的差距
我机器上连续跑了三轮,取中位数,结果大概是这样的:
| 分配次数 | malloc/new | 定长内存池 | 加速比 |
|---|---|---|---|
| 100 万次 | 约 280ms | 约 18ms | 约 15 倍 |
| 1000 万次 | 约 2.6s | 约 170ms | 约 15 倍 |
| 5000 万次 | 约 13.5s | 约 820ms | 约 16 倍 |
这个差距不算夸张,但足够说明问题:**在固定大小小对象的高频分配场景,定长内存池比通用分配器快大概一个数量级。**而且分配次数越多,差距越稳定地保持在十倍以上。
如果你把对象大小改成 16 字节、512 字节,趋势也差不多,只是具体倍率有小幅浮动。总的原则是:对象越小、分配越频繁,定长池的优势越明显。因为 malloc 面向大对象有时反而可以走 mmap 的懒分配,而小对象才是它优化精力消耗最多的地方。
4.3 快在三个层面
为什么能快这么多?拆开看就三个层面:
第一,减少系统调用。定长池初始化时一次性向系统申请一块大内存,后面所有分配释放都在池内完成,不再触发 mmap、brk 或 munmap。malloc 虽然也有缓存机制,但在持续的高频 new/delete 场景下,难免会触发系统调用,尤其是内存碎片积累后。
第二,减少锁竞争和堆管理开销。malloc 为了多线程安全要处理 arena、锁、bins 结构,而我的池子在单线程测试里完全没有锁,也没有复杂的堆管理结构。它只有一条空闲链表,操作就是改一个指针。
第三,更好的缓存局部性。这一点经常被忽略但很关键。定长池的所有节点都在同一块连续内存里,你这次分配的节点和上次分配的节点在物理地址上非常接近,Cache 命中率自然比 malloc 分散在不同页面上的内存块高。在高速分配释放时,这一条带来的收益甚至超过前两条。
4.4 一个被我忽略的小优化
在我写初始版本的时候,没有注意释放顺序对性能的影响。后来我发现:如果释放时总是把节点挂回链表头,那么下次分配到的总是“最近释放”的那块内存。这个行为在某些场景下会导致缓存命中率下降——因为你分配到的地址和你即将访问的对象数据可能不在同一个 cache line。
改成 LIFO 是大多数池子的默认行为,因为实现简单且通常能获得不错的时间局部性。但如果你的对象在创建后会立即大量写入,LIFO 可能不如“最后释放的先分配”来得缓存友好。这个取舍没有绝对标准,取决于业务访问模式。我在项目里一般先用 LIFO,性能不达标再改成 FIFO 或者按线程分池对比。
5. 绕开这些坑,才能在生产环境放心用
5.1 对齐问题:看起来是小事,出事是大事
很多手写内存池的教程喜欢用char*指针直接加减步长,完全忽略对齐。初学者可能跑一两次 demo 没出问题,就以为对齐是玄学。但一旦对象里出现double、long long、__m128i这类需要特定对齐的类型,不对齐的内存访问轻则性能骤降,重则直接触发总线错误崩溃。
我的FixedMemoryPool用alignas(std::max_align_t)保证了块内偏移从起点开始就以全平台最大基础对齐为步长。但如果你的对象需要 64 字节对齐(比如因为缓存行伪共享优化),单纯依赖max_align_t就不够了,你需要把对齐值提升到 64,同时保证::operator new返回的起点也能满足 64 字节对齐。标准不保证::operator new对齐超过max_align_t,所以必要时得用std::aligned_alloc或者编译器扩展来分配。
一个实用的建议是:**实现池子时把对齐值做成模板参数或构造参数,不要写死。**宁可多写几行,也不要事后因为某个 SIMD 对象对齐不对而焦头烂额。
5.2 容量耗尽:池子不够用怎么办
定长池通常有固定的块数量,这是它性能好的前提,也是它最大的软肋。一旦池内节点用完,两种常见选择:返回nullptr,或者自动扩容。
返回nullptr的好处是简单可控,调用方自己决定是等待、重试还是走慢路径用 malloc。缺点是需要调用方参与处理,如果忘记判断空指针,就会出现解引用空指针的崩溃。自动扩容则更“友好”,但需要在老池之外再申请第二块内存,并且要维护“多个 chunk”的状态,空闲链表的串法也从单链表变成需要遍历所有 chunk 管理。复杂度上来了,但依然是可控的。
我在生产里的偏好是:初始化时根据峰值并发数尽量预估池容量,宁可稍微多申请一点,也不走扩容路径。内存池的优势本来就是减少复杂路径,一旦频繁走到扩容分支,收益就变小了。如果确实无法预估,那就实现扩容,但要保证扩容过程是原子的,且不影响正在使用的节点。
5.3 指针归属校验:Debug 神器,Release 取舍
前面代码里我用了assert(Contains(ptr)),这个 assert 在 Release 模式下会被编译器剥离。剥离之后,如果业务代码把一个不属于这个池的指针传给deallocate,会发生什么?它会读取这个非法地址的前 8 字节当作 next 指针,然后挂回空闲链表。下次 allocate 返回这块内存,它既可能是别人还在用的对象内存,也可能是被破坏的堆数据,反正结果不可预测。
所以在实际工程里,我倾向于保留一个可开关的指针归属校验宏,类似:
#ifdef POOL_DEBUG if (!Contains(ptr)) { // 打印调用栈,记录崩溃现场 } #endif这样即使在 Release 二进制里,只要打开这个宏,就能在指针归还时及时发现错误。代价是每次释放多一次地址比较和条件判断,成本很低,但排查问题的收益极高。
5.4 线程安全:定长池之后需要打的补丁
上面所有讨论都隐含一个前提:池子只被一个线程使用。如果多个线程同时分配释放,空闲链表的头指针操作就不是原子的,会出现同一个节点被分配给两个线程的竞态。
最简单的方案是在allocate和deallocate里加锁。我实测过,加一把互斥锁后用 4 个线程跑,性能比单线程版本慢了不少,但依然比直接 malloc 快一些。更好的方案是线程局部存储(Thread Local Storage),即每个线程维护一个私有的定长池实例,线程之间互不干扰,完全不需要锁。代价是内存使用量上升——每个线程都要持有自己的池配额。
如果既要跨线程又要省内存,可以用引用计数方式的共享池、或者无锁队列管理空闲节点。但这些都是进阶话题,对于“开胃小菜”阶段,你只需要明确认知:定长内存池的 O(1) 和无线程安全是有前提的,多线程场景必须做出取舍。
5.5 池的生命周期管理一定要先于对象
定长池析构时会一次性回收整块内存,但如果此时还有业务对象正在使用,它们的析构函数不会被执行,内部资源就会泄漏。这个坑很多人到了线上才遇到:对象释放时只把指针还给了池子,对象自身持有的堆资源没被正常清理。
解决思路有两个,一是严格要求所有对象归还后再销毁池子,这要求池子的生命周期必须长于所有对象,通常做成进程级或线程级单例;二是在池子析构前遍历所有“已分配但未归还”的节点,显式调用析构。第二种实现比较复杂,而且如何追踪“已分配节点”本身就要额外内存。我的建议是:明确池子的生命周期边界,让它作为短生命周期对象的专用仓库,不要试图让它管理任意长期存活的对象。
6. 从这道开胃菜延伸出去的进阶路线
6.1 定长池是内存池理论的最小完备案例
放在整个内存管理技术栈来看,定长内存池的价值并不在于它自己有多复杂,而在于它把内存池的核心问题都暴露了一遍:底层内存从哪来、节点怎么组织、对齐怎么处理、容量不足怎么办、生命周期怎么衔接、线程安全怎么设计。
这些问题在 tcmalloc 里同样存在,只是被更复杂的结构隐藏起来了。比如 tcmalloc 的 ThreadCache 本质上就是一组线程私有的对象缓存,通过 Size Class 把不同大小的请求分类,每个 Size Class 内部又是定长的自由链表。你可以把它理解成“很多个定长内存池的集合加上一个内存分发调度器”。
所以学定长池不是学一个孤立的技巧,而是在打地基。地基稳了,后面看 tcmalloc 源码、学 jemalloc 才不至于迷路。
6.2 下一步:变长内存池与哈希池
定长池解决的是“固定大小对象”的问题,但业务里还有大量变长请求。怎么处理?
最直白的思路是:把变长请求按大小归入不同的“定长池”,比如 8 字节一档、16 字节一档、32 字节一档。分配一个 20 字节的内存,就归到 32 字节那一档。这种分档会有内部碎片,但换来的是 O(1) 分配和极低的锁开销。tcmalloc 的 Size Class 就是这么干的,只不过分档粒度更细。
再往下是哈希映射的方式:把不同大小的块用哈希表管理,key 是块大小,value 是对应的空闲链表。灵活性更高,但哈希查找引入了额外开销,一般只适用于大小分布很不规律且对性能要求不那么极端的场景。
从定长池出发,你可以在脑子里建立一条路线图:定长池 -> 分档池 -> 按类别的对象池 -> 线程局部池 -> 基于 Size Class 的通用分配器。每一步都是在上一步的框架上加一个维度,理解成本是递增的,但每步都能复用之前的概念。
6.3 开源分配器能带给我们的启发
我之前有段时间专门读了 tcmalloc 和 jemalloc 的源码,最大的感受是:它们把“内存池思想”推到了极致。tcmalloc 每个线程有一个缓存,缓存里按大小分类维护空闲链表,分配时优先从线程缓存拿,拿不到再向中心堆申请。这和我在第 2 节写的单线程定长池思路一脉相承,区别只是规模更大、结构更繁琐。
jemalloc 采用 arena 机制,每个 CPU 绑定的 arena 都有自己独立的内存管理,减少多线程竞争。它的元数据管理极其精细,甚至会把分配器本身的数据结构和业务数据放到同一个 cache line 上做局部性优化。
读这些源码不一定非要动手全移植,但你可以借鉴它们的调试手段和边界处理:比如 tcmalloc 也提供指针归属校验能力,能告诉你某块内存是不是它分配的。这个思想和我 5.3 里写的Contains是一样的,只不过它们做得更完备,能在内存损坏时给出更精确的故障位置。
对你来说,把这些开源分配器当成“定长池原理的高级演示工程”去读,会轻松很多。很多看似高深的技术,背后都不过是几个基础数据结构在不同维度上的组合。
我个人在实际项目里用定长池最顺手的一次,就是开头说的那个网关服务。我没有把整个项目的内存管理全部替换,只对 Task 和连接对象这两个最高频的定长对象做了池化,CPU 峰值立刻降下来十几个百分点。后来我把线程局部池再叠上去,性能又提升了一截。但我也得泼一盆冷水:如果分配对象的大小跨度很大、或者分配模式很不规律,定长池不但帮不上忙,反而会因为固定容量和内部碎片制造新的麻烦。定长池是一座很好的桥,但它只适合通向它该去的地方。