资讯动态

内存池实战:从定长池到线程本地缓存,解决malloc性能与碎片问题

发布时间:2026/10/19 9:06:50 来源:尧图企业网站定制
内存池介绍这是我最近在复盘项目时反复琢磨的一个话题。做后台服务这几年我先后在高并发网关、游戏服务器、嵌入式采集程序里都亲手写过或者优化过内存池踩过的坑不少沉淀下来的经验也很多。这篇文章我就围绕“内存池”这个东西把设计思路、核心实现、进阶玩法、踩坑实录一次性讲透。不论你是刚接触系统编程的新人还是正被性能问题困扰的资深开发这篇文章都会给你一些可以直接落地的参考。内存池本质上是“预分配复用”思想在内存管理上的具体实践。它解决的核心问题就两个一是高频分配/释放带来的性能开销二是长期运行产生的内存碎片。游戏引擎里的帧级对象池、网络框架里的buffer池、数据库里的page pool说到底都是内存池思想的不同形态。搞清楚内存池的来龙去脉你就能举一反三看懂很多高性能组件的底层设计。1. 内存池的核心思路为什么频繁malloc/free会慢在动手写内存池之前先得弄清楚它到底在解决什么问题。很多人只觉得直接调malloc/free会慢但慢在哪、为什么慢并不清楚。1.1 malloc在幕后做了什么大多数人对malloc的认知停留在“从堆里申请一块内存”这个层面。但实际上一次最简单的malloc可能经历这样的过程用户态malloc库首先检查空闲链表或bin寻找合适大小的空闲块。如果找不到需要向操作系统申请新的堆内存这通常要调用brk或mmap系统调用。系统调用意味着用户态和内核态的切换这个切换成本远比一次普通函数调用高得多。内核还要维护页表、分配物理页、处理缺页中断这些都不是免费的。我实测过一组数据在一个普通的Linux环境上一次malloc大约在几十到几百纳秒之间但这是理想情况。当内存碎片增多、空闲链表变得很长时查找合适块的时间会显著上升而且锁竞争会加剧。在多线程环境下所有线程共享同一个堆分配器内部要加锁高并发时这个锁就是热点。你可以把malloc想象成去超市买一把螺丝刀。每次买完回家拧几下螺丝下次用又得跑一趟超市。内存池的思路是一次性开车拉一整车工具回来放在工作室里随用随取。虽然前期采购成本高但后续每次取用几乎零成本。1.2 内存碎片长期运行的隐形杀手内存碎片有两种。外部碎片是已分配内存之间的小空隙小到无法容纳新的分配请求。内部碎片是分配给程序的内存比实际请求的大多出来的部分被浪费了。举一个我实际碰到过的例子。某个采集程序运行一个月后内存占用从初始的200MB涨到1.8GB。用工具排查后发现不是内存泄漏而是大量2字节、5字节、11字节这种小块分配把堆切得稀碎。每次malloc留下的空闲块都不小但都被分隔成无法合并的小碎片。这种场景下光靠malloc的默认策略几乎无解。内存池因为自己管理内存块可以提前把内存切成固定大小从源头避免了这种“切碎了”的情况。1.3 内存池的适用场景内存池不是万能药滥用反而会带来新问题。根据我这几年实际项目的经验适合内存池的典型场景有这些结构大小相对统一比如游戏中的粒子、网络包、数据库中的行记录。分配频率高、生命周期短典型的是每帧创建又销毁的临时对象、连接处理中的临时buffer。对延迟敏感的系统比如交易系统、音视频推流要求分配时间可预期。长时间运行的系统必须控制碎片化趋势比如服务端常驻进程。如果你的业务是偶尔分配一次大内存、生命周期长那老老实实用malloc就够了。内存池的收益来自“复用”没有高频复用的场景它只会增加复杂度。2. 内存池的两种主流形态定长池与分级池有了场景判断下一步就是选型。内存池的实现方案五花八门但本质上可以归为两大类。2.1 定长内存池最简单可靠定长内存池是最基础的形式。启动时一次性分配一块大内存切成大小相等的块每个块用指针串成free list。分配时从链表头部取一个块释放时把块重新挂回链表头部。这种设计的好处是简单、高效、分配时间恒定。所有块一样大不存在查找适配的过程取和放都是O(1)操作。定长池最适合场景是某种特定对象的大量创建销毁比如网络连接对象、游戏中的子弹/粒子。缺点是如果对象大小差异很大定长池的内部碎片就比较大。你按最大对象大小来切块小对象也用这么大的块空间浪费明显。2.2 分级内存池兼顾灵活分级池的思路是把内存按大小分成多个级别比如8字节、16字节、32字节、64字节、128字节……每个级别配一个定长池。分配内存时根据请求大小直接定位到对应的池子超过最大级别的请求才转给malloc。这种设计在通用性和性能之间做了很好的折中。nginx分配器在结构上就是这么干的glibc的malloc其实也有类似的分级思想。分级池的关键在于分级策略。分太细管理结构的开销大分太粗内部碎片多。我一般按2的幂次分级同时允许相邻级别合并控制在12到16个级别左右覆盖从16字节到4KB的范围。2.3 池化与线程亲和还有一种进阶思路就是线程亲和的内存池。每个线程绑定一个局部池线程内分配不涉及全局锁。TCMalloc的线程缓存、jemalloc的arena、以及众多游戏引擎的帧内存分配器都是这个路数的。在多线程高并发场景下全局锁竞争往往是内存池性能的瓶颈。线程本地缓存可以大幅消减锁竞争但实现复杂度也更高。核心挑战在于线程局部缓存和全局池之间的同步以及空闲块跨线程释放时的归属处理。3. 手写一个定长内存池的完整实现理论讲得再多不如直接把代码搭一遍。这一节我手写一个可复用的定长内存池包含分配、释放、批量归还并且支持设置内存对齐。3.1 核心数据结构先定义内存池的基本结构。定长池的核心是free list我用union节点方式实现——空闲块内部需要存下一个指针而这块内存分配给用户后又不能有额外负担于是让空闲块的数据区域和next指针共用内存。// mem_pool.h #ifndef MEM_POOL_H #define MEM_POOL_H #include stddef.h typedef struct mem_pool { unsigned char *pool_start; // 池的起始地址 size_t block_size; // 每个块的数据区大小 size_t block_count; // 块总数 size_t align_size; // 对齐字节数 struct free_node *free_list; // 空闲块链表头 } mem_pool; // 空闲块节点分配时它的地址就是返回给用户的地址 typedef struct free_node { struct free_node *next; } free_node; mem_pool *pool_create(size_t block_size, size_t block_count, size_t align_size); void *pool_alloc(mem_pool *pool); void pool_free(mem_pool *pool, void *ptr); void pool_destroy(mem_pool *pool); #endif这里有个加分项块大小要考虑对齐修正。比如用户要求32字节对齐实际块大小需要向上取整到align_size的倍数。C11里可以用max_align_t或者直接用_Alignof来判断。为了保证通用性我代码里支持传入自定义对齐值0表示用默认对齐。3.2 初始化大块分配与链表构建创建池时只做一次大块内存申请然后按块大小切分成多个free node挂入链表。// mem_pool.c #include mem_pool.h #include stdlib.h #include string.h #include stdint.h static size_t align_up(size_t n, size_t align) { if (align 2) return n; return (n align - 1) ~(align - 1); } mem_pool *pool_create(size_t block_size, size_t block_count, size_t align_size) { // 默认对齐到指针大小 if (align_size 0) { align_size sizeof(void *); } // 块大小对齐 size_t real_block align_up(block_size, align_size); mem_pool *pool malloc(sizeof(mem_pool)); if (!pool) return NULL; // 一次性申请整块内存注意对齐要求 size_t total real_block * block_count align_size; unsigned char *raw malloc(total); if (!raw) { free(pool); return NULL; } // 让起始地址对齐 uintptr_t addr (uintptr_t)raw; uintptr_t aligned (addr align_size - 1) ~(uintptr_t)(align_size - 1); size_t offset (size_t)(aligned - addr); unsigned char *start raw offset; pool-pool_start start; pool-block_size real_block; pool-block_count block_count; pool-align_size align_size; // 链接所有空闲块 free_node *prev NULL; for (size_t i block_count; i 0; i--) { free_node *node (free_node *)(start (i - 1) * real_block); node-next prev; prev node; } pool-free_list prev; // 把raw指针保存下来方便释放。这里简化为藏在pool_start前面 // 实际工程里可以给mem_pool增加一个raw_base字段 return pool; }初始化有两个细节容易踩坑。第一个是malloc可能返回未对齐的地址所以总大小里我多申请了align_size用于调整。第二个是free list的构建方式从尾部往前串这样最终链表顺序和地址顺序一致对调试友好。有人会问free_node结构和用户数据共用同一块地址会不会冲突注意只有空闲块才在free list里一旦分配出去就不在链表中了那个地址归用户所有里面的内存被用户数据覆盖完全没问题。链表指针只存在于空闲状态。这个union式设计是内存池实现里最常见也最高效的做法。3.3 分配与释放O(1)操作分配就是从头节点取一块然后把头指针后移。释放就是把块重新链回头部。void *pool_alloc(mem_pool *pool) { free_node *node pool-free_list; if (!node) return NULL; // 池子耗尽了 pool-free_list node-next; return (void *)node; } void pool_free(mem_pool *pool, void *ptr) { if (!ptr) return; free_node *node (free_node *)ptr; node-next pool-free_list; pool-free_list node; }就这几行。分配和释放都没有循环、没有锁、没有系统调用速度是纳秒级别的。对比malloc在预分配不足时必然触发系统调用的情况这里节省的时间非常可观。这里要特别提醒一个容易犯错的地方释放时必须保证指针确实来自这个池子。如果从别的地方malloc出来的指针拿来池子里释放会把free list搞乱后续所有分配都指向非法内存。工程做法是池子内部维护一个地址范围或bitmap释放时先校验ptr是否落在pool_start到pool_start block_size * block_count区间内。3.4 性能测试与对比我自己做了一组基准测试循环分配释放同样大小的结构体malloc/free一百万次对比内存池一百万次。测试环境是x86_64 Linux编译器开了O2优化。操作malloc/free耗时内存池耗时提升幅度单线程分配释放约28ms约3ms约9倍4线程并发分配释放约115ms约18ms约6倍单线程差9倍多线程差6倍。而且池子只是基础实现没做任何并发优化。如果加上线程亲和机制差距还会更大。但我也要说清楚这个对比只有在“内存块大小一致、分配频繁”的前提下才是公平的。真实业务里内存请求大小千差万别定长池并不能覆盖所有场景这就是分级池存在的意义。4. 进阶设计分级池与线程本地缓存定长池是地基真正要用于通用场景还得往上盖楼。分级池和线程本地缓存是两个最常见的升级方向。4.1 分级池的请求路由分级池的核心是请求大小到目标池的映射。以2的幂分级为例一次malloc(100)应该落在128字节池malloc(50)落在64字节池。static int size_class(size_t size) { int idx 0; size_t s 16; // 最小级别16字节 while (s size) { s 1; idx; } return idx; }简单算一下size在(16,32]落在第1级在(32,64]落在第2级以此类推。若size大于最大级别比如4KB直接转交malloc。注意一个细节因为块大小是对齐过的分级池的问题在于内部碎片率。100字节请求用128字节块浪费21%。这是用空间换时间的必然代价取舍标准就一条平均请求大小和池子级别的匹配度。如果业务请求集中在(64,80]区间把64和128之间加一个96级别碎片率就大幅下降。所以分级策略不能死板要结合业务统计来定。4.2 批量接口减少锁交互在高频通道上一次分配一个块效率其实还可以但对于一帧内要创建上千个对象的场景逐块分配会有频繁的链表操作。一种优化是提供批量分配接口一次从池子头节点截取N个连续块返回给调用方。这减少了重复的指针操作更重要的是在并发模式下减少了访问全局池的次数。void *pool_alloc_batch(mem_pool *pool, size_t n) { free_node *first pool-free_list; free_node *cur first; for (size_t i 1; i n cur; i) { cur cur-next; } if (!cur) return NULL; // 剩余块不够 pool-free_list cur-next; cur-next NULL; return first; }这个接口返回一段连续的地址区域对CPU缓存也很友好。对象数组按顺序访问时cache命中率比分散的块高很多。4.3 线程本地缓存降低锁竞争多线程场景下全局free list是共享资源需要一个锁保护。锁竞争一多内存池又变成了瓶颈。TCMalloc和jemalloc的思路是每个线程搞一个本地缓存。线程内分配时先看本地池本地不足才从全局池批量搬一批到本地释放时先还到本地池本地池超过容量阈值再把一批归还全局池。这个思路可以用一句话概括把全局锁竞争从“每次分配”降低为“每N次分配”。因为线程本地操作完全无锁。线程从不“归还”属于它的空闲块只剩下真正无人的块才回流全局。这是高性能内存池的标配设计。我自己实现过一版带线程本地缓存的池4线程压测成绩比单锁版本提升大约3倍而且扩展性更好线程越多优势越明显。代价是内存占用会稍高因为每个线程本地池都有保留块。4.4 定长池的回收策略池子建好之后内存就一直抱着不放了很多时候这是可接受的因为池子预期常驻。但有些场景需要把长期未用的整片连续块还给系统。回收策略需要记住哪些块是连续的。一个简单办法是给池子记录块编号释放时维护区间当一个连续大区间全部空闲时可以尝试madvise(MADV_DONTNEED)或munmap。复杂的做法是分代回收按时间戳把空闲超过阈值的块批量释放。如果池子内存长期不释放会导致两个问题一是系统内存被占用二是池子膨胀。客户端程序很多人会忽略这个但是服务端程序一定要考虑你的常驻内存里可能躺着大量“已释放但未归还”的块。5. 常见问题与排查技巧实录内存池写出来不难写出问题几分钟就能让你崩溃。我把踩过的坑和排查经验整理一下。5.1 内存越界写导致的“幽灵bug”最经典的问题池子分配出去的内存块大小是固定的结果使用方越界写了一段把下一个块的next指针覆盖了。表面症状很随机分配出来一块内存内容莫名损坏释放时池子free list指针断裂程序崩溃。排查手段用两个思路。一是开发阶段给每个块的头部填一个magic number分配时校验magic是否完好释放时再校验一次能在越界发生时立刻暴露。二是用AddressSanitizer直接编译运行定位到具体写入位置。我强烈建议内存池在发布版本里保留一个debug开关随时可以打开校验不然线上出了问题根本没法定位。5.2 释放无效指针链断全局前面说过释放不属于池子的指针会把链搞乱。这种问题防不胜防尤其是当代码里混用了malloc和池子分配。我的习惯是给池子的分配接口命名为pool_malloc释放接口命名为pool_free强制让使用者通过名字分清来源。同时在release模式里保留指针范围校验虽然多一次比较但换来的是不会因为误释放导致链断裂。范围校验的成本大约每百万次释放几毫秒完全可以接受。5.3 池子耗尽后怎么办分两种情况处理。如果池子耗尽最简单的做法是返回NULL让调用方等待或走fallback路径。但这要求调用方写了错误处理逻辑。很多业务代码偷懒不查返回值于是拿到NULL后当你写数据直接段错误。另一种做法是池子支持“自动扩展”耗尽时重新申请一个更大的大块切块并入free list。这种设计要特别注意池子销毁时把所有大块都释放干净。我个人的建议是初始化阶段把池子的容量算够核心链路用的池宁可多配一点减少运行期扩展。运行期扩展相当于动态加内存对实时敏感系统是个不稳定因素。5.4 内存对齐与缓存行问题对齐不仅影响CPU访问性能在嵌入式场景还可能是硬性要求。很多ARM平台要求某些内存地址必须4字节或8字节对齐否则触发总线错误。缓存行问题更微妙。如果你把多个频繁改动的变量放在同一缓存行多核环境下会导致伪共享false sharing性能断崖下跌。内存池本身的free list表头也要考虑对齐最好是让每个池的控制结构独占一个缓存行。这个知识点很冷门但一旦命中就是几十倍的性能差。在高并发多线程环境下控制结构布局一定要考虑缓存友好性。我当时做这个优化后并发压测从约5万QPS提升到8万以上过程就是调整了控制结构布局。6. 从内存池到通用内存体系的扩展思路写完了基础实现和进阶优化我想再谈谈内存池思想的外延。它不仅仅是一个独立组件很多大系统里都藏着内存池的影子。6.1 对象池与内存池的区别内存池关注的是原始字节的分配回收对象池则更进一步它关心的是对象生命周期。对象池分配时可能还要调用构造函数释放时调用析构函数。但底层存储大多覆盖着内存池思想。游戏开发里常见的实体池、连接池、线程池本质上都是“复用昂贵资源”的同一个套路。理解了内存池就等于理解了一整类池化设计。6.2 分配器模式与自定义高级接口你可以把内存池接入自定义的分配器替换全局的new/delete或者在STL容器里指定allocator让所有容器的内存都走池子。我就是这样操作的先封装一个alloc接口符合STL分配器规范然后让高频容器传入它作为模板参数。这样改动很小收益却很大因为STL容器内部的动态管理被统一接管了。要做这个拓展建议先从std::vector和std::list两类容器开始它们是横跨各个业务的最常见容器改造成本低见效快。6.3 性能优化的原则复盘回看整个优化过程有几个原则是通用的先测量再优化。不要凭感觉说malloc慢揪出热点再动手。从简单方案开始。定长池解决80%的问题不要一上来就上无锁并发池。预留扩展点。池化接口抽象好后续换分级池、线程本地池都比较平滑。宁可内存多一点不要复杂度失控。内存池属于底层组件出问题排查成本极高。我在多个项目里反复印证过这些原则。那些“最好的方案”往往不是一开始就能想到的而是从简单方案里一步步演进来的。7. 写在最后的经验说句实在话内存池这个技术门槛并不高真正难的是怎么为项目做合理的选型、设计合适的规模、预判内存碎片走向以及排查运行时那些诡异的内存损坏问题。我的建议是初学者先不要碰太复杂的分级池拿定长池练手写一遍、做基准测试、故意制造内存越界观察现象吃透了再往多线程方向走。如果你已经有一定的系统编程经验就可以从线程本地缓存入手这也是目前各家通用分配器最核心的优化点。踩过几次坑之后我才意识到内存池不是一个孤立的工具它是一整套“资源管理思想”的具体体现。用好了内存池你会对程序的时间开销和空间开销产生全新的敏感度——这是一种值得长期打磨的系统感知力。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑