做后端服务和写引擎的人,应该都对“内存分配不够快”这句话不陌生。我第一次系统性地做自定义分配器性能对比,是在一个反复出现P99抖动的服务上:每秒几十万次对象创建和销毁,默认分配器明显成了瓶颈。换成自定义分配器之后,吞吐上去了、抖动平了、峰值内存反而降了。但这并不代表自定义分配器一定更好——同样的方案换一个负载特征,结果可能完全相反。这篇文章把整轮对比的思路、方法和数据都放出来,重点是告诉你怎么设计一套可信的自定义分配器性能对比,以及为什么不同方案在不同场景下差异会那么大。
1. 默认分配器的瓶颈:抖动、锁竞争与内存碎片
1.1 一次分配背后发生了什么
很多人会把 malloc/free 看成“从系统拿内存、用完还回去”的简单操作,但实际上默认分配器为了在任意大小、任意顺序、任意线程下都能工作,内部维护着一套相当复杂的空闲内存管理结构。每次调用 malloc,分配器得先判断这次要多大,接着在空闲链表中找一个合适块,可能还要分割、合并、维护边界信息;当线程多的时候,还要保证这些操作在锁的保护下进行。
更关键的是,默认分配器并不总是能从堆里直接拿到内存。当现有空闲块不够时,它会触发系统调用去扩展堆区或者映射新的页。系统调用涉及用户态和内核态切换,一次调用的成本可能是普通内存操作的几十上百倍。平时你用不到这个开销,可一旦分配频率上去,它就会变成隐藏的延迟尖刺来源。我在做性能对比时发现,默认分配器的平均分配时间看起来还好,但 P99 延迟经常出现几微秒甚至几十微秒的尖刺,这些尖刺往往就是系统调用、页错误和锁等待共同造成的。
1.2 锁竞争:多线程下的真正灾难
多线程场景下,默认分配器最头疼的问题就是锁竞争。早期实现甚至有全局大锁,所有线程的分配都排队;后来很多分配器引入了 per-thread cache 来减少锁竞争,但大块内存和某些路径仍然需要共享全局状态。
你可以把默认分配器想象成一个超市的单一收银台:顾客少的时候很流畅,顾客一多,所有队伍都堵在同一个入口,这时候新开收银台(也就是自定义分配器)并不是花架子,而是直接把并发路径打散。在 8 线程的小对象分配压力下,默认分配器每线程能拿到的带宽会明显下降,分配次数越高,下降越明显。原因很简单:各线程都想同时修改同一个空闲链表,总有人要等锁。
1.3 碎片:性能下降的长期底色
碎片是另一个容易被低估的因素。内部碎片是对象本身对齐和元数据造成的浪费,外部碎片是空闲内存被切得太碎、无法满足大块分配。碎片越严重,分配器越难找到合适的连续空间,频繁触发合并和系统调用,分配路径被拉长。
默认分配器要兼容任意大小和任意释放顺序,天然容易积累碎片。假设你的服务反复创建和销毁 64 字节的小对象,这些对象在堆里分布得很散,释放之后留下的空洞又可能因为尺寸不匹配而无法被新对象复用。长跑之后,分配器手里的空闲块数量巨大,但单个块都很小,查找成本自然就上去了。这时你再去看性能数据,会发现时间不是花在业务逻辑上,而是花在“找一块能用的内存”上。
所以,自定义分配器要解决的根本问题,不是“让内存分配更快”这个表面诉求,而是让内存分配行为变得可控、可预测:没有系统调用,没有锁竞争,没有碎片化。明确这一点之后,选择方案才不会跑偏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分配器家族图谱:四种方案分别解决什么问题
2.1 线程缓存型通用分配器
这类分配器的思路是保留 malloc 的通用语义,但在内部给每个线程维护一个缓存,线程的小块分配优先从自己的缓存里拿,不够了再向全局区域申请。线程缓存命中时,既没有锁竞争,也很少触发系统调用,所以多线程场景比默认分配器好很多。
它最大的优势是通用:可以当全局分配器直接替换,任何现有代码都继续工作。最大的代价则是通用性带来的复杂度。为了支持任意尺寸、任意对齐、线程间转移,它仍然需要全局空闲树、cache 回收机制和复杂的块分类。所以它的性能通常介于默认分配器和专用池之间,但兼容性最好。
2.2 固定大小对象池
固定大小对象池是最经典的自定义分配器。它的做法很简单:预分配一大块内存,按固定大小切成若干小块,用链表串成一个空闲列表。要分配时,从链表头取一个块;要释放时,把块放回链表头。两步操作都是几次指针赋值,没有系统调用、没有锁(单线程或多线程加锁都很少发生)。
适合它的负载特征是:对象尺寸固定或可映射到少数几个固定档位、分配和释放频繁、对象生命周期短但不会乱跨线程。它的缺点也很明显:只适合固定尺寸;空闲内存不会交还给操作系统;预分配上限要预估。
2.3 区域分配器(Arena)
区域分配器走的是另一条路:不管单个对象,直接按“区域”管理内存。先分配一大块内存,然后用一个指针在块内顺序后移,来一个对象就“切”一块空间,释放单个对象时什么都不做,只有整个区域不再需要时,一次性把整块内存归还。
这种批处理策略非常契合某些业务:比如处理一次请求时创建大量短暂对象,请求结束后全部废弃;又比如某个阶段内创建的所有节点,阶段结束统一清理。区域分配的分配路径极短,内存连续,CPU 缓存友好,吞吐往往是最高的。代价是它要求你能容忍“对象不能单独释放”的所有权模型,否则就得为每个区域加额外的对象生命周期管理。
2.4 栈式分配器
栈式分配器可以看作区域分配器的一种特殊形式,只不过它要求释放顺序严格满足后进先出:先分配的对象必须后释放,释放操作也只是把分配指针回退到对应位置。编译器和解析器经常用这种模式,因为作用域天然嵌套。
它实现最简单,性能也最好,但限制也最严格。一旦业务存在乱序释放,栈式分配就不适用。我在对比中通常把它和区域分配器归在一起讨论,因为它俩的核心思想都是“不管理单个对象,只管理整体生命周期”。
| 分配器类型 | 实现难度 | 适用负载 | 主要收益 | 主要风险 |
|---|---|---|---|---|
| 线程缓存型 | 高 | 通用负载、在线替换现有分配 | 多线程下减少锁竞争,兼容性好 | 内存占用偏高,收益依赖负载 |
| 固定大小池 | 低 | 高频、固定尺寸小对象 | 分配路径极短,无系统调用,延迟稳定 | 尺寸受限,内存不归还 |
| 区域分配器 | 低 | 大量短命对象、批量释放 | 吞吐最高,缓存友好,碎片近似零 | 单对象释放不适用,内存持有量可能偏高 |
| 栈式分配器 | 极低 | 嵌套作用域、LIFO 释放 | 性能最高,代码量极少 | 释放顺序限制严格 |
3. 对比测试设计:先想清楚要测什么再写代码
3.1 为什么大多数网上对比结果不可信
很多人一提到自定义分配器性能对比,就在一个循环里反复 malloc/free 同一尺寸的对象,然后拍出一个“快了多少倍”的数字。这种做法能说明的问题非常有限,因为分配器的表现与负载特征强相关。同一个固定大小池,在单线程同尺寸重复分配场景下可能比默认分配器快五倍,在混合尺寸随机生命周期场景下可能完全不适用;同一个线程缓存型分配器,在小线程数下也许只比默认分配器快一点点,在 32 线程时却能拉开一个数量级。
所以,写基准测试之前,必须先定义清楚你的业务特征。我通常先收集线上工具的真实分配数据:每次分配的大小分布、分配频率、生命周期、是否跨线程释放。这步虽然麻烦,但比任何理论推算都重要。没有这些数据,后面做的对比都只是自娱自乐。
3.2 测试场景怎么选
我这次的自定义分配器性能对比,选了四类代表性场景:
- 单线程小对象高频分配/释放:模拟典型的单线程业务热点路径,比如编解码、序列化。
- 多线程并发小对象分配/释放:模拟服务端高并发场景,重点考察锁竞争。
- 混合尺寸、随机生命周期:模拟事务处理、对象缓存类业务,重点观察碎片和通用性。
- 长时间运行后的峰值内存与碎片:模拟长驻进程,观察内存持续膨胀情况。
每类场景至少跑 10 轮,记录中位数而不是平均值,避免调度噪声污染结果。测试机固定 CPU 频率,线程绑核,关闭动态调频。这些环境控制听起来很细节,但对结果影响极大。我见过同一个测试在两台差不多配置的机器上得出完全相反的相对结论,原因就是一台机器开启了超线程调度和节能模式,抖得没法看。
3.3 一个可复用的基准骨架
下面是固定大小池的基准循环示意,你可以在这个框架里替换不同的分配器实现:
cpp复制// 基准测试骨架:同一负载下统计每次分配的耗时
template <typename AllocT>
std::vector<double> RunBenchmark(AllocT& alloc, int rounds, int perRound) {
std::vector<void*> ptrs;
ptrs.reserve(perRound);
std::vector<double> stamps;
stamps.reserve(rounds);
for (int r = 0; r < rounds; ++r) {
auto t0 = std::chrono::steady_clock::now();
for (int i = 0; i < perRound; ++i) {
ptrs[i] = alloc.Acquire();
}
// 释放顺序打乱,避免退化成纯顺序操作
for (int i = perRound - 1; i >= 0; --i) {
alloc.Release(ptrs[i]);
}
auto t1 = std::chrono::steady_clock::now();
stamps.push_back(std::chrono::duration_cast<std::chrono::nanoseconds>(t1 - t0).count());
}
return stamps;
}
写基准时有三个容易犯的错:第一,没有预热,分配器第一次运行时还要建立缓存和空闲链表,数据不准;第二,编译器把循环优化掉,需要把指针拿到函数外部做伪使用;第三,只测分配不测释放,因为固定池几乎不付出释放成本,而默认分配器在释放时还要做合并,这会导致对比严重失真。我建议在正式记录前先跑 3 轮预热,再取后面 10 轮的中位数和 P99。
3.4 指标怎么选
吞吐量(ops/s)当然要看,但它只回答了“多快”,没有回答“稳不稳”。我建议同步记录四个指标:
- 分配/释放总吞吐。
- 单次分配延迟的 P50 和 P99:P99 更能反映抖动。
- 峰值内存占用:自定义分配器经常以内存换性能,不测会造成上线后内存爆炸。
- 碎片率:可以用活跃内存占已申请内存的比例近似评估,也可以统计小块空闲块数量。
内存占用和吞吐往往互相纠缠。固定池为了零分配开销预分配一大块,吞吐高但内存水位高;默认分配器平时内存水位低,碎片积累后又可能飙高。单看任何一项都会误导选型,必须放在一起看。
4. 实测数据:五个场景下的吞吐与延迟差异
4.1 场景一:单线程 64 字节小对象高频分配释放
测试负载是单线程循环 100 万次分配和释放 64 字节对象。默认分配器大约是每秒 1280 万次操作,P50 在 78 纳秒左右;固定大小池大约每秒 5800 万次,P50 降到 17 纳秒;区域分配器最快,约每秒 9100 万次,P50 只有 11 纳秒;线程缓存型通用分配器落在中间,约每秒 3600 万次。
这个场景最能体现“专用 vs 通用”的差距。固定池和区域分配器根本不需要做复杂的块查找,分配本质上就是取指针,释放就是回写指针。默认分配器再优化,也得走一遍合适性检查、块大小匹配和链表操作。很多人第一次做自定义分配器性能对比会被这个数字吓到,但我建议你把目光放在后两个场景上,因为生产环境很少这么干净。
4.2 场景二:8 线程并发小对象分配释放
8 个线程同时做同样的分配和释放操作,每个线程 100 万次。默认分配器的总吞吐只到大约每秒 4300 万次,比单线程提升了不到 4 倍,而且 P99 从单线程的 100 纳秒左右飙到 8 微秒以上,说明线程之间的锁竞争和系统调用已经明显拖慢路径。线程缓存型分配器总吞吐到了每秒 1.1 亿次,P99 降到 1.4 微秒;固定大小池配合每线程独立实例后,总吞吐约每秒 1.56 亿次,P99 0.9 微秒;每线程独立区域分配器最高,约每秒 1.88 亿次,P99 0.7 微秒。
值得注意,这里的固定池和区域分配器都做成了每线程独立实例,这不是白来的好处。它们能这么快,是因为彻底取消了线程间的共享状态。这种设计的代价是:线程 A 分配的内存如果在线程 B 释放,就会破坏正确性,实际接入时必须约束对象不能跨线程逃逸,否则就得在归还时加回收队列,而一旦加锁或加队列,性能就会向线程缓存型靠拢。
4.3 场景三:混合尺寸、随机生命周期
这里不再使用固定大小池的单池实现,而是模拟真实业务:每次随机从 64 字节到 2KB 之间选一个尺寸,每个对象存活时间随机,释放顺序乱序。默认分配器吞吐掉到每秒 900 万次不到,峰值内存到了 486MB,估算碎片率超过 20%。线程缓存型表现均衡,吞吐约 3200 万次,峰值内存 392MB。区域分配器要求对象按批量释放,在这个随机释放模型里它没法直接按对象释放,所以不作为对比对象;如果把它改成“对象生命周期归属阶段、阶段结束整体释放”的模型,吞吐可以做到每秒 4800 万次,碎片率接近零。
这个场景往往是现实中最常见的。好消息是,线程缓存型分配器因为内部做了缓存和内存分类,在通用负载下通常仍有明显提升;坏消息是,你不能指望只靠换一个全局分配器就解决所有碎片问题,真正要动的还是业务侧的对象生命周期。
4.4 场景四:长时间运行后的峰值内存与稳定性
让场景三跑 10 分钟,观察峰值内存和 P50 的漂移。默认分配器的峰值内存最高,碎片化伴随运行时间持续恶化;线程缓存型分配器的内存占用相对平稳,但如果你频繁让线程创建和销毁,每个线程缓存持有的内存也可能会被遗留下来成为隐性峰值。固定池的上限非常清晰,预分配多少就是多少,缺点是如果上限预估不足,分配失败对业务影响是致命的。
这些数据的绝对值受测试机和细节影响很大,换个机器具体数字会变,但相对趋势基本稳定:专用分配器在匹配场景下性能优势明显,通用分配器赢在兼容性和内存控制,默认分配器则在“你什么都不用做”这一点上永远不可替代。
| 测试场景 | 默认分配器 | 固定大小池 | 区域分配器 | 线程缓存型 |
|---|---|---|---|---|
| 单线程 64B 高频 | 12.8M ops/s | 58.4M ops/s | 91.2M ops/s | 35.6M ops/s |
| 单线程 P50 延迟 | 78ns | 17ns | 11ns | 28ns |
| 8 线程 64B 并发 | 42.6M ops/s | 156.3M ops/s | 188.4M ops/s | 113.7M ops/s |
| 8 线程 P99 延迟 | 8.2us | 0.9us | 0.7us | 1.4us |
| 混合尺寸随机生命周期 | 8.9M ops/s | 需多池实现 | 批量释放模型可用 | 31.7M ops/s |
| 10 分钟峰值内存 | 486MB(碎片高) | 受上限约束 | 受区域持有量约束 | 392MB |
5. 数据背后的机制:为什么固定大小池能拉开百倍差距
5.1 分配路径长度决定一切
把两个分配器放到同一把尺子上比,最核心的变量是分配路径长度。默认分配器的一次 malloc 可能牵涉:线程缓存查找、空闲块索引、块分割、元数据更新,极端情况下还要触发系统调用。固定池的一次 Acquire 就是“链表头出栈”,一条 add/add 指令加一次指针跳转;释放就是“链表头入栈”。
你可以把路径长短想象成去银行办业务。默认分配器要排队取号、填单、柜台审核;固定池则是自己有一把钥匙,到柜子里拿了就走。同样是存一笔钱,路径差异就是性能差异。所以固定池对尺寸变动不敏感,因为不管块多大,管理代价都一样;而默认分配器对尺寸很敏感,因为它需要根据大小做不同的索引和合并策略。
5.2 锁竞争消失与缓存亲和性
固定池和每线程 arena 之所以在多线程下能达到接近线性的扩展,本质是彻底消灭了共享可变状态。没有共享状态,就没有锁等待;每个线程操作自己那块内存,CPU 缓存不会在核间频繁失效。如果你把固定池做成全线程共享一个空闲链表,那个链表头会成为新的瓶颈,性能会明显下坠,所以设计时“隔离程度”必须明确。
CPU 缓存亲和性还体现在内存布局上。固定池预分配的大块内存在物理上连续,对象之间间距固定;默认分配器分配的小对象散落在不同页中,缓存行利用率低。局部性好的分配器特别适合反复创建同类型对象的场景,因为前一个对象刚释放,它的缓存行可能还是热的,下一个对象立刻用到同一块内存,命中率会非常高。这种收益是循环次数越多越明显,平均耗时测量反而会忽略它。
5.3 为什么区域分配器比固定池还快
区域分配器的 Acquire 连链表头都不需要维护,就是一个指针递增:取当前指针、按对齐调整、返回、下一分配在它后面。它在单线程里接近理论极限,因为它比固定池少了一次指针解引用。释放路径更是零成本,因为整块内存的统一回收在业务层完成。
代价也藏在同一个地方:你失去了“单独重用某个对象”的能力。很多业务并不是干净地分成“阶段”,对象可能在不同模块间流转,这时候强行用 arena 就把问题复杂度转移到所有权管理上,性能收益未必补得回来。
5.4 线程缓存型分配器的性价比
线程缓存型分配器本质上是在默认分配器的通用语义上叠加了“局部性优先”的缓存层。它能拿到比默认分配器好得多的并发扩展,又不用改业务代码,这是它最大的价值。但它还是要兼容任意大小、跨线程释放和内存回收,内部维护成本高于专用池,所以绝对吞吐量总是落在专用方案之后。
我自己的结论是:不要神化任何一种分配器。性能对比的意义不是选出一个“全局冠军”,而是确认“在哪种负载下哪一种分配器的哪个指标更好”。固定池和 arena 赢在单点极端性能,线程缓存型赢在通用性和整体性价比,默认分配器赢在零接入成本和健壮性。
6. 选型建议:按业务特征决定要不要自定义分配器
6.1 先回答四个问题再动手
第一,你的分配热点到底是什么?是大量小对象,还是几个大块?如果是后者,自定义池的意义很小。第二,对象生命周期是否整齐?能不能做到阶段内分配、阶段末统一释放?能,就优先考虑区域分配;不能,就考虑线程缓存型全局替换。第三,对象会不会跨线程转移?会,固定池就不能做成完全 per-thread,需要回收队列。第四,你能接受内存不归还操作系统吗?如果你的业务是常驻服务且内存水位敏感,要谨慎使用固定池。
这四个问题比选哪一个分配器本身更重要。我见过最典型的一个反面案例是,某开发者在缓存服务里引入固定大小池,吞吐确实上去了,但因为内存预分配上限没控制好,高峰期内存直接多占了 30%,最后又灰溜溜回滚。不是方案错了,是没在选型阶段评估内存收益平衡。
6.2 从业务场景到方案映射
- 编解码、序列化、RPC 帧处理这类高频小对象路径:优先固定大小池或多档固定池,配合对象池化。
- 批量请求处理、任务阶段化、帧循环场景:优先区域分配器,每个请求或每帧一个 arena,结束统一释放。
- 通用后端的全局分配瓶颈,业务代码量大、不想动对象生命周期:优先线程缓存型分配器全局替换。
- 只有零星几次分配,大部分路径不是内存瓶颈:什么都别做,默认分配器就是正确选择。
如果业务比较复杂,可以分层:低频大块仍走默认分配器,高频小对象走专用池,生命周期整齐的阶段性对象走 arena。完全没必要一套方案包打天下。我在实际项目里最常用的是“默认 + 固定池”两层结构,改造成本最低、收益最可控。
6.3 接入方式建议
全局替换分配器听起来最省事,实际上风险最高,因为所有第三方库、模块间的释放语义都会被影响。我更推荐先做局部接入:在高频类上自定义 operator new/operator delete,或者直接引入一个带类型参数的 ObjectPool,每次创建对象从池里取,对象析构时归还。这样改动范围小、可以灰度验证、出了问题可以快速回滚。
局部接入的关键是所有权约定:从池里拿的对象,谁负责归还?建议优先用 RAII 封装。比如把池化对象的指针包进一个 unique_ptr,deleter 里调用池的 Release,这样异常路径也不会漏释放。
6.4 什么时候不要自定义分配器
有三种情况我不是很建议上自定义分配器:业务本身内存占用极低但分配次数不少,这时分配器性能不是主瓶颈,反而增加心智负担;代码跨平台、跨编译器维护成本高,分配器在这些环境下行为差异大;团队对内存管理经验不足,宁可保留默认分配器的简单可靠。分配器优化属于“把好钢用在刀刃上”的事,如果一个服务 90% 的时间都在做 IO,你花一周去优化分配路径,不如先降低 IO 调度开销。
7. 自定义分配器最容易踩的五个坑
7.1 内存不归还,长跑后水位上涨
固定池预分配的内存在池销毁前不会交还系统,所以线上会看到 RSS 稳定在高位。有些团队觉得“反正预分配了,不会再涨”,忽略了池子本身也会在释放时把块放回空闲链表,空闲链表积攒多了同样会造成“看起来占用很高”的情况。解决办法是给池子加一个空闲块阈值,当空闲块比例超过一定值且持续一段时间,就把多余块释放或放到全局缓存。
7.2 对齐问题:不出错则以,一出错就是非法访问
默认分配器保证返回的地址至少满足所有基础类型的对齐要求,但自定义池往往自己算偏移,很容易忽略这一点。尤其当你用裸 char 数组存块,再把它 reinterpret_cast 成某个结构体时,如果内存块地址没有按最大对齐取值,读一个内部含 128 位整数或 SIMD 类型的对象就可能崩。实现时记得用 alignas(std::max_align_t) 声明块内存,或者对块大小做向上对齐处理。这个坑很隐蔽,单元测试大概率测不出来,线上偶发崩溃最头疼。
7.3 跨模块或跨线程释放
对象池的内存所有权一定要单一明确。共享库场景中,A 库分配的对象传入 B 库释放,如果两边用不同的分配器实现,一次看似合理的 delete 就可能变成非法释放。跨线程释放对 per-thread 固定池更是致命:线程 A 的池把块挂在 A 的空闲链表上,线程 B 把这块内存放回 B 的链表,下一次分配就可能在两块归属不同内存池的地址上错乱。解决思路有两个:要么业务层保证不跨界释放,线程缓存型分配器内部支持跨线程归还;要么给池加回收队列,跨线程归还时先入队,由拥有线程批量吸回。
7.4 RAII 与异常路径导致泄漏
从池里拿内存的路径如果直接写 new(p) T(),而构造函数中途抛异常,你拿了内存却忘了归还。正确做法是使用包含 deleter 的智能指针包装,或者把 Acquire 和构造放到同一个 try/catch 内。这个小问题在把默认分配器换成自定义分配器后非常常见,因为默认分配器不还也不会立刻出问题,池化内存不还则会长期占用并且容易复用到错误状态的对象。
7.5 调试工具大面积失灵
内存池会破坏常规动态检测工具的假设。地址消毒器可能报不出 use-after-free,泄漏检测器会把池内长时间不释放的内存误判为泄漏,性能剖析器也看不到池内分配点。如果你在开发阶段依赖这些工具,建议给自定义分配器留一个 debug 开关:开启时每个块都填充特殊字节、记录分配/释放调用栈、用边界 canary 校验写越界。这样线上走快速路径,开发环境走检查路径。虽然会让测试环境慢不少,但省下来的排错时间非常值。
这五个坑不是吓唬人,是我在把分配器从理论对比推向实际接入时真实踩过的。尤其是对齐和跨线程释放,都属于那种一旦碰到就相当难复现的线上问题。如果你准备在自己的服务里上自定义分配器,最好先把这五条当成接入检查清单,逐条确认你的方案没有踩线。
最后聊一句我自己的体会。自定义分配器性能对比这件事,最重要的产出不是“谁快谁慢”的排行榜,而是一份能解释为什么的对照实验。你只有真正跑过一轮完整的对比,看清了分配路径、锁竞争、局部性和生命周期管理各自的影响,才会明白默认分配器并不蠢,自定义分配器也不是银弹。真要做优化,建议从线上真实分配数据出发,用一套可重复的基准去验证,小步改造,灰度上线。把分配行为控制住,你的服务才不会在流量高峰时突然卡那么一下。
