先交代背景:我做C++服务端开发这些年,被内存分配卡到过不止一次。业务流量一上去,最不起眼的 new/delete、malloc/free 反而成了最大的热点。于是“自定义分配器”这个词频繁出现在我的优化清单里,从最早照着网上的代码抄对象池,到后来自己设计多线程版本,再到现在用一套固定流程做性能对比,踩过的坑、总结出的判断标准都挺值得聊聊。
这篇内容的核心就是“自定义分配器性能对比”这件事到底怎么做、数据怎么看、结论怎么下。我会用自己的实测方式拆一遍,同时把实现思路和常见陷阱写清楚。适合正在做网络服务器、游戏后端、嵌入式中间件,或者被 malloc 锁竞争逼到要看底层分配器的朋友。哪怕你现在不准备手写分配器,搞懂这套对比方法也能帮你判断:tcmalloc、jemalloc 或者简单内存池,到底值不值得引入。
1. 自定义分配器到底解决什么问题
动手之前,先得搞清楚默认分配器哪里不给力,否则容易把力气用错地方。很多人的第一反应是“malloc 不够快”,但“不够快”是个很模糊的描述。你要能够回答:是单次延迟太高,还是多线程时吞吐上不去,还是内存碎片太严重,还是峰值内存超了。不同的瓶颈,对应不同的分配器方案。
1.1 默认分配器的开销藏在哪儿
glibc 的 malloc 实现是 ptmalloc 思路,为了兼顾任意大小、任意线程模型、任意生命周期,它维护了多个 arena,线程多的时候还得做 arena 的分配和锁管理。单线程场景下,一次 malloc/free 可能只有几十纳秒,看着不大,但如果是高频交易里的撮合循环、游戏服务器里的每帧对象创建,一次逻辑往往会产生几百上千次分配,这个开销会被放大得非常明显。
new/delete 更不用说,本质上就是对 operator new 的封装,底层还是 malloc/free。所以对齐要求、元数据开销、锁竞争、系统调用这些成本,一样都没少。内存释放时,free 不一定把页面还给操作系统,而是留在进程的堆缓存里,这就导致两个结果:一是进程的 RSS 看起来一直很高,二是堆里的空闲块可能被切得很碎,后续分配变慢。
自定义分配器要解决的,往往就是这几件事里的某一两件。比如固定大小对象的场景,可以用对象池直接把 free list 串好,分配就是取一个头部节点,释放就是塞回头部,完全绕开 malloc 的复杂管理。再比如生命周期一致的批量对象,可以用 arena,一次性申请大块内存,用偏移量切,结束统一释放。核心思路是“用局部规则换掉通用逻辑”,从而减少锁竞争、减少系统调用、减少元数据。
1.2 三类值得自己做分配器的场景
第一类是高频小对象场景,典型代表是网络收发包、连接对象、协议编解码缓冲区。这些对象大小固定、生命周期短、创建销毁频繁,对象池基本是满分答案。第二类是批次生命周期场景,比如一帧游戏里的临时碰撞数据、一次请求处理的中间结果,它们在同一批逻辑内创建,逻辑结束就不需要了,用 arena 释放整块内存,代价极低。第三类是跨线程传递比较频繁的场景,自己设计带线程局部缓存的分配器,可以明显降低全局锁的争用。
反过来说,如果你的业务内存对象大小差别巨大,生命周期嵌套复杂,甚至对象之间互相持有引用,那我建议先别碰自定义分配器。硬写出来的分配器可能比 malloc 更慢,因为你设计不出足够通用的规则,反而要花大量 CPU 去处理边界情况。所以做性能对比之前,先确定场景是否值得。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 可选方案:从 std::allocator 到手写内存池
经常有人把 std::allocator 和“自定义分配器”混在一起说。实际上 std::allocator 只是一个标准容器和内存分配之间的接口层,它默认的实现就是调用 operator new / operator delete,并没有带来性能提升。你真正要做的,是往这个接口里塞入自己设计的存储分配策略。
2.1 std::allocator 只是入口,不是银弹
C++ 标准库容器都支持传入模板参数 Allocator,例如 std::vector<int, MyAllocator
使用 std::allocator 接口需要实现 allocate/deallocate,规范里还要求 value_type、rebind、相等比较等。C++20 之后有 std::allocator_traits,写起来简单不少,但核心仍是 allocate 返回原始内存、deallocate 回收内存,构造和析构由容器自己在内存上完成。所以自定义分配器不该负责构造函数,它只是“内存资源的提供者”。
这里要强调一点:不是用了自定义分配器就一定会更快。分配器接口本身有额外跳转成本,如果底层分配逻辑做得不好,可能比默认还慢。我之前见过有人写的分配器里每个 allocate 都去 calloc,然后大量内存永不释放,那还不如不写。
2.2 不同分配模型的核心差异
我把常见的分配模型放在一张表里,方便对比:
| 分配方案 | 核心思想 | 适合对象 | 线程安全 | 实现难度 | 碎片控制 |
|---|---|---|---|---|---|
| malloc/free | 通用堆管理,按大小分类维护空闲块 | 任意大小、任意生命周期 | 内部处理,但存在锁竞争 | 低 | 一般 |
| std::allocator 默认 | 包装 operator new/delete | 任意 | 同上 | 低 | 一般 |
| 固定大小对象池 | 预分配大块内存,按相同尺寸切块,用 free list 串起 | 大小固定、高频创建的对象 | 需要自己加锁或无锁化 | 中 | 好 |
| Arena | 一次申请大块,按顺序偏移分配,统一释放 | 生命周期一致的批量对象 | 单线程天然高效,多线程需分段 | 低到中 | 好 |
| 多尺寸缓存池 | 按 8/16/32/64... 多个级别分组管理 | 常见的小对象组合 | 较复杂 | 高 | 好 |
| 线程局部缓存 + 对象池 | 每个线程维护独立缓存,减少跨线程锁 | 多线程高频小对象 | 无全局锁 | 高 | 中 |
从性能对比的角度看,没有哪个方案是绝对王者。固定大小对象池在单线程、固定大小场景下可以把 malloc 甩开一大截,但一旦出现混合大小对象,它的优势就没了。Arena 在批量生命周期场景下非常快,但你没法对单个对象单独释放。线程局部缓存方案是多线程场景下的最优解之一,不过实现复杂度也是最高的,而且要小心线程退出后的内存回收问题。
2.3 先试第三方库,再决定要不要手写
说到高性能分配器,很多人第一个想到的是 tcmalloc、jemalloc、mimalloc 这些成熟开源库。它们已经内化了线程缓存、大页支持、碎片整理等大量优化,通常直接用就能拿到很好的效果。所以我的经验是,先做性能对比,把 tcmalloc 或 jemalloc 挂上去试试。如果达标,就不要手写。如果没达标,再研究是哪一项不达标,然后针对那一项做定制池。
有人会觉得“我的手写池一定能超过 jemalloc”,这个结论在特定场景下确实可能成立,因为 jemalloc 必须兼容通用分配模式,而你的池可以针对固定大小优化到极致。但通用池在混合大小、多线程、生命周期交错场景下的稳,是新手很难短期追上的。自定义分配器更像一把手术刀,使用前要确认病灶在哪,而不是大炮打蚊子。
3. 性能对比测试方案设计
这一节是整个文章最实际的部分。没有一套可信的对比方法,你得到的所有数字都是噪音。我见过不少人用“我写了个循环跑了一下,快了 5 倍”这种说法来推广池子,但当你自己复现时,结果完全对不上,原因基本都在测试设计上。
3.1 环境固定与负载模型
性能对比的前提是除了被测试变量之外,其他都不变。我的习惯是把 CPU 频率锁在一个固定值,或者至少在测试时关闭动态调频;用 taskset 绑定 CPU 核心;关闭 ASLR 对某些测试的影响;编译选项保持一致,比如都开 -O2。操作系统、glibc 版本、编译器等也要记录,因为同样的代码在不同平台上结果差异巨大。
负载模型要根据你的业务来定。至少设计三类:单线程小对象连续分配释放、多线程高并发分配释放、混合大小对象加随机生命周期。只测一种场景很容易得出偏颇结论。比如对象池单线程跑得非常漂亮,但一上多线程,如果有全局锁,反而可能比 glibc 还慢。
3.2 指标定义与统计口径
我习惯记录这四个指标:
- 每秒完成的分配/释放操作对数,即 ops/s
- 单次操作的平均延迟、P50、P99 延迟
- 峰值物理内存 RSS
- 测试结束后堆内存的碎片情况,或者进程内存占用回落速度
延迟统计要注意“平均值会掩盖长尾”。所以平均延迟和 P99 最好都看。分配器属于那种“绝大时候几十纳秒,但偶尔一次系统调用导致几百微秒”的组件,只看平均完全不够。
统计口径上,每一轮测试要设定相同的总操作次数和运行时长。我一般每次测试跑 5 轮,取中位数,而不是平均值,因为首轮往往会受页缓存、冷页影响。预热也很重要,可以先跑一部分操作让内存热起来,再开始计时。
3.3 基准测试代码骨架
我提供一个最简单的固定大小对象池实现,突出核心逻辑。这里为了清晰,先展示单线程版本:
cpp复制class FixedObjectPool {
public:
FixedObjectPool(size_t object_size, size_t capacity) {
object_size_ = std::max(object_size, sizeof(Node));
capacity_ = capacity;
head_ = nullptr;
chunk_ = static_cast<char*>(::operator new(object_size_ * capacity_));
for (size_t i = 0; i < capacity_; ++i) {
Node* p = reinterpret_cast<Node*>(chunk_ + i * object_size_);
p->next = head_;
head_ = p;
}
}
~FixedObjectPool() { ::operator delete(chunk_); }
void* acquire() {
if (!head_) {
// 真正使用中需要扩容,这里先简化
return nullptr;
}
Node* p = head_;
head_ = p->next;
return p;
}
void release(void* p) {
Node* n = static_cast<Node*>(p);
n->next = head_;
head_ = n;
}
private:
struct Node { Node* next; };
char* chunk_ = nullptr;
Node* head_ = nullptr;
size_t object_size_ = 0;
size_t capacity_ = 0;
};
测试循环时要注意编译器优化。直接写 for 循环里 acquire/release,编译器可能会发现对象完全没有被使用,从而整段优化掉。我习惯在拿到指针后做一次“编译器屏障”:
cpp复制template <typename T>
static void DoNotOptimize(T& value) {
asm volatile("" : "+r"(value) : : "memory");
}
再比如 arena 分配器,核心分配逻辑也就几十行:
cpp复制class Arena {
public:
Arena() = default;
Arena(const Arena&) = delete;
Arena& operator=(const Arena&) = delete;
void* alloc(size_t size) {
size = AlignUp(size, alignof(std::max_align_t));
if (remain_ < size) {
chunks_.emplace_back(new char[CHUNK_SIZE]);
cur_ = chunks_.back().get();
remain_ = CHUNK_SIZE;
}
void* result = cur_;
cur_ += size;
remain_ -= size;
return result;
}
void reset() {
cur_ = nullptr;
remain_ = 0;
// chunks 保留,后续继续复用,也可以清空归还
}
private:
static size_t AlignUp(size_t n, size_t align) {
return (n + align - 1) & ~(align - 1);
}
static constexpr size_t CHUNK_SIZE = 4 * 1024 * 1024;
std::vector<char*> chunks_;
char* cur_ = nullptr;
size_t remain_ = 0;
};
测试中使用的总次数要足够多,才能区分纳秒级差距。我通常单线程场景跑至少 500 万次操作,多线程场景每线程跑 100 万次,然后统计整体耗时。数据量太小时,系统噪声会淹没真实差异。
4. 实测数据与结果解读
下面这组数据是我在固定频率的 x86-64 Linux 机器上做的一轮典型结果,目的是展示对比方法,不是给你一个放之四海而皆准的绝对结论。不同编译器、glibc 版本、CPU 缓存的差异很常见,所以你自己复测时数字可能不一样,但变化的趋势应该是接近的。
4.1 单线程小对象场景:对象池的强项
测试负载是固定 64 字节对象,分配后立即释放,循环 500 万次。结果如下:
| 分配器类型 | 吞吐量(百万次/秒) | 平均单次耗时(纳秒) | P99(纳秒) |
|---|---|---|---|
| std::allocator 默认 | 8.3 | 120.4 | 238 |
| 固定对象池 | 41.2 | 24.3 | 41 |
| Arena(不单释放) | 55.0 | 18.2 | 39 |
对象池比默认分配器快约 5 倍,核心原因是分配和释放都只操作 free list 的头部节点,没有系统调用,没有空闲块查找,也没有锁。Arena 在“只分配不逐个释放”的模式下更快,因为它只是移动一个指针。
但由于对象池需要预分配容量,它的启动时内存占用是固定的。如果你预分配了 10 万个对象的内存,即使只用 1 个,RSS 也占着。这一点在对比时要注意,不能只比时间不比内存。
4.2 多线程竞争场景:锁与缓存是关键
多线程场景下,我用 8 个线程,每线程分配释放 100 万次,总吞吐量如下:
| 分配器类型 | 单线程吞吐(百万次/秒) | 8线程总吞吐(百万次/秒) | 扩展效率 |
|---|---|---|---|
| glibc malloc | 8.1 | 25.4 | 约 3.1 倍 |
| 固定对象池 + 全局锁 | 10.2 | 13.6 | 约 1.3 倍 |
| TLS 缓存 + 对象池 | 40.0 | 245.0 | 约 6.1 倍 |
这个结果很有意思。glibc 的多线程扩展并不差,因为它有多 arena 机制,但线程很多时仍会有锁迁移和 arena 扩展开销。而简单的“对象池 + 一把全局锁”在 8 线程时反而不如 malloc,因为每一对操作都抢一次锁,竞争非常激烈。
TLS 缓存方案是每个线程绑定一个自己的 free list,分配释放都不碰全局结构,因此扩展效率接近线性。但这需要额外的实现工作,比如线程退出时如何把缓存里的内存归还给全局池。
多线程性能对比里,我强烈建议同时记录宿主机的 CPU 利用率和上下文切换次数。如果线程数超过物理核心数,结果会混入调度噪声,对分配器评价不公平。
4.3 混合大小对象与碎片观察
固定对象池的局限很明显,所以我又加了一组混合负载:分配大小在 1、16、64、256、1024 字节之间随机选择,生命周期也很随机,每个对象存活时间从 1 到 1000 次循环不等。这种负载更接近真实业务。
| 分配器类型 | 吞吐量(百万次/秒) | 峰值 RSS(MB) |
|---|---|---|
| glibc malloc | 2.1 | 182 |
| 多尺寸缓存池 | 3.4 | 195 |
| tcmalloc | 4.8 | 164 |
glibc 在这种随机生命周期下最吃亏,因为分配和释放顺序乱,碎片容易累积。多尺寸缓存池比 glibc 快一些,但峰值内存更高,因为预分配的多个 size class 都占用了内存。tcmalloc 在吞吐和内存两端都表现不错,印证了“先试第三方库”的建议。
碎片是一个容易被高估又容易被低估的指标。碎片会直接影响分配速度,因为找连续块更困难;但 RSS 包含分配器缓存的内存,并不等于碎片。想观测碎片率,比较准确的做法是记录“分配器持有的空闲块数量和浪费字节数”,而不是只看进程 RSS。不过在简单对比里,RSS 峰值结合运行耗时已经能说明很多问题。
4.4 延迟曲线与长尾现象
我单独做了一次延迟采样,记录连续 10 万次分配释放的耗时分布。默认 malloc 的 P99 在 240 纳秒附近,但最大值偶尔跳到几十微秒,这个跳动通常来自堆扩展时触发的 sbrk/mmap 系统调用。对象池由于内存已经预分配,整个过程没有任何系统调用,P99 和最大值都稳定在几十纳秒级别。
我自己的经验是,如果业务对延迟抖动极敏感,比如高频交易、音视频渲染,那么自定义分配器带来的不止是“平均快”,更重要的是“尾部更稳”。这是性能对比里最容易被忽略的价值。反过来,如果业务只是批处理、对整体吞吐敏感,P99 未必是第一优先级。
5. 自定义分配器实现要点与避坑
写分配器和做性能对比其实是两件事,都容易踩坑。分配器实现得不好,或者对比方法不严谨,都会得出错误结论。这一节我把两个方向的问题都列出来。
5.1 实现时最容易忽略的几件事
内存对齐是第一优先级。C++17 里要求 operator new 返回的内存满足 alignof(std::max_align_t),一般是 16 字节。如果你在自己的池里按更小的块切分,千万要保证每块都对齐到边界。常见的做法是让每个块的大小向上取整到 16 的倍数,或者计算块大小时用 align_up(sizeof(T), alignof(std::max_align_t))。
free list 的节点复用也很有讲究。如果使用一个 Node 结构放在空闲块头部作为 next 指针,必须保证块大小不小于 sizeof(Node)。否则你往小块里写 next 指针会越界,这是新手最容易犯的错误。更安全的设计是用 union:
cpp复制union FreeNode {
FreeNode* next;
alignas(alignof(std::max_align_t)) unsigned char data[1];
};
构造与析构不能交给分配器。allocate 只负责返回一块原始内存,容器或业务逻辑要用 placement new 来构造对象,手动调用析构函数后再归还内存。把析构逻辑混进 deallocate 会导致复杂对象的状态完全错乱,这种问题非常难排查。
另外,内存归还策略要提前想清楚。对象池析构时一次性归还整块 chunk,这是最简单可靠的做法。如果做成长期驻留的单例池,就要考虑“闲时内存会不会一直占用着不放”。我之前遇到过一个服务,空闲时段 RSS 高居不下,就是因为对象池在扩容后从不收缩。
5.2 做性能对比时常踩的坑
第一个坑是编译器优化。如果你在测试里达成了“所有对象都死了”,编译器可能直接把整个循环删掉,测出来的数字快得离谱。解决办法就是上面提到的 DoNotOptimize,让编译器相信每个返回的指针都被用到了。
第二个坑是测试顺序。同一个进程里先跑 malloc 再跑对象池,malloc 产生的堆结构可能留在内存里,影响后续页面分配。我通常会选择配置多个可执行程序,每个只测一种分配器,或者至少每轮测试用随机顺序并重启进程。
第三个坑是统计工具不统一。用 time 命令测 wall time 没问题,但要看命令本身是否受系统负载影响。我习惯在程序内部用 steady_clock 计时,并且记录 CPU 亲和性,避免测量到的是被调度器打断的时间。
第四个坑是没有反应真实业务。测试“立即分配立即释放”和业务里“分配后对象存活很久”得到的结果可能完全相反。所以性能对比的负载模型要根据业务微调,不能永远只测最理想的单线程固定大小场景。
5.3 该不该上自定义分配器:我的判断标准
我现在的习惯是按这个顺序做事:先用 profile 工具确认分配器是不是瓶颈,再挂 tcmalloc/jemalloc 跑一轮对比,数据达标就直接收工。如果不达标,再分析分配模式,找到固定规律,动手写专用池。写完之后,用和上线前一样的压力测试和监控指标来验证收益,而不是只跑一个 micro benchmark。
这套流程帮我避过很多次“优化了个寂寞”的坑。大部分业务场景里,优化真正的瓶颈往往是锁竞争、网络 IO、序列化或者数据库查询,分配器只是表面现象。自定义分配器是个好工具,但性能对比才是那个能帮你做出正确判断的锚。随便找个 benchmark 跑一下就说快了多少,这种结论我劝你别全信。
试过太多分配方案之后,我现在的体会是:自定义分配器不是在“更好”和“更差”之间选择,而是在“适合”和“不适合”之间选择。哪怕数字再好看,如果它增加了太多维护成本、并且只有人工构造的测试场景下才有效,那它就不适合上线。反过来,如果你的场景足够聚焦、收益明确、测试覆盖得住,那手写一个专职分配器带来的稳定性和性能收益,会远超我的预期。最后再分享一个小习惯:任何分配器改动,我都会保留一套可重复的回归测试和性能基线脚本,不然后面优化到哪一步了、为什么变快变慢,完全说不清楚。
