我最初接触自定义分配器,是为了解决一个极其现实的问题:一个多线程服务器进程,在长时间的请求处理中,内存碎片率超过了30%,明明还有几百兆空闲内存,却依旧出现大块内存分配失败的情况。当时的进程里到处是 malloc 和 new 的调用,性能分析显示,分配器内部的锁竞争占了总CPU的12%以上。单纯调整 glibc 的 MALLOC_ARENA_MAX 或者 M_MMAP_THRESHOLD 只能缓解症状,没法根治碎片化和锁竞争这两个相互纠缠的痛点。
那之后我做了一个不算激进的决定:只在一条关键热路径里,把频繁创建的短生命周期对象池换成了自定义的池分配器。结果令人震惊,不仅分配耗时下降了五倍,内存增长曲线也平滑了很多。这次实战让我意识到,自定义分配器并不是什么高不可攀的底层魔法,它只是一套“按需分配”的工程手段。如果你也正被内存碎片、分配延迟或者锁竞争问题困扰,这篇文章可能会给你提供一条可执行、可验证的路线。
1. 为什么默认分配器解决不了这类问题
绝大多数人遇到的性能瓶颈,不是 malloc 本身“慢”,而是默认分配器在通用场景下的妥协太多。这个理解非常重要,因为如果没弄明白通用分配器为什么慢、为什么碎,后面的自定义方案就无从谈起。
1.1 通用分配器身上的历史包袱
malloc 或 new 的设计目标,是在任何程序、任何分配模式、任何线程数量下都能“勉强可用”。这个通用目标决定了它必须面对几个硬约束。
首先是任意顺序释放。用户可能在任意时刻释放任意大小的内存块,并且不会保证释放顺序与分配顺序有任何关系。为了支持这种高度自由的语义,通用分配器需要维护复杂的空闲块管理结构,常见的是自由链表数组加上大小类分级。
其次是任意大小分配。从16字节的小对象到1GB的大块内存,同一个分配器都必须能处理。为此,分配器内部通常将内存块按照大小分桶,每个桶维护一个空闲列表。当请求大小不在合适的桶里时,还会向下取整、向上取整,产生内部碎片。
第三是多线程安全。默认分配器必须服务于所有线程。无论是 glibc 的 ptmalloc 还是 jemalloc,都会为每个线程维护本地缓存(thread-local cache),然后在全局空闲链或区域之间进行平衡。但缓存的大小、过期时间、合并策略,都是全局调优的结果,不是针对你当前应用调参的。
这些约束叠加起来,导致默认分配器在大部分场景中表现平庸:它要应付最坏情况,所以每个分配动作都要做更多的链表遍历、缓存检查、锁尝试。
1.2 热路径上的隐性代价:锁、二次遍历与伪共享
我实际测过一个网关程序,核心循环里每秒要分配并释放约8万个细粒度小对象,每个对象大小在64字节到128字节之间。用 perf 抓热点,发现 _int_malloc 和 _int_free 这两个函数占到了CPU的6.8%。这还只是分配器本身的消耗,没有算上因为内存碎片导致更多的页错误,以及因为对象分布在不同的cacheline上带来的缓存缺失。
问题的另一面是锁。即便 glibc 在较新版本里做了 per-thread cache,当线程数量增多、分配请求交织时,全局管理结构上的锁冲突依然会出现。jemalloc 在这块做得更好,但也不是零成本,它依赖更细粒度的锁和更复杂的状态机。我见过一个极端情况,某服务在48核机器上跑,有40个线程同时触发分配,结果 jemalloc 内部锁的 spin 时间占到了总CPU的15%。用自定义分配器把这条路径改成无锁池后,直接省下了一整颗核的开销。
碎片化就更直观了。默认分配器的自由块是按地址连续存储的,一旦对象交错分配和释放,地址空间就会被切得支离破碎。比如先分配了100个A对象,然后每隔一个释放一个,再在这些空洞里分配B和C对象,几次循环之后,空闲空间虽然总量足够,但找不到一个连续的区域来满足一个大型缓冲区的请求。这种情况在长连接、长期运行的服务里几乎必然出现。
1.3 自定义分配器的核心优势:语义收窄
自定义分配器能解决这些问题,不是因为它的代码写得比 malloc 更妙,而是因为它把“通用”换成了“专用”。当你的分配模式具备以下任一特征时,自定义分配器就能带来质的提升:
- 分配对象大小基本固定,或者集中在几个尺寸。
- 对象的生命周期与某个逻辑边界强相关,比如一个请求、一个帧、一个事务。
- 分配和释放发生在同一个线程内,或者至少可以由单一线程集中管理。
- 需要极低的分配延迟,比如每帧执行百万次分配。
满足这些特征后,分配器就可以放弃“任意顺序释放”“任意大小分配”这些通用约束,简化内部结构,从而实现高性能和低碎片。最简单的例子就是对象池:提前分配好一块连续内存,按固定大小切好块,分配时只需取出一个空闲块,释放时把块放回去,整个过程没有系统调用、没有链表遍历、没有锁。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 自定义分配器的几种常见形态与选型思路
在动手写代码前,你得先搞清楚自己属于哪一类使用场景。自定义分配器不是只有一种“终极形态”,我实际用过和看过几种不同的方案,各有各的适用边界。
2.1 线性/竞技场分配器(Arena Allocator)
这是最经典的一类。分配器一次性从系统申请一块大内存(比如16MB或64MB),内部只维护一个简单的偏移指针。每次分配时,指针向后移动请求的大小,并做对齐;没有“释放单个对象”的概念,要么全部保留,要么在某个检查点一次性重置偏移。
这种设计的天花板很低:分配只是移动几个寄存器,没有任何链表操作。代价是单对象释放成本很高,只能在一个“阶段”结束后统一重置。适用场景很明确:编译器的中间表示、帧内存、请求生命周期内的临时对象。
我曾经在一款图像处理工具里,把每帧的临时缓冲区全部放在一个arena里,帧结束直接reset。这个修改让每秒帧数(FPS)从57提升到了63,原因很简单:传统的 malloc/free 每一次都会触发元数据读写,而arena的reset只需要改一个整数。
选型时要特别注意:如果单个对象生命周期跨越了arena的重置点,或者存在长期持有的指针,那arena就不是好选择。这类分配器对“谁拥有谁销毁”的关系要求非常严格。
2.2 对象池分配器(Pool / Free-list Allocator)
对象池是另一个常见的形态。它在初始化时把一大块连续内存划分为等大小的slot,然后用一个单向链表把空闲slot串起来。分配时从链表头取一个slot,释放时把slot插回链表头。分配和释放都是O(1),并且因为物理地址是预先固定好的,缓存局部性极佳。
对象池的变种很多:
- 大小分级池:为多个固定大小(如16字节、32字节
