
前阵子帮朋友看一个网游网关的压测问题发现一个特别有意思的现象单条消息处理逻辑明明很轻但整个进程的 CPU 已经快跑满了。perf 一抓malloc、free 占了将近三成。三成 CPU 花在内存申请和释放上这合理吗当然不合理。当时我给的建议就是其中一类频繁创建和销毁的对象改用定长内存池。这也是我一直认为最值得动手实现一遍的技术。这篇文会把定长内存池原理和实现从头到尾讲清楚它解决什么问题、核心数据结构长什么样、代码怎么写、实测能快多少、生产环境有哪些坑。适合刚开始接触内存池的朋友也适合那种看了好多零散资料、脑子里还是缺一条完整脉络的人。定长内存池是内存池家族里最轻量、最好理解的一个变体把它啃下来后面再看变长池、slab、tcmalloc都会顺很多所以我愿意叫它开胃小菜。1. 一次性能排查让我重新审视 malloc1.1 压测里的反常曲线那个网关服务本身逻辑不重收到一条消息创建一个 Task 对象塞进队列线程池取出来处理处理完销毁对象。每条消息对应的对象大小固定生命周期非常短典型的朝生暮死型小对象。单看逻辑最耗 CPU 的应该是业务处理和网络收发。可是压测数据摆出来QPS 到一定阈值就上不去了CPU 是一个核进 100%perf top 里前几名赫然是 malloc 和 free。我当时第一反应是不信因为对象才 64 字节new/delete 怎么会这么贵后来把调用栈展开真相就很直白了每次 new 一个 Task都要走一遍 malloc 的堆管理逻辑释放的时候又要走 free 的归并逻辑同时还要加锁保护堆的并发访问。这一来一回消耗的指令数比 Task 本身业务逻辑还多。这就引出一个核心认知内存分配的真正成本往往不在“从操作系统拿内存”本身而在通用分配器为应对各种复杂情况做的额外工作。1.2 malloc 到底为我们做了什么很多人以为 new/delete 就是一次系统调用拿着开价就是页表、缺页中断其实完全不是。现代 mallocglibc 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 或者 unionunion 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 数据结构与内存对齐理论说完直接看代码。我用 C11 写一个最小可用的定长内存池支持自定义块大小、块数量。关键点在两个内存对齐以及空闲链表指针的内存复用。#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_castchar*(::operator new(total)); _begin _chunk; _end _chunk total; // 把整块内存切成 blockCount 个节点串成空闲链表 char* cursor _chunk; for (size_t i 0; i blockCount; i) { FreeNode* node reinterpret_castFreeNode*(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_castvoid*(node); } void deallocate(void* ptr) { assert(Contains(ptr)); FreeNode* node static_castFreeNode*(ptr); node-next _freeList; _freeList node; } bool Contains(void* ptr) const { uintptr_t p reinterpret_castuintptr_t(ptr); uintptr_t b reinterpret_castuintptr_t(_begin); uintptr_t e reinterpret_castuintptr_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 内部持有堆资源这就会造成资源泄漏。定长池很纯粹它不知道也不关心你的对象怎么构造和析构责任完全在调用方。为了让这个封装更安全通常会在池子外层套一个模板类比如ObjectPoolT内部自动完成 placement new 和析构调用。这也是一种扩展方向但作为理解原理用原始指针版本更容易看透本质。我在调试内存池相关 bug 时有个习惯在释放后往节点内存里填充一个特殊字节比如0xDD这样如果程序之后再次访问这块内存反汇编或者看内存 dump 时会非常显眼。如果你也想这么做可以在deallocate里加一句memset(ptr, 0xDD, _blockSize);注意这只是 Debug 辅助手段Release 版本要删掉否则既是额外的性能开销又会破坏对象残留数据的可观测性。4. 实测数字对比malloc vs 定长内存池4.1 测试设计模仿真实压力理论说得再好不如跑个 benchmark 让人信服。我按新手的标准做法设计了一个测试构造一个 64 字节大小的小对象循环一百万次“创建-销毁”对比 malloc/free 和定长池的耗时。测试环境是 Linux x86_64GCC 11C17。计时用std::chrono::steady_clock对照组就是标准的new和delete实验组用上面实现的 FixedMemoryPool 配合 placement new 和显式析构。为了公平两个方案都只测循环内的分配释放操作不做额外业务逻辑。测试循环长这样for (int i 0; i 1000000; i) { Task* t static_castTask*(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 峰值立刻降下来十几个百分点。后来我把线程局部池再叠上去性能又提升了一截。但我也得泼一盆冷水如果分配对象的大小跨度很大、或者分配模式很不规律定长池不但帮不上忙反而会因为固定容量和内部碎片制造新的麻烦。定长池是一座很好的桥但它只适合通向它该去的地方。