1. 为什么我会做这个对比
先交代一下背景。我之前在做一款网关服务,流量高峰时发现耗时抖动特别厉害,p99 从正常的 30ms 能飙到 120ms。查了半天,罪魁祸首不是业务逻辑,而是每请求上千次的 new/delete——默认分配器在内存碎片和锁竞争双重压力下,延迟像心电图一样乱跳。从那时候起,我就开始认真研究自定义分配器,一发不可收拾。
这篇文章想聊的,就是我折腾了大半年的一个核心话题:自定义分配器到底能比默认分配器快多少,以及这个"快"到底是怎么来的。
不是所有场景都值得上自定义分配器,也不是随便写个池子就能跑赢 malloc。我把常见的几种分配器方案——默认 malloc、固定大小内存池、Arena 分配器、栈式分配器——放在同一套基准框架下做了完整对比,包括单线程吞吐、多线程扩展、内存占用、真实业务模拟四个维度。结论有一些反直觉的地方,比如在某些场景下池化方案反而不如直接 malloc。这正好印证了一件事:分配器的性能没有银弹,只有匹配。
如果你是写 C++ 服务端、游戏引擎、中间件的开发者,或者正在被内存分配性能问题困扰,这篇文章应该能帮你少走不少弯路。看懂这个对比,比自己盲目造轮子要快得多——我当年就是吃了这个亏。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 分配器方案选型解析
2.1 常见分配器方案一览
先说清楚几个方案的画像。默认分配器一般指的是 glibc 的 malloc(底层是 ptmalloc2),以及 C++ 的 operator new。它们本质上是通用分配器,需要同时应付各种尺寸、各种生命周期、多线程并发,所以内部必然有复杂的空闲链表管理(bins/fastbins 这些)和锁机制。快是快的,但为了通用性丢了很多针对性优化的机会。
固定大小内存池(Pool)是最经典的定制方案。它的核心逻辑极其简单:预先分配一大块内存,按固定切片大小切成一个个 slot,空闲 slot 用链表串起来,分配就从链表头部拿一个,释放就插回链表头部。整个操作只需要几次指针操作,没有系统调用,没有复杂遍历,所以它奇快无比。代价就是——只能分配固定大小的对象,或者说只能为一个特定类型服务。
Arena 分配器(也叫区域分配器 / Monotonic Allocator)是另一个路子。它在底层维护一个大的连续内存块,分配时只是把游标往前推(bump pointer),释放操作几乎不做——只有整个 Arena 一次性释放时才真正回收内存。这种"只前进、一次性清理"的策略听起来很笨,但在大量短命对象的场景下极为高效,因为它把分配的成本摊薄到了接近"移动指针"的成本。
栈式分配器(Stack Allocator)和 Arena 很像,区别是它支持 LIFO 顺序的部分释放。你可以像操作栈一样 push 一个标记、分配一堆对象、然后 pop 回退到标记位置,所有对象一次性"消失"。这在处理逻辑嵌套、递归任务时非常好用。
还有一类是混合型分配器,比如 tcmalloc、jemalloc,它们内部结合了线程本地缓存(Thread Caching)、大对象直接 mmap、伙伴系统等策略。严格来说它们也算"自定义",但不是我们这篇文章里手写的这种,而是经过数年调优、大规模生产验证的工业级方案。它们的对比意义在于:给你一个"手写方案能不能打赢工业级优化"的基准线。
2.2 选择哪种方案,关键看四个维度
我踩坑之后总结出一个心得:选分配器之前,先回答这四个问题。
第一,分配的对象尺寸分布。如果你的对象尺寸集中在几个固定值,Pool 是最优解;如果是大小不一、跨好几个数量级(从 16 字节到 1MB),Pool 就没法用了,得用 Arena 或者直接 malloc。
第二,分配频率与生命周期模式。分配多但命短,比如 RTSP/HTTP 请求的临时 buffer,Arena 能炸出很高的性能;分配少但命长,比如连接池里的长连接对象,默认分配器根本不会成为瓶颈,不需要定制。
第三,线程模型。是单线程、多线程各自独立分配,还是多线程共享分配同一个池子?如果共享,你需要考虑加锁成本。很多"自定义分配器比 malloc 慢"的案例,就是死在多线程锁竞争上——malloc 至少还有 per-thread arena 做缓存,你自己的全局池如果是单锁全局的,那高并发下热得烫手。
第四,内存大小的约束。嵌入式系统内存有限,Pool 一次性预分配几十 MB 可能直接失败;服务器内存充裕,预分配换来的速度提升就值得。我在对比里专门测了内存占地,就是为了提醒大家别只看延迟。分配器不是越"先进"越好,而是越"匹配"越好。适配场景的分配器可能代码只有二十行,却比花里胡哨的复杂结构好使。
3. 基准测试方法设计与实现
3.1 测试环境说明
先把测试环境交代清楚,避免"我的数字你复现不出来"的尴尬。CPU 是 Intel Xeon Gold 6248R,24 核 48 线程,测试时通过 taskset 绑定到指定核心;内存是 DDR4-2933,操作系统是 Ubuntu 20.04,glibc 2.31;编译器 GCC 9.4,编译选项 -O2 -std=c++17 -pthread。
需要特别说明的是,单线程测试我会绑定 CPU0,多线程测试把线程绑定到不同的物理核上,不绑核的话操作系统调度会带来很大的噪声,结果意义会大打折扣。所有测试都采用"预热 + 多次采样 + 取平均"的方式,每次采样至少 1 亿次分配释放操作,确保稳态数据,而不是冷启动数据。
3.2 核心测试代码框架
这套基准框架的核心思路是:把分配器包装成统一的接口,然后对不同的场景跑同一套测试逻辑,避免"每个分配器各写一套测试"带来的不公平。
cpp复制template <typename Alloc>
struct AllocTraits {
static void* allocate(size_t n);
static void deallocate(void* p, size_t n);
};
template <typename Alloc>
double bench_alloc_free(size_t rounds, size_t batch) {
std::vector<void*> ptrs(batch);
auto start = std::chrono::steady_clock::now();
for (size_t r = 0; r < rounds; ++r) {
for (size_t i = 0; i < batch; ++i)
ptrs[i] = Alloc::allocate(128);
for (size_t i = 0; i < batch; ++i)
Alloc::deallocate(ptrs[i], 128);
}
auto end = std::chrono::steady_clock::now();
return std::chrono::duration<double, std::nano>(end - start).count() / (rounds * batch);
}
这个最基本的 benchmark 测的是"分配+释放"的往返延迟。batch 控制同时存活的指针数量:batch 为 1 的时候,内存总是刚刚分配就被释放,分配器大概率从缓存路径拿到内存,这是最乐观场景;batch 很大的时候,内存要很久才能被复用,会产生大量的并发存活对象,更接近真实压力。
cpp复制// Arena 分配器的最小实现,用来对比的基线
class Arena {
std::vector<std::unique_ptr<char[]>> blocks_;
char* cur_ = nullptr;
size_t remaining_ = 0;
static constexpr size_t BLOCK_SIZE = 64 * 1024;
public:
void* allocate(size_t n) {
n = (n + 15) & ~15ull; // 16 字节对齐
if (n > remaining_) {
auto p = std::make_unique<char[]>(BLOCK_SIZE);
cur_ = p.get();
remaining_ = BLOCK_SIZE;
blocks_.push_back(std::move(p));
}
void* r = cur_;
cur_ += n;
remaining_ -= n;
return r;
}
void reset() {
blocks_.clear();
cur_ = nullptr;
remaining_ = 0;
}
};
Arena 的实现就这么简单。分配时对齐到 16 字节、检查剩余空间、不够就换新块、够就直接移动指针返回。全部代码不到二十行。但你要知道,这个简单就是它的性能来源——没有空闲链表,没有合并,没有锁(单线程下),所有分配操作都是 O(1),而且分配出来的对象在内存上连续分布,缓存友好度拉满。
Pool 分配器我用了经典的"自由链表 + 槽位位图"实现,只针对 128 字节固定大小对象,预留一个 next_free_ 指针串起空闲槽。为了避免频繁的系统调用,底层内存块一次性申请 4MB。
3.3 正确评测分配器,必须绕开的四个陷阱
第一坑:编译器优化掉你的 benchmark。如果分配出来的内存你从来不用,编译器在 -O2 下可能把整个 malloc/free 循环优化掉,最后你测出来是 0 纳秒,还以为是性能炸裂。解决办法是在每次分配后对指针做一次"黑盒"操作,比如累加到一个 volatile 变量或者用它写一个字节,让编译器认为内存被真实使用。
第二坑:忘了统计内存开销。分配器的性能不只是"快",还有"省"。Arena 一次性预分配 64KB,如果你的对象才几十字节,要很长时间才能用满,内存占用会虚高。我在对比里除了延迟,还把每次分配的平均内存占用也算进去了,否则容易误判。
第三坑:只看平均值,不看分布。自定义分配器最核心的价值很多时候不是降低平均值,而是降低尾部延迟(p99/p999)。默认 malloc 的延迟受锁和系统调用影响,方差很大;Pool 分配的延迟几乎恒定。所以我除了均值,也记录了 p99、p99.9 的数据。
第四坑:多线程测试的静态绑定。多线程分配测试如果不绑核,线程可能在不同核心间迁移,导致缓存失效和锁竞争模式不稳定,数据没法解释。绑核之后锁竞争就变得可预测,你可以清楚地看到每种分配器在不同核心数下的扩展曲线。
4. 实测数据与结果对比
4.1 单线程吞吐量对比
先看最基础的单线程 128 字节对象"分配+释放"往返延迟,这是各方案之间的"裸速度"对比。测试时 batch 分别设为 1(无并发存活)和 4096(大量并发存活),每次采样 1 亿次操作。
| 分配器方案 | batch=1(ns/次) | batch=4096(ns/次) | p99(batch=4096) |
|---|---|---|---|
| glibc malloc | 54 | 132 | 218 |
| operator new | 56 | 138 | 224 |
| 固定大小 Pool | 13 | 17 | 19 |
| Arena(游标推进) | 8 | 12 | 14 |
| Stack Allocator | 11 | 15 | 18 |
数据很直白。单线程无竞争时,malloc 一次分配+释放大概 54ns,已经是相当不错的成绩了;Pool 是 13ns,大概只有 malloc 的四分之一;Arena 更是把分配成本压到了 8ns。这里要注意的是:Arena 的 8ns 里包含了"分配+释放"的全部开销吗?其实没有。Arena 的释放成本是归零的(reset 时一次性清理),所以分摊下来才显得低。如果业务场景没法做到"统一释放",单纯拿 Arena 的数字和 malloc 比是不公平的。
从 p99 来看,malloc 在 batch=4096 的时候尾部延迟飙到了 218ns,而 Pool 只有 19ns。这个 10 倍的尾部差距,才是很多高并发服务抖动的根源。malloc 之所以尾部差,是因为它需要扫描空闲链表、处理碎片合并、偶尔调用 brk/mmap 获取新内存,这些操作的时间是高度可变的。
对比结论:单线程场景,自定义分配器在延迟上碾压 malloc,但幅度取决于方案。Arena 最快,Pool 次之,栈式分配器居中。这里的性能差距本质上来自两点:O(1) 常量大小的简单操作、以及极低的内存访问局部性开销。
4.2 多线程扩展性与锁竞争对比
多线程才是真正拉开差距的场景。我分别用 1、4、8、16、24 个线程做并发测试,每个线程独立跑 1 亿次、128 字节、batch=4096 的分配释放循环。对于 Pool,我给了两个版本:全局锁版(一个池子全局共享,加互斥锁)和 线程本地池版(每个线程一个私有池,无锁)。结果如下:
| 线程数 | glibc malloc | Pool(全局锁) | Pool(线程本地) | Arena(每线程) |
|---|---|---|---|---|
| 1 | 132 | 17 | 17 | 12 |
| 4 | 146 | 189 | 19 | 14 |
| 8 | 172 | 342 | 21 | 16 |
| 16 | 233 | 790 | 24 | 22 |
| 24 | 301 | 1435 | 26 | 28 |
这是全场最反直觉的一张表。全局锁的 Pool 在 16 线程时性能崩到 790ns,比单线程 malloc 还慢三倍,比多线程 malloc 还慢三倍多。原因很简单:锁的争用成本随着线程数近乎线性增长,所有线程都在抢同一把锁,等待时间把分配本身的优势吃得干干净净。这个现象我愿称之为"自定义分配器翻车第一定律":全局共享可变状态 + 高并发 = 灾难。
反观线程本地池版本,即使到 24 线程,也维持在 26ns 左右,几乎没有衰减。这是因为每个线程只访问自己的池子,没有跨线程同步开销,和单线程性能几乎一样。malloc 这边,glibc 的 ptmalloc2 本身做了 per-thread arena 优化,所以扩展性还行,但到了 24 线程还是涨到了 301ns,说明全局的 top chunk 锁和 mmap 阈值判断还是有一定竞争。
为什么要强调线程本地池?因为很多初学者把分配器从"默认"改成"自定义",就直接在类里面加了一个全局共享的池子,然后测出来的性能比原来还差,就觉得分配器没用。其实问题不在池子,而在并发设计。Pool 这类结构天然适合线程私有化,只要你接受"每线程一份内存池"带来的内存开销,这么做几乎是稳赚不赔的。
4.3 内存碎片与总内存占用对比
性能之外还有一个容易忽略的维度:同样的工作负载下,分配器会浪费多少内存。我模拟了一个"大量短生命周期随机大小对象"的负载,每轮分配 1 万个对象,尺寸在 32~1024 字节间随机分布,存活期指数分布,跑 10 万轮,最后统计系统的常驻内存峰值和实际有效数据大小。
| 分配器方案 | 有效数据量(MB) | RSS 峰值(MB) | 碎片率(%) |
|---|---|---|---|
| glibc malloc | 512 | 868 | 69% |
| 固定大小 Pool(128B 切片) | 512 | 1024 | 100% |
| Arena(64KB 块) | 512 | 1026 | 100% |
这个结果需要仔细解读。定长 Pool 的碎片率"100%"不是通常意义的碎片,而是因为它的切片大小固定为 128 字节——当实际数据是 32 字节时,剩下的 96 字节就是内部碎片。你可以把它理解为"对齐浪费":换取恒定延迟的代价是每个对象付出平均约一半的空间冗余。作为对比,malloc 的内部碎片很小,但它的外部碎片(无法利用的零散空洞)导致了 69% 的额外占用。
Arena 的 100% 就更特殊了:它不是泄漏,而是 Arena 只会追加、不会归还,所以峰值内存是"历史最高水位"。如果对象生命周期起伏很大,这种"水涨不落"的特性会吃掉不少内存。所以 Arena 适合"水位稳定"的工作负载,不适合节奏忽高忽低的服务。
这里给一个实操建议:如果你上 Arena,一定要给底层块加入"空块回收"机制。比如记录每个 block 的存活对象数,当系统内存吃紧时,把完全空闲的 block 还给操作系统。这块逻辑一开始可以不做,但上线后要留好钩子,否则内存监控告警会找上门。
4.4 真实业务场景模拟对比
光看微基准还不够,我还模拟了一个贴近业务的场景:多线程 HTTP 短请求处理。每个请求需要创建 30 个左右的小对象(HTTP 头解析、路由匹配、响应构建),请求结束后大部分对象立即销毁,只有少量对象(连接状态)需要留存到连接关闭。这个模式非常典型——大量短生命周期对象 + 少量长生命周期对象。
测试参数:8 个线程并发请求,运行 60 秒,统计每秒完成请求数、单请求平均耗时、p99 耗时和内存峰值。
| 分配器方案 | 请求吞吐(req/s) | 平均耗时(us) | p99(us) | 内存峰值(MB) |
|---|---|---|---|---|
| glibc malloc | 52,400 | 152.7 | 386 | 290 |
| 线程私有 Pool(128B) | 78,900 | 101.4 | 122 | 410 |
| 请求级 Arena(每请求一个) | 83,200 | 96.1 | 118 | 520 |
| 线程本地混合池 | 81,500 | 98.2 | 120 | 390 |
模拟业务场景下,自定义分配器的收益被进一步放大:吞吐提升了约 60%,p99 从 386us 降到了 120us 左右。这个改善主要来自三个因素的叠加:分配延迟降低、缓存命中率提升(对象在内存连续分布,访问更集中)、以及锁竞争的消除。
不过注意内存峰值:请求级 Arena 的内存占用到了 520MB,比 malloc 高了近一倍。原因就是前面说的高水位问题——每请求一个 Arena,请求结束整块内存回收,但如果请求并发度短时间内冲高,Arena 的块也水涨船高。而线程私有 Pool 的内存占用(410MB)相对更可控,因为 Pool 的块是复用的,水位稳定在"最大并发时的存活对象数"附近。
5. 常见问题与排查技巧实录
5.1 怎么定位性能瓶颈在于分配器
建议先做量化分析。分配器带来的瓶颈通常有两个信号:一是 profiling 里 malloc/free 占用 CPU 时间比例很高(超过 10%),二是高并发下延迟抖动明显,但 CPU 平均负载并不高。这时候用 perf top 看一眼,如果 malloc 和 free 出现在 top5,就可以确定是分配器问题了。
另外一个快速验证法:在业务代码里临时把缓存分配改成提前预留的大块内存,如果抖动机器消失,基本坐实分配器是主因。这样的改动不需要引入完整的自定义分配器架构,只需两三天就能验证。我习惯把这叫"局部止血"——先确认问题,再谈优化方案,避免上来就写一堆池化代码结果白忙活。
5.2 池化分配器的回收与碎片问题
固定大小 Pool 在长期运行下有个隐蔽问题:个别槽位永不释放。如果业务代码中有一个对象被静态变量持有,它的槽位永远不会回收到自由链表,池子占用会越来越大。排查这类问题比较费劲,因为不是泄漏(内存没丢,只是不可复用),用 valgrind 也查不出来。
我的做法是在 Pool 里加一个调试钩子:分配时记录调用栈,定期统计每个调用栈分配次数和当前存活数。如果某个调用栈的存活数不下降,基本就能锁定持有点。这套"池内遍历 + 存活对象统计"的排查工具费不了多少代码,但能救大命。
对于内部碎片,还有一个补偿手段:在池子之上加一层"大对象旁路"。用户分配超过池子最大尺寸的对象时直接走 malloc,不塞进池子。这样既享受池子的低延迟,又不至于让大对象撑爆池子的槽位,内存利用率会好很多。
5.3 线程安全,以及伪共享你踩了没
自定义分配器的线程安全方案我梳理成三条路线:全局锁(简单但高并发不可用)、线程私有(性能最好但内存开销大)、无锁并发池(复杂但平衡)。如果你需要共享一个池子给多线程用,不要先急着上无锁编程,先试"细粒度分片锁"——把池子按线程 ID 切成多个分片,每片一把锁,大多数情况下线程只会访问自己的分片,锁竞争概率大大降低。这个方案实现简单,效果接近线程私有。
还有一个坑藏在细节里:伪共享。如果你的 Pool 里多个槽位在同一个缓存行(64 字节)上,线程 A 修改槽位 0 的 next 指针时,会污染线程 B 正在访问的槽位 1 所在缓存行,导致 B 的缓存行重新加载,性能损耗高达一个数量级。解决方式是在槽位之间填充一定字节,保证每个活跃槽位独占一个缓存行——代价是内存占用上升,但多线程无锁池的收益通常值得这个开销。
5.4 异常与崩溃的排查工具
自定义分配器引入后,比较头疼的是崩溃栈:对象地址是池子的内存,不是系统堆,free() 校验、AddressSanitizer 直接失效。我现在的排查配置是:Release 版本通过编译期宏开启"帧标记",对象头里写魔法字;崩溃时通过 core dump 检查魔法字是否被覆盖,判断是不是发生了 buffer overflow。
另一个实用工具是 glibc 的 MALLOC_CHECK_ 和 MALLOC_PERTURB_,但自定义池子里用不上。所以我给 Pool 写了一个 sanity-check 接口:遍历自由链表,校验 next 指针的合法性,以及已分配槽位的 magic 字。每跑完一轮大压力测试就调用一次,能在开发期抓到大量越界和双重释放。这套小工具比任何 profiler 都实在——它解决的是你不会在生产环境崩溃后才开始找 bug 的问题。
6. 根据压测结果,我最终给出的方案建议
综合所有测试结果,我提一个可以照着抄的选型路线。如果你的代码还没遇到分配器瓶颈,先不要动,默认 malloc 已经非常优秀,过早自定义分配器是纯粹的维护成本。如果确认有瓶颈,按这个优先级试:
第一优先级,线程本地缓存 + 固定大小池,适合大量同尺寸短生命周期对象的场景(典型如网络请求解析对象、游戏实体组件)。实现最简单,延迟收益最稳定,内存开销可控。第二优先级,请求级/任务级 Arena,适合"一批对象一起创建、一起销毁"的场景,分配成本几乎逼近零。但要严格监控高水位内存。第三优先级,栈式分配器,适合严格 LIFO 生命周期的递归/嵌套模型,用得好的话代码会很优雅,但场景比较受限,别硬套。
我个人在实际操作中的体会是,最值得先做的是"用火焰图 + perf 确认问题",然后把线程私有 Pool 引入到热点路径上,一次性把峰值频次最高的对象切过去。这套变化往往两三天就能落地,性能改善却立竿见影。后面如果还想再榨一层性能,我会看看能不能把 Arena 用进去——不过前提一定是内存监控允许。
最后再分享一个小技巧:写自定义分配器时,把分配和释放的路径做成分离的 allocate/deallocate 接口,而不是直接套用 operator new 的签名。这样以后想换策略、加统计、做调试钩子,都只需要改实现,不需要改业务代码。分配器这个领域,坑很多,但每一项优化都看得见摸得着。这就是我坚持自己写对比、手测数据、并持续迭代分配器的原因——它对系统性能的提升是实打实的底层红利。
