从零实现定长内存池:原理、代码与性能实测

发布时间:2026/10/20 16:01:50
从零实现定长内存池:原理、代码与性能实测 内存池这个词听起来像是要出什么高深的大招但把它拆开来看无非就是一句话把一大块内存提前拿在手里按固定大小切成小块谁要用就从里面摘一块用完再还回来。上一篇聊池式组件的时候我把池能解决的问题和应用场景梳理了一遍这篇二就直接落地从零写一个最简单的定长内存池把原理、代码、实测数据以及我踩过的几个坑全部摊开讲。先说我为什么要碰这个东西。之前优化一个网关程序压测时发现 malloc/free 占了 CPU 将近三成——每个请求都要创建和销毁几个小的临时对象吞吐一高分配器就成了瓶颈。那时候我就明白系统自带的通用分配器再快也架不住你高频、短命、同构地造对象。这种场景就是内存池的用武之地。这篇文章适合两类人一是被性能问题困扰、想了解池化思路的工程师二是读过很多概念、但没亲手写过内存池的开发者。读完你手里应该有一份能跑的代码、一套能解释的原理还有几条能直接抄走的避坑经验。定长池是整个池式组件系列的地基把它吃透后面的分级池、对象池都会顺很多。1. 频繁 malloc 的账单从系统调用到内存碎片1.1 一次小对象分配的隐藏链路很多人对 malloc 有个误解觉得它不过是个拿内存的函数代价可以忽略。实际上从进程视角看一次 malloc 牵扯到的东西很多。系统分配器要先检查当前线程的缓存和空闲链表看看有没有合适的块如果没有可用 chunk就要向内核申请内存触发 brk 或者 mmap 之类的系统调用多线程环境下还要处理锁竞争和内存池之间的迁移。这几层走下来一次小对象分配的耗时往往在几十到几百纳秒。听起来不大但一天几十亿次分配的时候就完全是另一回事了。perf 采样结果里 malloc 相关路径占了近三成的现象在短连接高并发的服务端程序里非常常见。元数据开销同样被低估。以 Linux 上常见的 C 运行库实现为例malloc 会给每个 chunk 附加头部信息小对象场景下这部分占比相当可观。16 字节的分配实际占用的内存可能远超 16 字节算上头部和对齐损耗有效利用率会明显打折。在内存敏感的嵌入式环境里这种浪费甚至比性能问题更致命。1.2 碎片与延迟抖动比性能更隐蔽的问题是碎片。程序长期运行反复分配、释放不同大小的对象堆内存会被切得越来越碎留下大量难以利用的小空洞。分配器为了满足一个新请求可能要遍历很多碎片拼接出一个足够大的连续区这就是所谓的外部碎片。极端情况下明明进程还有几百 MB 内存却分配不出一个稍大的块。碎片对时效性的影响也很大——分配时间不再稳定。可能这次几纳秒下次因为触发了块合并和系统调用就变成几微秒。对于追求确定性延迟的场景比如游戏引擎、实时音视频处理这种毛刺非常难受。内存池的优势正是让分配耗时变得稳定无论系统当前内存布局多乱池内永远是从链表头摘一块纳秒级完成。1.3 什么样的业务值得上内存池结合上面的问题当出现以下特征时内存池基本就是标准答案对象生命周期短创建和销毁非常频繁对象大小接近固定或者只有有限的几种大小分配和释放的顺序没有明显规律但单个对象存活时间短对延迟抖动敏感希望分配耗时稳定。网络连接对象、协议解析 buffer、游戏中的子弹和特效、日志系统中的临时事件都是典型的适用对象。反过来如果对象数量少、生命周期长或者大小分布极其不均匀内存池带来的收益就很有限——这时候老老实实用系统分配器反而更稳。2. 为什么简单内存池通常就是定长块池2.1 定长让复杂问题消失先面对一个核心问题内存池为什么多数是定长的变长分配的难点在于你需要维护哪些区间是空闲的、大小是多少分配时要找到合适的区间释放时要考虑和前后的空闲区合并。这本质上就是实现一个简化版的 malloc复杂度直接上一个数量级。作为一篇讲简单的文章我不会去做这种事。定长块池的思路完全不同提前把一大块内存切成 N 个大小完全相同的块空闲块用链表串起来。需要时就摘一个块归还时就挂回去。没有查找、没有合并、没有大小分类——整个分配过程就是一次链表头部操作。这也是它快的原因。malloc 是通用工具必须处理各种大小和复杂场景内存池是专用工具只服务一种固定大小可以把路径缩短到极致。通用和专用之间的差异正是性能差距的根源。2.2 顺带澄清对象池与内存池很多人会把对象池object pool和内存池memory pool混在一起说这里顺便理清楚。严格讲对象池管的是对象创建时调用构造、复用时重新构造内存池管的是原始内存不管对象长什么样。定长内存池和定长对象池在实现上非常接近差异只在要不要调用构造函数和析构函数。我这篇讲的是内存池管的是内存块的分配与回收。但代码稍加包装在外面包一层模板让它负责构造和析构就变成对象池了。理解这个区别很重要后面你看到别的地方讲对象池就不会觉得是另一个完全不同的东西。2.3 分级块池定长思路的自然延伸如果业务里确实存在几种常见大小可以做分级块池维护一组定长池比如 16 字节一档、32 字节一档、64 字节一档……分配时把请求大小向上取整到最近的档位再交给对应的定长池处理。这个思路是所有通用内存池的基础很多知名分配器的 size class 设计就是它的工程化版本。它比完全变长简单得多代价是内部碎片请求 65 字节被放到 128 字节的档位里浪费接近一半。所以分档次数的选取要在覆盖常见大小和减少浪费之间权衡。对于这个系列来说定长池是理解其他所有池组件的地基。它把内存池最核心的三个问题暴露得最清楚内存从哪来、空闲块怎么组织、分配释放如何做到 O(1)。这些问题搞明白了后面的分级池、对象池都只是在基础上加东西。3. 零元数据开销的设计核心把链表指针借给用户3.1 同一个地址两种身份现在说实现里最核心的设计点空闲链表指针放在哪。最直观的做法是每个块额外分配一个控制块里面存 next 指针。这样就有额外开销——块越大浪费越多违背了内存池省内存的初衷。更好的做法是借用内存块本身。块空闲时它前几个字节没有人用正好用来存 next 指针一旦被分配出去整块内存归用户所有next 指针自然就不需要了。同一个位置两种身份互不冲突。在 C 语言里可以用 union 把这个语义表达清楚typedef union mem_block { struct mem_block* next; // 空闲时链表指针 max_align_t align; // 保证对齐 } mem_block_t;实际工程里更常见的写法是直接用结构体因为结构体第一个成员的地址就是结构体本身的地址效果完全一样代码还更直观。下面的完整实现用的就是一个朴素的 struct道理和 union 是同一个。3.2 对齐与最小块大小两个隐藏约束这个借用设计有两个隐藏约束新手很容易踩。第一个是块大小必须对齐。malloc 返回的地址是对齐的一般至少满足平台上最严格的对齐要求比如 16 字节但只要一按 block_size 切块如果 block_size 不是 16 的倍数后续块的地址就会错位。用户往块里写 double 或 SIMD 类型时轻则性能下降重则直接崩溃。所以切块前要把 block_size 向上对齐到 16 字节。第二个约束是块大小至少要能放下一个指针。如果请求 8 字节而 64 位平台上的指针正好是 8 字节勉强够请求 4 字节甚至 1 字节呢next 指针就没地方放了。因此实现里必须先做一次地板检查小于 sizeof(mem_block_t) 的按 sizeof(mem_block_t) 处理。对齐之后还有一点值得注意用户请求的对象如果内部有自己的对齐要求比如包含 double、指针等成员16 字节对齐在绝大多数平台上都够用了。如果你要支持特殊对齐类型把对齐宏改成更大的值即可思路不变。3.3 池的元信息与状态维护除了空闲链表本身池还需要维护一组描述自身状态的元信息块大小、块总数、空闲块数量、内存起始地址。这些字段看似简单实际作用很大。以空闲块计数为例它的价值在监控和调试时才会显现。线上运行中多一个 free_count 字段就能随时知道池的使用率判断容量是否够用。调试模式下还能根据它判断是否存在释放了未分配的块之类的异常。而 block_size 字段则给调用方提供了确认容量的渠道——分配前查一下池的块大小就能从源头避免对象大小错配。4. 一份可以直接上手的实现4.1 初始化建链为什么倒着串先给出头文件和结构定义。我追求的是能跑、够简单、可扩展没做任何花哨的优化// mem_pool.h #ifndef MEM_POOL_H #define MEM_POOL_H #include stddef.h #define POOL_ALIGN_SIZE 16 typedef struct mem_block { struct mem_block* next; } mem_block_t; typedef struct mem_pool { mem_block_t* free_list; unsigned char* base; size_t block_size; size_t block_count; size_t free_count; } mem_pool_t; int mem_pool_create(mem_pool_t* pool, size_t block_size, size_t block_count); void mem_pool_destroy(mem_pool_t* pool); void* mem_pool_alloc(mem_pool_t* pool); void mem_pool_free(mem_pool_t* pool, void* ptr); #endif每个字段都有它的作用。base 是整块池内存的起始地址销毁时要用它来释放。block_size 是对齐后的块大小block_count 是总块数free_count 方便监控池的使用率。free_list 就是那个被反复摘挂的链表头。创建函数是重点。它要做三件事调整并记录块大小、一次性分配整块内存、构建空闲链表。// mem_pool.c #include mem_pool.h #include stdlib.h static size_t align_up(size_t n, size_t align) { return (n align - 1) ~(align - 1); } int mem_pool_create(mem_pool_t* pool, size_t block_size, size_t block_count) { if (!pool || block_size 0 || block_count 0) { return -1; } if (block_size sizeof(mem_block_t)) { block_size sizeof(mem_block_t); } block_size align_up(block_size, POOL_ALIGN_SIZE); unsigned char* base (unsigned char*)malloc(block_size * block_count); if (!base) { return -2; } pool-base base; pool-block_size block_size; pool-block_count block_count; pool-free_count block_count; pool-free_list NULL; // 把整块内存切成 N 块串成空闲链表 mem_block_t* prev NULL; for (size_t i 0; i block_count; i) { mem_block_t* node (mem_block_t*)(base i * block_size); node-next prev; prev node; } pool-free_list prev; return 0; }构建空闲链表时我用了倒序插入循环结束后 free_list 指向下标为 0 的那一块。这样做的目的是让后续分配按照内存地址从低到高的顺序进行一定程度上利于缓存局部性。虽然对性能的影响在大多数场景下可以忽略但养成这个习惯没坏处。4.2 分配O(1) 的背后是接口约定分配函数堪称整个池的精华版void* mem_pool_alloc(mem_pool_t* pool) { if (!pool || !pool-free_list) { return NULL; } mem_block_t* node pool-free_list; pool-free_list node-next; pool-free_count--; return (void*)node; }逻辑就三步取表头、表头后移、计数减一。时间复杂度 O(1)没有任何搜索。这里要特别强调一个接口约定池满时返回 NULL调用方必须处理这个返回值。很多第一次写内存池的人会忽略这一点觉得我的池开得够大不会满结果高并发下一旦峰值超过设计容量空指针就变成让人头疼的崩溃现场。内存池是否够大是个运行时问题编译期无法保证所以分配失败的路径必须被认真对待。4.3 释放与销毁头插与整块回收释放和分配是对称的void mem_pool_free(mem_pool_t* pool, void* ptr) { if (!pool || !ptr) { return; } mem_block_t* node (mem_block_t*)ptr; node-next pool-free_list; pool-free_list node; pool-free_count; }把归还的内存块头插回链表同样是 O(1)。整个池没有对单个块的逐个释放所有的块在 destroy 时一次性回收void mem_pool_destroy(mem_pool_t* pool) { if (!pool) { return; } free(pool-base); pool-base NULL; pool-free_list NULL; pool-block_count 0; pool-free_count 0; }一个常见的疑问是释放时要不要把块内存清零我的建议是默认不清零因为清零是纯浪费除非你开了调试模式。调试时填充 0xFD 之类的标记值可以帮助捕获使用已释放内存的 bug这个技巧第七节会展开说。4.4 C 使用的注意点placement new上面这套 C 接口换到 C 使用时有一个大坑内存池只负责分配内存不负责调用构造函数和析构函数。正确用法是配合 placement new#include new Foo* foo new (mem_pool_alloc(pool)) Foo(args); // ... 使用 ... foo-~Foo(); // 手动析构 mem_pool_free(pool, foo); // 归还内存如果不这样写直接拿分配回来的裸指针去调用成员函数对象里的虚表指针、内置类型成员全都没有初始化行为是未定义的。反过来归还前忘了析构对象持有的资源文件句柄、堆内指针就会泄漏。C 里更合理的方式是包一层对象池模板把 placement new 和析构封装进去这里就不展开了。5. 容量扩展与线程安全从单机到并发的边界5.1 池满的三种应对策略前面说过池满时返回 NULL这是最保守的策略。实际项目中往往不能接受分配失败常见做法有三种。第一种是池内扩展池满时再 malloc 一整块内存切块后串进空闲链表。这个方案实现简单代价是销毁时要遍历所有内存块逐一释放适合请求高峰短暂、平时池子够用的场景。第二种是满载回退 malloc分配时发现池空直接调用系统分配器释放时判断指针归属属于池内就还给池否则走普通释放。问题在于释放时无法快速判断指针归属——要么维护区间范围做比较要么给每个块加归属标记都会引入额外成本。第三种是开大池子直接把容量设成理论峰值。简单粗暴但如果峰值远高于平均值内存浪费会很明显。我的建议是先按平均负载乘以一个安全系数来开池再实现第一种池内扩展作为兜底。扩展函数只在初始容量用尽时才被调用平时零开销。如果后面确实需要更灵活的容量再升级成分级池或者并入更完善的对象池设计。5.2 加锁到底划不划算基础版本的 alloc/free 完全没有线程同步不支持多线程直接共享。这一点必须在文档里写清楚。如果就是要多个线程共用一个池最省事的方案是给分配和释放包一层互斥锁。临界区只有链表头的一次指针读写锁开销摊薄后吞吐依然比裸 malloc 高因为 malloc 的临界区要复杂得多——涉及 bin 操作、合并、系统调用等。在我的实测中加锁后内存池相对 malloc 的优势从接近 10 倍缩水到 3~5 倍但依旧有显著收益。如果你对并发收益有更高的要求可以考虑原子操作实现无锁栈但要做好处理 ABA 问题的准备以及池空时如何安全扩展这个难题对新手来说复杂度明显偏高。我的观点一直是不要为了并发而并发先用简单的加锁方案跑起来确认瓶颈确实在锁上再优化。5.3 线程私有池更简单的并发方案多线程场景下我更推荐的还是线程私有池每个线程维护自己的池实例线程间不分享内存块自然没有竞争。代价是池总容量变成各线程峰值之和内存占用会略高但换来的是完全无锁的分配释放而且局部性更好——线程频繁使用的块都在自己的池里。这个方案唯一的麻烦是跨线程归还一个线程分配出去的内存块如果被另一个线程释放归还时不知道要还给哪个池。解决办法是约定池的内存块不允许跨线程释放。绝大多数高频短命对象场景下这个约束都能满足满足不了的时候再回到共享池加锁的方案。6. 实测与解读一百万次分配/释放的差距6.1 一个可复现的最小基准光说不练没意思。下面这份代码把 malloc/free 和内存池放进同一个程序对比运行一百万次每次分配完立即释放顺带写一个字节避免编译器优化掉#include stdio.h #include stdlib.h #include time.h #include mem_pool.h #define TIMES 1000000UL #define BLOCK_SIZE 64 int main(void) { mem_pool_t pool; if (mem_pool_create(pool, BLOCK_SIZE, 1024) ! 0) { fprintf(stderr, pool init failed\n); return 1; } clock_t t0 clock(); for (size_t i 0; i TIMES; i) { void* p malloc(BLOCK_SIZE); if (!p) return 1; *(volatile unsigned char*)p 1; free(p); } clock_t t1 clock(); double malloc_ms (double)(t1 - t0) * 1000.0 / CLOCKS_PER_SEC; t0 clock(); for (size_t i 0; i TIMES; i) { void* p mem_pool_alloc(pool); if (!p) return 1; *(volatile unsigned char*)p 1; mem_pool_free(pool, p); } t1 clock(); double pool_ms (double)(t1 - t0) * 1000.0 / CLOCKS_PER_SEC; printf(malloc/free: %.2f ms\n, malloc_ms); printf(mem pool: %.2f ms\n, pool_ms); printf(speedup: %.1fx\n, malloc_ms / pool_ms); mem_pool_destroy(pool); return 0; }编译时记得加优化选项比如 -O2。不开优化的话两个循环本身的指针读写开销占比太大池的优势会被稀释测出来的数据没有参考意义。6.2 数据表格与快在哪的解读在我本机普通 x86-64 Linux 环境的实测中结果大致是这样块大小malloc/free 耗时内存池耗时提升倍数16 B约 230 ms约 18 ms约 12x64 B约 140 ms约 12 ms约 11x256 B约 110 ms约 12 ms约 9x这几个绝对值会随平台、系统库版本和机器负载变化大家别直接抄去当结论但相对趋势基本是稳定的对象越小、分配越频繁内存池优势越明显。换用 256 字节块malloc 那边有所改善但内存池依然快约 8~10 倍。快的原因其实在前面已经埋了伏笔——分配路径只有三次内存访问取表头、改表头、计数减一没有 chunk 元数据、没有系统调用、没有锁竞争、没有碎片整理。另外因为池的内存是连续的生命周期相近的对象在地址上往往也靠近缓存命中率比 malloc 散落各处的对象更高这是容易被忽略的隐性收益。6.3 压测时必须补上的一课基准里我分配完立刻释放池子始终很满分配器从不遇到池空。真实业务里如果出现分配频率远高于归还频率的突发峰值池很快会耗尽每次分配都要走扩展路径甚至返回 NULL此时性能会急剧恶化。所以压测内存池时不能只用分配 N 次、释放 N 次这种稳态模型一定要加一个分配峰值超过池容量的用例看扩展路径的花销是否可接受。这一条我之前吃过亏某个模拟项目里池容量按平均值配置结果高峰流量一来系统出现大量分配失败日志定位问题花了一整天。7. 踩坑清单内存池最容易翻车的四个细节7.1 双重释放的反直觉崩溃这是所有内存池使用中最危险的一类问题。系统 malloc 对 double free 通常能检测出来并给出明确报错内存池不会做任何校验——它只是把指针塞回链表于是同一块内存可能同时存在于链表和用户手里等到某一天被两个地方同时写入时内存损坏会在完全不相干的代码里爆发。我的习惯是调试模式下在 mem_pool_free 里检查标志位如果发现已经在空闲链表中就主动断言。实现上可以在块里预留一个 magic 值分配时写 0xA5A5释放时写 0xDEAD越界访问和双重释放马上就能被断言抓住。代价是每个块要额外几个字节正式发布时用条件编译去掉即可。7.2 定长池无法感知对象大小错配定长池的存在要求使用者严格保证放进去的多大取出来的就是多大。但池本身不会记住这一点——你把一个 128 字节的结构体放进 64 字节块的池里编译器不会报错运行时会写到下一块的开头。这种 bug 在地址邻近的内存池里尤其隐蔽损坏可能隔很长时间才暴露而且事故现场和真凶往往毫无关联。规避方法是在接口层做类型约束。C 里写一个模板对象池allocT()在编译期就 static_assert(sizeof(T) block_size)C 语言没有这个能力只能靠代码审查和单元测试兜底。我建议任何内存池接口都附带一个块大小查询函数帮助调用方在分配前确认容量。7.3 高水位池内存不会自己瘦身内存池从创建开始就把整块内存握在手里直到销毁才会还回去。即使长时间空闲占用的内存也居高不下这就是所谓的高水位问题。在内存充裕的服务端问题不大但在嵌入式或容器环境里一不留神就可能被误判为内存泄漏。解决思路有两个一是实现池的自动收缩定期检查 free_count 与 block_count 的比值把长期完全空闲的整块内存释放掉二是先做监控把池使用率暴露成指标确认业务确实需要池再开池。无论如何我都不建议为了省代码而忽略这个问题的存在。7.4 调试工具与池的兼容性问题用 AddressSanitizer 或 Valgrind 调试程序时内存池经常导致大量误报因为池的行为本质上就是绕过工具对分配器的跟踪内存池中的块在 ASan 看来是仍被分配的整块内存你也不知道内部哪个块空、哪个块忙。我的做法是做一个开关打开后池内部不再用链表而是用一个按位图标记的数组记录每个块的忙闲状态分配释放时更新位图。这样 ASan 能准确看到哪个块被占用同时调试期代码仍能走通完整的分配释放路径。虽然这会让调试版本跑得慢一些但换来的是可定位的内存问题这笔账非常划算。其实走到这一步你会发现简单内存池的核心思想一点也不复杂一次分配、N 次复用、链表串起来分配和释放都做成 O(1)。但把它真正用稳靠的是对对齐、容量、线程边界和调试手段的敬畏。我在实际项目里的体会是内存池是最适合作为池式组件系列入门主题的素材写一遍、压一遍、踩一遍坑后面再去看分级池、对象池甚至无锁池理解会顺手很多。如果你想自己动手验证建议先把我这份实现放在一个小程序里跑通再把池容量调成一个很小的值来触发扩展路径然后观察调试标记位能不能帮你抓住越界——这套流程走完你对内存池的理解会比单纯读十篇博客都扎实。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询