
简介这是一份C内存池的完整实现面向需要掌握内存分配优化、多线程并发控制的中高级C开发者。资源采用两级内存池架构一级内存池管理大块内存二级内存池为每个线程提供小块内存的快速分配代码覆盖初始化、内存分配与释放、并发控制、空闲块状态管理等核心环节可帮助读者理解内存池从原理到落地的完整路径。压缩包共33个文件以17个头文件和9个C源文件为主头文件负责接口与数据结构声明源文件实现分配释放逻辑与同步机制另配套Visual Studio工程文件、说明文档与构建脚本整体仅19KB结构紧凑便于对照学习。已有1268人学习下载。实现中还讨论了每块内存指针级浪费带来的空间效率问题并延伸出内存块大小调整、动态扩展、碎片整理、缓存局部性优化与避免锁竞争等改进方向适合作为内存管理调试和性能调优的实战参考资料。 内存池是我上手C之后第一个亲手写出来的“工业感”组件印象很深。几年前在游戏服务端做定时玩法热点路径上每秒几十万次小对象分配new/delete直接成了瓶颈线上高峰期卡顿肉眼可见。后来把核心对象换成内存池指标很快稳住了。所以我一直觉得内存池是C开发者的必修课不管你做网络框架、游戏服务端还是准备面试八股都值得亲手实现一次。这篇文章不打算讲多高深的理论就围绕一个C内存池实现聊它解决什么问题、代码怎么写、有哪些坑。为了让你看完能直接跑起来我会给出一份可以复制到本地编译运行的最小版本同时在最后一章整理几个面试官常问的点。先从一个最朴素的问题开始malloc/free到底慢在哪它不是简单地从一个大数组里取一块再放回来而是要维护大量元数据要考虑不同大小、不同对齐、线程安全甚至还要做碎片整理。单次malloc的开销一般在几十纳秒级别听上去不多但当一秒要分配几百万次小块内存时这个固定开销就被放大得非常可观。内存池的思路其实很朴素提前从系统批发一大块内存自己切成合适的小片分配就从这些片里拿释放就把片还回去不再走系统堆。1. 先想清楚内存池到底解决了什么问题1.1 从一次性能分析说起我当时用perf看服务端热点malloc居然排在前三名点开调用栈发现全是消息处理逻辑里频繁创建销毁的小结构体。这里要澄清一点系统分配器glibc的malloc、Windows的HeapAlloc本身并不弱它必须优先保证正确性和低碎片率要处理各种尺寸、各种对齐、多线程并发性能上做了很多权衡。它能做到的是“通用最优”而不是“场景最优”。我们当时的小对象生命周期极短分配后很快释放使用模式高度规律大量请求集中在少数几个固定尺寸上。这种场景交给通用分配器等于让一个全科医生来做流水线工人的活不是不能做但明显不是最高效的。内存池的优势体现在两个地方分配和释放都变成O(1)操作取链表头、插链表头没有系统调用没有复杂的元数据查找。内存局部性好同一个Block里连续分配的对象在内存上天然紧挨着遍历对象时CPU缓存命中率会高不少这在游戏/网络服务里往往比分配速度本身更值钱。我后来在自己的测试机上做了一个粗略对比1,000,000次固定32字节的分配释放new/delete组合耗时大约60毫秒内存池大约9毫秒差了六到七倍。注意这不是严谨benchmark不同机器、不同分配器版本差异很大但它能说明一个趋势高频小对象场景下内存池的收益是实打实的。1.2 什么时候该用什么时候别碰内存池不是万金油我见过不少人把代码里所有new都换成内存池最后性能没提升反而多了一堆bug。根据我的经验判断标准大致是这样场景是否推荐使用内存池高频创建/销毁且对象尺寸相对固定强烈推荐对象生命周期短释放频繁但规律推荐多线程高并发但可以用TLS或分片优化推荐但需谨慎设计锁分配尺寸波动极大几十字节到几MB不推荐收益不明显单个对象生命周期很长数量很少没必要通用分配器足够超大对象256KB直接走系统分配别进池还有一个边界值得注意内存池优化的是内存分配路径它不会帮你改善对象设计。如果业务逻辑本身就在频繁创建不必要对象第一件事应该是减少对象创建而不是升级分配器。我用内存池都是在“确认分配确实是热点”之后才做的不是上来就套。2. 从malloc到内存池设计思路拆解2.1 按大小分桶size class既然要满足“多个尺寸”的分配请求最直接的设计就是分桶。按8字节对齐划分等级8、16、24、32……最高到256字节。请求小于等于256字节时向上取整到对应桶超过256字节直接退回::operator new。为什么要8字节两个原因。第一64位系统上一个指针正好8字节空闲链表的next指针至少需要8字节存储空间第二8字节对齐能满足绝大多数基础类型int、指针、double等的对齐要求。你要分配一个16字节对齐的结构体这个池就得改对齐参数后面踩坑章节我会专门说。等级计算公式很简单// 请求 size 字节返回对应桶下标 // size 8 - 0 // size 15 - 1 // size 16 - 1 // size 256 - 31 static int size2index(size_t size) { return static_castint((size kAlign - 1) / kAlign - 1); }这里向上取整的写法是(size kAlign - 1) / kAlign比如size15(157)/82减1得到1对应16字节桶。这个桶结构的好处是分配时不需要遍历比较一次除法定位到桶复杂度O(1)。坏处是内部会有平均4字节左右的浪费对于256字节以内的对象来说这个浪费完全可以接受。2.2 Block大块内存与空闲链表桶确定之后每个桶内部要管理内存。我的做法是每个桶维护一个Block链表每个Block是一整块连续内存大小固定为64KB。这个Block在被创建时会按自己所属桶的chunk_size切成一个个小片这些切片通过单向链表串起来头指针就是free_list_。free_list_的节点直接用空闲内存本身来存next指针。内存没有被分配出去时它只是一块普通内存我们把它强转成FreeNode结构体里面只放一个next指针分配出去时这块内存被用户当成普通对象用没有任何额外头部开销。这是内存池设计的精髓不浪费任何字节做元数据。struct FreeNode { FreeNode* next; };分配时从free_list_头部摘一个节点返回释放时把内存头节点插回free_list_。整个过程就是两个指针操作比malloc少做太多事情。这个设计参考了SGI STL的_Alloc思路是经典中的经典。2.3 为什么这个方案是“最简单可靠的”真实的内存分配器glibc malloc、tcmalloc、jemalloc要处理碎片整理、伙伴系统、大块小块分类、Per-CPU缓存等一堆问题。但业务项目里我们通常只需要解决一个问题大量相同尺寸的小对象高频分配。把这个需求拆开就会发现设计可以非常克制。我选择每个桶完全隔离的方案而不是一个统一内存池里动态适配所有尺寸原因有三个。第一不同size class之间互不干扰一个桶空了就去申请新Block和别的桶没关系逻辑简单。第二释放时只要确认指针属于某个Block就一定能确定这个Block是哪个桶切出来的不需要额外的头部标记。第三如果某个尺寸长期没被使用它只是在构造时建了一个空链表真正内存到用时才申请不会造成预分配浪费。这个方案的局限是不同桶之间不会有内存借用比如32字节桶满了即使64字节桶很空32字节的分配也只能继续申请新Block。但实际业务里热点尺寸就那么几个这个限制影响很小。3. 核心实现分配、释放与线程安全3.1 分配路径库里没货怎么办allocate的逻辑分两条路。请求超过kMaxSize直接用::operator new否则定位到对应桶先看free_list_有没有空闲节点有就直接摘没有就调用refill()申请一个新Block切好再摘。关键点在于refill()它一次性把64KB切开把所有节点串到free_list_上。注意我丢掉了一个Block的尾部残留因为64KB不一定能被chunk_size整除比如chunk_size24时64KB除以24余4尾部4字节无法使用。这个浪费很小不用管。void* allocate() { if (size 0) size 1; if (size kMaxSize) return ::operator new(size); int idx size2index(size); return allocators_[idx]-allocate(); }你可能会问为什么不每次只切一个节点而要一次性切开整个Block一次性全部切开后面几十次分配都只是链表头节点摘取零额外操作如果每次只切一个那每次分配都要做“取内存、计算边界、挂链表”三件事性能差不少。小细节但对高频路径影响很大。3.2 释放路径怎么证明内存是我们的释放路径比分配路径多一个关键问题我只拿到一个裸指针p怎么判断它属于当前内存池如果判断错了把一个普通堆地址插进链表后面所有分配都会拿到一块非法内存追查起来非常痛苦。我的做法是每个FixedAllocator在释放时遍历自己的Block链表检查指针是否落在某个Block的内存区间内。这里是内存池最容易踩坑的地方专门写了一段判断逻辑bool deallocate(void* p) { Block* b blocks_; while (b) { char* mem static_castchar*(b-mem); if (p mem p mem b-size) { FreeNode* node static_castFreeNode*(p); node-next free_list_; free_list_ node; return true; } b b-next; } return false; }我这里选择在MemoryPool层先根据size算出idx再进对应FixedAllocator的deallocate。这样做的前提是调用者释放时要传回和分配时一样的size。这其实是C标准分配器的通用约定std::allocator的deallocate第二个参数就是size目的是给分配器提供桶定位信息。如果size传错最坏情况是deallocate返回false而我的实现中返回false后会走到::operator delete(p)这就会把池内内存交给系统分配器程序基本立刻崩。所以这个池的约束是分配和释放必须成对出现size必须一致。3.3 完整代码骨架我把前面说的所有内容整理成了一份可以编译的代码。为了控制篇幅我去掉了一些防御性检查保留了最核心的分配、释放和Block管理逻辑#include cstddef #include mutex #include new #include vector class MemoryPool { public: static constexpr size_t kAlign 8; static constexpr size_t kMaxSize 256; static constexpr size_t kLevels kMaxSize / kAlign; // 32 static constexpr size_t kBlockBytes 64 * 1024; MemoryPool() : allocators_(kLevels, nullptr) { for (size_t i 0; i kLevels; i) { allocators_[i] new FixedAllocator((i 1) * kAlign); } } ~MemoryPool() { for (auto* a : allocators_) delete a; } void* allocate(size_t size) { if (size 0) size 1; if (size kMaxSize) return ::operator new(size); int idx size2index(size); return allocators_[idx]-allocate(); } void deallocate(void* p, size_t size) { if (size kMaxSize) { ::operator delete(p); return; } int idx size2index(size); if (!allocators_[idx]-deallocate(p)) { ::operator delete(p); // 正常路径不会走到 } } private: static int size2index(size_t size) { return static_castint((size kAlign - 1) / kAlign - 1); } struct FreeNode { FreeNode* next; }; struct Block { void* mem; size_t size; Block* next; }; class FixedAllocator { public: explicit FixedAllocator(size_t chunk_size) : chunk_size_(chunk_size), blocks_(nullptr), free_list_(nullptr) {} ~FixedAllocator() { Block* b blocks_; while (b) { Block* next b-next; ::operator delete(b-mem); delete b; b next; } } void* allocate() { std::lock_guardstd::mutex lock(mutex_); if (!free_list_) refill(); FreeNode* node free_list_; free_list_ node-next; return node; } bool deallocate(void* p) { std::lock_guardstd::mutex lock(mutex_); Block* b blocks_; while (b) { char* mem static_castchar*(b-mem); if (p mem p mem b-size) { FreeNode* node static_castFreeNode*(p); node-next free_list_; free_list_ node; return true; } b b-next; } return false; } private: void refill() { size_t total (kBlockBytes / chunk_size_) * chunk_size_; void* mem ::operator new(total); Block* b new Block{mem, total, blocks_}; blocks_ b; char* p static_castchar*(mem); for (size_t i 0; i chunk_size_ total; i chunk_size_) { auto* node reinterpret_castFreeNode*(p i); node-next free_list_; free_list_ node; } } size_t chunk_size_; Block* blocks_; FreeNode* free_list_; std::mutex mutex_; }; std::vectorFixedAllocator* allocators_; };这段代码是完整的核心不超过100行。它用的是每个FixedAllocator一把锁也就是每个size class一把锁理论上不同尺寸的分配/释放可以并行比全局一把锁冲突小一些。如果同一个size class是绝对热点这把锁依然会竞争解决办法是线程局部缓存TLS每个线程维护一个无锁空闲链表自己线程释放的内存直接进自己的缓存跨线程释放才加锁往公共链表塞。这个就不展开了属于进阶优化方向。3.4 锁的粒度与优化空间锁是内存池绕不开的话题。我这个实现用标准std::mutex简单可靠但在多线程高并发下会有性能损耗。有一次我压测时发现8线程并发分配内存池竟然比malloc还慢一查就是锁竞争。怎么优化三个常用手段Thread Local StorageTLS每个线程维护独立free_list分配和释放都不加锁只有线程销毁或者缓存太大时才回收到公共池。无锁队列用原子操作CAS实现无锁空闲链表减少锁等待但ABA问题需要处理复杂度高。分片池比如按CPU核心数创建N个独立池线程绑定到固定池减少跨核访问。我的建议是业务代码先用mutex版本跑起来有真实性能瓶颈再上TLS。一上来就写无锁调试成本会非常高。4. 实测对比与快速运行4.1 测试代码与结果为了验证效果我写了一个很简单的测试程序把内存池和new/delete放在同一个进程里对比分别循环1,000,000次操作对象是一个32字节的结构体#include chrono #include iostream struct Item { int64_t a, b, c, d; }; // 32字节 void bench_pool(MemoryPool pool) { auto start std::chrono::steady_clock::now(); for (int i 0; i 1000000; i) { Item* p static_castItem*(pool.allocate(sizeof(Item))); pool.deallocate(p, sizeof(Item)); } auto end std::chrono::steady_clock::now(); std::cout memory pool: std::chrono::durationdouble, std::milli(end - start).count() ms\n; } void bench_new() { auto start std::chrono::steady_clock::now(); for (int i 0; i 1000000; i) { Item* p new Item; delete p; } auto end std::chrono::steady_clock::now(); std::cout new/delete: std::chrono::durationdouble, std::milli(end - start).count() ms\n; }我本机Windows 10MinGW g 12-O2跑出来的结果是内存池大约9毫秒new/delete大约58毫秒。需要强调这个数字只能说明趋势别拿它当标准答案。机器、编译器、标准库实现都会影响绝对值甚至你多跑几次都有波动。观察两个点第一内存池在大量重复分配/释放时把成本压到了很低第二new/delete的固定开销在这个测试里占了主导。4.2 数据怎么看这个测试反映的是“无实际工作负载”的场景现实中对象构造、析构、业务逻辑会占大头内存池的收益比例会被稀释。所以我始终建议做整体压测而不是只测内存池本身的分配速度。通过perf或火焰图看整体热点是否从malloc转移到了逻辑上才说明优化有效。另外要特别关注空闲率如果池申请了很多Block但free_list_常年是满的说明内存没有复用好池帮了倒忙。4.3 在VS Code里快速跑起来很多朋友私信问我C环境是怎么配的。这里给一个最简配置下载VS Code装C/C扩展然后建一个tasks.json编译命令写{ version: 2.0.0, tasks: [{ label: build, type: shell, command: g, args: [-stdc17, -O2, main.cpp, -o, main] }] }Windows下需要装MinGW-w64或MSYS2把g加到PATH里Linux/macOS自带g或clang直接用。之后按CtrlShiftB编译在终端运行生成的main可执行文件。调试的话再配launch.json给C/C扩展指定程序路径为main就行了。这个配置足够跑通本文的所有代码也够日常做算法练习。5. 踩坑实录与面试经验5.1 内存对齐的隐性风险这是最容易出问题的地方。我前面设计的池是8字节对齐但C11之后有max_align_t通常是16字节对齐。如果你在内存池上要放alignas(16)的对象或者SIMD类型_m1288字节对齐就不够。我用::operator new给Block申请内存时Block内存默认是符合max_align_t对齐的所以Block内部起始地址对齐没问题。问题是chunk_size_是8的倍数切片地址也自然8对齐无法保证16对齐。解决方案是把参数改成最大对齐值要么在分配时额外预留对齐字节要么直接规定本池只服务对齐要求小于等于8字节的对象。我的建议是后者简单也够用。5.2 size传错导致的内存灾难前面提到这个池要求deallocate传回和allocate一样的size。有一次我把一个结构体从32字节改成了40字节某个地方释放时还传着旧的32结果指针被判定不属于36字节桶的Block走了::operator delete程序直接崩。后来我加了一个断言如果deallocate返回false立刻打印指针和size。这个习惯救了我好几次。更稳健的做法是在每个chunk头部存一个4字节的magic number或者池指针释放时校验但会增加内存占用且和FreeNode返回裸指针的layout冲突。实际项目里还是约定为主配合断言辅助。5.3 内存池和Sanitizer的冲突内存池会一次性持有大块内存ASan、Valgrind这类工具默认会对这种“长生命周期的大块内存”报告泄漏或者无效访问误报。我之前用AddressSanitizer跑内存池测试出来的报告几乎没法看。解决办法有两个一是给ASan配置suppression文件忽略内存池的已知行为二是写一个开关在编译测试版本时让内存池直接退化成new/delete这样Sanitizer就能正常工作。别小看这个线上内存问题排查全靠这个开关救命。5.4 Block的回收策略我的实现里Block只有在FixedAllocator析构时才释放运行期间不会主动还给系统。这意味着如果一个峰值流量让池申请了很多Block流量过去后这些内存会一直占着可能被误认为是内存泄漏。这是内存池的常见取舍保留内存是为了后续复用牺牲的是空闲时内存占用。如果你很在意这个可以加一个定期清理策略比如Block空闲超过N秒且free_list_全满就标记该Block可回收并归还系统。但这项优化很复杂我不建议在新手阶段就做。5.5 面试时怎么把内存池讲出层次内存池是高频面试题很多人能背出“减少系统调用、避免内存碎片”这几句但问深一点就露馅。面试官通常关注三个点一你知不知道free list为什么可以复用对象内存本身来存指针也就是FreeNode的设计二你如何处理释放时判断归属能不能讲清楚Block区间查找的边界条件三你如何做线程安全能不能说出TLS和无锁队列的区别。能把这三点讲明白再配合一段自己写过的代码这道题基本稳了。另外如果能提一句“对齐问题”面试官会认为你有实际踩坑经验这个加分很明显。最后再分享一个我现在常用的习惯给内存池加一个统计接口每个size class能打印当前空闲节点数、Block数量。上线前先跑一轮压测观察空闲率如果某个level长期空闲要么是这个size根本没用上要么是释放路径有bug。这个习惯比任何代码评审都管用因为数据不会说谎。内存池不是银弹但把这类基础设施写扎实、量清楚整个系统的稳定性会上一个台阶。本文还有配套的精品资源点击获取