如果最近你在排查一个越跑越慢的服务,或者发现某个高并发模块的内存像漏勺一样只涨不降,那这个话题值得花十分钟认真看完。内存池自定义实现,本质就是提前向操作系统申请一大块连续内存,然后由程序自己管理小块内存的分配与回收,而不是每次需要时都去调 malloc/free 或 new/delete。我最早接触内存池是在帮一个网关服务做性能优化的时候,那服务运行几小时后延迟开始莫名抖动,从日志里看内存占用不算高,但进程的常驻内存一直在涨。后来定位到根因,是连接对象在高峰期被频繁创建销毁,堆上产生大量内存碎片,顺带还拖慢了分配速度。换成自定义内存池之后,这个问题直接消失,而且代码量没增加多少。
内存池这类东西,看起来很高端,实际上核心思想特别朴素:批量进货、零散零售。你需要多少小对象,我一次性向系统拿一大块,然后把这一大块切成一个个大小固定的槽位,谁要用就从槽位列表里拿一个,谁不用了再还回来。整个过程不涉及系统调用,不需要遍历空闲链表查找合适大小,操作的只是几个指针的赋值。这篇文章会从为什么需要内存池讲起,再到如何设计、如何手写一个可用的通用内存池,最后给出实际测试数据和几个容易被坑的细节,适合正在做服务端性能优化、游戏引擎开发、嵌入式开发的朋友参考。
1. 为什么需要内存池:先搞清楚 malloc 在背后做了什么
1.1 系统级内存分配的隐藏成本
很多人觉得 new 一个对象很快,但实际上 new 底层走的是 malloc,而 malloc 为了保证多线程下的安全、控制内存碎片、维护不同大小类别的空闲块,内部逻辑远比想象中复杂。以常见的通用分配器为例,一次 malloc 可能要经历:检查线程本地缓存、在空闲链表里找大小合适的块、如果找不到就向上申请更大的堆块,甚至需要加锁、调用操作系统内核扩展堆内存。这个链路的开销在小对象高频分配场景下会被放大得非常明显。
举个直观的例子。一个只有 32 字节的小对象,分配一次可能只需要拷贝几个字段,但 malloc 为了找到一块合法的 32 字节内存,可能要遍历多个空闲链表桶。如果这些操作还要和其他线程抢同一把锁,那延迟就是毫秒级别的抖动,而不是几个纳秒的事。这个问题在性能敏感的服务里几乎是致命的,因为你无法预测哪一次 new 会突然慢下来。
我见过的一个真实案例是,某个消息推送服务的单机 QPS 很高,每次推送都要创建和销毁一个包含几十个字段的上下文对象。上线初期一切正常,运行一周后 RT 的 P99 从 30 毫秒涨到 300 毫秒。排查到最后,发现就是堆内存碎片化严重,导致分配器经常需要触发内存整理或者向系统申请新内存块。这种问题用 top 看内存占用反而不明显,因为总内存没有超限,但分配路径已经变得极不稳定了。
1.2 内存碎片:最难排查的那类问题
内存碎片分两种,一种是内部碎片,一种是外部碎片。内部碎片好理解,比如你分配 64 字节的内存块,实际只需要 48 字节,那 16 字节就白白浪费了。外部碎片才是真正让人头疼的:系统内存里散落着很多小块空闲内存,它们加起来足够大,但每一块都不够大,导致无法满足一个大对象的分配请求。
外部碎片是怎么产生的?想象一个停车场,每辆车大小不一,你一会儿停一辆小轿车,一会儿开走一辆 SUV,一会儿又停进一辆面包车。时间长了,空位很多,但东一块西一块,来一辆大巴就停不进去。堆内存也是这个道理,对象反复创建销毁之后,空闲的内存块被切割得七零八落。池化分配则不同,它把所有槽位切成一样大,不存在“找一块合适大小的连续区域”这个过程,因此从根源上避免了外部碎片的产生。
2. 内存池的核心设计思路:从需求到方案选型
2.1 定长还是变长:先选对自己的场景
内存池要做的第一个决定就是:池里的对象是定长的还是变长的。定长池最简单,每个槽位大小一样,空闲时通过链表串起来,分配和释放都是 O(1) 操作。变长池则要复杂得多,需要根据请求大小维护不同类别的空闲链表,甚至引入伙伴系统等算法。
我的建议是:90% 的应用场景都用定长池就够了。网络框架里的连接对象、游戏里的实体和组件、日志系统里的日志条目、缓存系统里的热点数据,这些对象的类型是固定的,大小在编译期就能确定,完全不需要变长分配。变长池适合内存池内部还要承载字节缓冲区、字符串等大小动态变化的数据结构,但那是另一套工程,复杂度不是一篇文章能覆盖的。
这篇博文里的实现就是定长池,而且是模板化的定长池。模板参数是对象类型 T,池内部按 sizeof(T) 切割槽位。用到的时候,比如你想池化 SocketConnection 对象,就声明一个MemoryPool<SocketConnection>,然后从池里取对象、归还对象,其他地方不需要改动。
2.2 空闲链表:内存池的自动取号机
定长池最核心的数据结构是空闲链表。你可以把它想象成银行大厅里的自动取号机:取号时从机器里撕一张号,办完业务回来把号重新放回机器。机器里存的不是号码纸,而是空闲槽位的地址。
具体做法是:在空闲槽位的内存里直接存储下一个空闲槽位的地址。注意,这里说的“直接存储”是指这块内存当前没有被使用,所以可以被当作指针字段随便写。当一个对象被分配出去后,这块内存交给对象使用,就不再参与链表。当对象归还后,内存块重新变成链表节点,把 head 指针指向它,就完成了回收。
这种设计的好处是,不需要额外分配任何管理结构,空闲链表本身就在待分配的内存里,一块多余的内存都不用花。这也是“嵌入指针”或“侵入式链表”的思路,很多工业级实现都用这个技巧。
2.3 线程安全:加锁还是私有池
线程安全是绕不开的话题。最简单的做法是在分配和释放函数里加一把互斥锁,但这又回到了原有的竞争问题上。更好的做法是让每个线程持有自己的私有池,或者用线程本地存储保存内存池实例。还有更激进的无锁方案,用原子操作维护空闲链表的头指针。
从工程角度看,我的建议是分两步走:第一,先确认你的对象是否真的会被多线程并发分配和释放。如果是,先用一个带锁的包装类把内存池包起来,保证功能正确,再考虑性能。第二,如果性能测试说明锁竞争确实成了瓶颈,再升级到线程本地池。线程本地池的实现也不复杂,利用 C++11 的 thread_local 关键字就可以做到。
我自己的项目里通常用的是单线程池或者每线程一个池的方式,因为这类池子大多用在某个模块内部的高频小对象分配上,跨线程共享的需求并不多。如果确实需要跨线程共享,我会选择加外层锁,因为无锁栈虽然实现简单,但 ABA 问题、内存序问题处理起来并不是一篇文章能说透的,没必要为了省一把锁引入更大的风险。
3. 完整的自定义内存池实现
3.1 数据结构与关键成员
下面给出一个可直接编译的通用定长内存池实现。它包含两个核心结构:一个是空闲节点 FreeNode,另一个是内存块 Block。FreeNode 用 union 实现,既能在空闲时作为链表指针,又能在分配后作为对象内存;Block 用来记录我们从系统申请的大块内存,便于在池析构时统一释放。
#include <cstddef> #include <new> #include <utility> template <typename T> class MemoryPool { private: union FreeNode { FreeNode* next; alignas(T) char storage[sizeof(T)]; }; struct Block { Block* next; char* data; }; FreeNode* freeList_; Block* blockList_; size_t blockSize_; // 实际每个节点需要的空间 static constexpr size_t NodeSize() { return sizeof(FreeNode) > sizeof(T) ? sizeof(FreeNode) : sizeof(T); } public: explicit MemoryPool(size_t blockSize = 4096) : freeList_(nullptr), blockList_(nullptr), blockSize_(blockSize) {} ~MemoryPool() { Block* cur = blockList_; while (cur != nullptr) { Block* next = cur->next; delete[] cur->data; delete cur; cur = next; } } MemoryPool(const MemoryPool&) = delete; MemoryPool& operator=(const MemoryPool&) = delete; void* allocate() { if (freeList_ == nullptr) { addBlock(); } FreeNode* node = freeList_; freeList_ = freeList_->next; return node; } void deallocate(void* ptr) { if (ptr == nullptr) return; FreeNode* node = static_cast<FreeNode*>(ptr); node->next = freeList_; freeList_ = node; } template <typename... Args> T* construct(Args&&... args) { void* mem = allocate(); return new (mem) T(std::forward<Args>(args)...); } void destroy(T* ptr) { if (ptr == nullptr) return; ptr->~T(); deallocate(static_cast<void*>(ptr)); } private: void addBlock() { Block* block = new Block; block->data = new char[blockSize_]; block->next = blockList_; blockList_ = block; size_t nodeCount = blockSize_ / NodeSize(); char* base = block->data; for (size_t i = 0; i < nodeCount; ++i) { FreeNode* node = reinterpret_cast<FreeNode*>(base + i * NodeSize()); node->next = freeList_; freeList_ = node; } } };这里的 Block 链表负责记录内存块,析构时从链表头依次释放。每个 Block 通过new char[blockSize_]申请一整块连续内存,然后从这块内存的起始地址开始,按固定节点大小切成槽位,依次挂进空闲链表。这样一次系统申请可以服务上百个对象,平均分配成本被摊薄到极低。
3.2 分配与释放的核心逻辑
allocate 的逻辑只有三步:如果空闲链表为空,先补一大块内存款;从链表头取出一个节点;把链表头指向下一个节点。deallocate 更简单,把归还的节点插到链表头部就行。整个路径上没有遍历、没有排序、没有系统调用,只有几次指针读写,所以它才是 O(1) 的。
有一点需要特别说明:一个节点在被分配出去之后,它内存里存的是什么完全由对象决定;在归还之后,它的前几个字节会被当作 next 指针。这就是为什么用 union 而不是普通 struct——它保证这块内存无论被当作指针还是被当作对象存储空间,大小和对齐都是正确的。
addBlock 里有个容易被忽略的重点:新申请来的整块内存要被切分成多个节点,并全部挂进空闲链表。而不是每次 allocate 时临时切一块。这个“批量预切”的思路就是内存池效率高的根源,一次切分,N 次使用。
3.3 与 new/delete 的配合:placement new 和显式析构
池里拿到的只是一块原始内存,要把它变成真正的对象,需要显式调用构造函数,这就是 placement new 的用途。construct 方法里,分配完原始内存后,用new (mem) T(std::forward<Args>(args)...)在指定内存上构造对象。destroy 方法则先调用析构函数,再把内存归还给池。
很多刚开始写池的人容易犯两个毛病:一是不调构造函数直接强转类型使用,导致对象内部字段是随机值;二是不调析构函数直接回收内存,如果对象内部管理着堆资源,就会造成资源泄漏。我在工程里见过因为省掉析构函数导致的文件描述符泄漏,排查了一整个下午才发现是对象没有走 destroy 流程。
这段代码本身就体现了一个关键思想:内存池只负责“内存生命周期”,对象的“生命周期”还要由你自己控制。这是好事,因为控制权完全在你手里,但也意味着责任在你这,调用 construct 之后必须配 destroy,不能图省事。
3.4 一个可直接编译的小型 benchmark
空谈性能没有说服力,下面给出一个简单的基准测试。构造一个普通对象,分别用 new/delete 和自定义内存池跑十万次,对比耗时。这个测试不严谨,没有消除编译器优化,但它能直观反映两者在量级上的差距,作为验证思路已经足够。
#include <chrono> #include <cstdio> struct Data { int id; double score; char name[32]; Data(int i, double s) : id(i), score(s) {} }; int main() { const int N = 100000; auto t0 = std::chrono::steady_clock::now(); for (int i = 0; i < N; ++i) { Data* p = new Data(i, 1.0); delete p; } auto t1 = std::chrono::steady_clock::now(); MemoryPool<Data> pool; auto t2 = std::chrono::steady_clock::now(); for (int i = 0; i < N; ++i) { Data* p = pool.construct(i, 2.0); pool.destroy(p); } auto t3 = std::chrono::steady_clock::now(); auto mallocUs = std::chrono::duration_cast<std::chrono::microseconds>(t1 - t0).count(); auto poolUs = std::chrono::duration_cast<std::chrono::microseconds>(t3 - t2).count(); std::printf("new/delete: %lld us\n", (long long)mallocUs); std::printf("memory pool: %lld us\n", (long long)poolUs); return 0; }在我本地跑的时候,内存池大概比 new/delete 快四到八倍。这不是一个固定的数,不同平台、不同分配器、不同对象大小都会有变化,但量级差距是稳定存在的。真正的收益还不止这四到八倍,更关键的是分配延迟的稳定性,后面我会展开说。
4. 实测效果与性能分析
4.1 高频率分配下的绝对耗时差距
上面 benchmark 里分配的对象有 40 多字节,属于典型的“小对象高频”场景。new/delete 的耗时主要集中在遍历空闲桶、以及对内存块的元数据读写上。内存池几乎没有额外操作,因此差距很明显。
我做过一组对比实验,在同样的机器上换不同对象大小,结果有意思:对象越小,内存池优势越明显。当对象只有 8 字节时,new/delete 光维护堆块元数据就需要额外内存,而池里一个槽位就是 8 字节,既紧凑又快速。当对象超过 512 字节后,两者的差距会缩小,因为对象本身的构造和析构开始占用主要时间,分配路径那点开销不再占大头。所以我在团队里有一个习惯:先测对象的平均大小和每秒分配次数,再决定要不要上内存池。
4.2 真正的价值是延迟稳定,而不是峰值吞吐
很多人理解内存池只看到了“快”。快当然是一部分,但我认为它最大的价值是确定性。通用分配器为了提升平均性能,采用了非常复杂的缓存机制,这导致单次分配延迟的波动很大。运气好时命中了线程本地缓存,可能几十纳秒就完成;运气不好时触发了系统调用或者锁等待,可能就是几十微秒。这个差异在长期运行的服务里会造成不可预测的抖动。
池化分配不同。一次 allocate 的路径就是检查一个指针、更新一个指针,哪怕发生缺块需要新申请一整块内存,代价也被摊到几百个对象上。整个路径几乎没有分支,延迟非常稳定。
印象比较深的一次是我负责的某个排行榜服务,刚上线时 P99 正常,跑几天后出现周期性尖刺。把火焰图拉出来看,发现尖刺出现在内存分配函数里,调用了内核扩展堆的路径。换成内存池后,同样跑了一周,P99 曲线几乎是一条直线。这种稳定性在高并发场景下比所谓“吞吐提升”重要得多,因为它直接决定了你能否满足服务等级协议的要求。
5. 常见问题与避坑指南
5.1 内存对齐:看着没问题,跑着就崩溃
自定义内存池最隐蔽的坑就是内存对齐。new char[blockSize_]返回的地址通常按最大对齐值对齐,所以第一个节点没问题。但如果你按sizeof(T)当步长切割节点,而 T 的实际对齐要求比 sizeof(T) 更大的时候,后续节点的地址就可能错位。比如 T 包含一个long double或 SSE 向量类型,它的对齐要求可能超过普通对象大小。
前面代码里用 union 包了一层alignas(T) char storage[sizeof(T)],就是为了让 FreeNode 的对齐和 T 保持一致。在 addBlock 里切割节点时,步长也应该取sizeof(FreeNode),这样才能保证每个节点都正确对齐。这是我自己踩过的坑,早期版本只用了 sizeof(T),测试普通结构体一切正常,一旦换成带alignas(32)的类就随机崩溃,排查了很久才意识到是切割步长算错导致节点地址不对齐。
5.2 对象构造与析构遗漏
内存池只负责内存,不负责构造和析构。这在章节里强调过,但实际操作中还是容易漏。典型场景是:从池里 allocate 拿到内存后,直接强转成 T* 就返回给业务层。如果业务层恰好只做简单赋值还好,一旦对象构造函数里有初始化列表、资源申请、状态登记等逻辑,这些就全被跳过了,拿到的是一块残留着上一次数据的脏内存。
反过来,对象归还前不调用析构函数,问题同样严重。如果对象内部包含容器、指针、锁等资源,不析构就归还,轻则资源泄漏,重则内存状态错乱。我的建议是:内存池只暴露 construct 和 destroy,不要暴露裸的 allocate/deallocate,从设计上逼着调用方走完整生命周期。
5.3 线程竞争:同一个池被多线程同时访问
如果你的内存池实例被多个线程同时用,而你没有加任何锁,那空闲链表会被并发写坏。这不是“可能”的问题,是“必然”的问题。线程 A 和线程 B 同时取头节点,A 取走节点,B 也取走同一个节点,两个对象就用了同一块内存,后续任何数据错乱都只能算“轻伤”。
两种常见解决思路:第一种,给 allocate 和 deallocate 加锁。简单可行,适合低频分配场景。第二种,使用线程本地池,一个线程一个池实例,互不干扰。第二种方案在服务端比较实用,尤其是 worker 线程模型的数据处理流程里。代码改动量也不大,把池实例放在 thread_local 变量里就行。如果未来真遇到线程间需要共享同一个池,再考虑无锁方案,但那是另外一个复杂话题了。
5.4 内存池无法把内存还给操作系统
这一点很多人容易忽略。内存池一旦从系统申请了内存,除非显式销毁整个池,否则这些内存不会被归还。哪怕你把池里所有对象都还回去了,空闲链表里串着的那些内存块仍然归你的池所有。对于长期运行且对象峰值需求波动巨大的服务,这会造成内存“只涨不降”的观感。
解决思路有两种:第一,为不同的业务场景创建不同生命周期的池。比如一次性请求上下文,可以随请求周期整体释放;常驻的连接池,才让它常驻。第二,定期检查空闲链表的长度,当空闲节点超过一定阈值时,把整块 Block 归还给系统。第二种实现稍微复杂,但能兼顾池化效率和内存回收,适合对内存占用敏感的服务。
6. 内存池的适用场景与选型建议
6.1 适合用池的典型业务场景
判断一个场景适不适合上内存池,就看三个特征:对象大小是否固定、分配频率是否够高、生命周期是否有明显边界。三个都满足,基本可以放心用。
网络服务里的会话对象是最典型的例子。一个连接从建立到断开,中间只有极少数状态变更,对象大小固定,连接数往往成百上千甚至更多,生命周期也不交叉。日志框架里的日志条目同理,每条日志被创建、写入缓冲区、然后销毁,整个过程短暂且高频。游戏服务器里的实体、技能、Buff 效果也符合这些特征,所以游戏引擎和游戏服务器是内存池使用大户。
工业界很多中间件的内存池应用也遵循这个原则。比如数据库连接池、协程调度器里的协程上下文、定时器管理里的时间轮节点,它们的核心都是“固定大小、高频使用、明确生命周期”的对象。
6.2 不适合用池的场景
不适合的也很明显:对象大小差异极大、一个池里混用多种类型、对象生命周期不明确且无法掌握归还时机,这三类情况不应该硬上池。
我曾经见过业务同学把内存池用在了一个会长期持有对象的模块上,一个对象申请后可能几个月都不释放。结果就是池里大量节点被占着,其他新对象反而没法用,内存利用率反而比通用分配器差。这种情况下用最简单的对象复用逻辑都比池强。还有一类不适合的是超大对象,比如几 MB 的大缓冲区,池化对它的帮助很小,直接走系统分配更合适,因为超大对象的分配频率低,碎片的代价也小。
6.3 更进一步的扩展方向
如果看完这篇博文,你打算把内存池应用到自己的项目里,我建议按这个顺序做扩展:先把上面的实现跑通,套进你的高频对象模块;再根据分配次数和内存占用决定是否需要线程本地池;如果确实需要跨线程共享,再考虑加锁版或者无锁版本。
如果后续还想要更强的性能,可以研究 buddy system 和 slab allocator 的思路,很多知名框架的底层都在用类似的设计。也可以把池封装成自定义分配器,喂给标准库容器,让 vector、map 这种原本频繁扩容的数据结构也走池化分配。这一步对代码侵入性大一些,但收益也大,尤其适合容器节点被反复插入删除的场景。
我个人在实际项目里的习惯是,先用最简版本解决眼前的性能问题,确认收益后再决定要不要增加复杂度。内存池本身不是越复杂越好,而是越贴合业务场景越好。一个没有锁的定长池,配合正确的生命周期管理,已经能解决大部分服务端的分配瓶颈;剩下的那些极端情况,等到你真遇到了再上强者方案,完全不迟。