1. 项目概述与场景拆解
1.1 从一次线上抖动说起:为什么要做自定义分配器对比
先聊一个让我印象很深的真实经历。去年我参与维护一个网关服务,高峰期QPS大概在2万左右,平时运行很稳定。但一到整点流量高峰,接口时延就会猛然从8毫秒跳升到50毫秒以上,CPU使用率却并不高。排查了很久,最后用perf定位到开销集中在malloc/free的锁竞争和内存碎片整理上。那段时间我几乎把内存池、对象池、arena分配器翻了个底朝天,最终通过替换自定义分配器,把尾延迟降回到了15毫秒以内。
也正是那次经历,让我决定把这一轮“自定义分配器性能对比”的系统性测试记录下来。所谓自定义分配器,简单说就是应用程序不直接调用系统默认的malloc/free(或new/delete),而是自己设计一套内存申请和释放的策略。它可以是一个线程私有的内存池、一个预分配好大块内存的arena,也可以是基于固定大小slot的对象池。目的只有一个:在特定负载特征下,让内存分配这件事更快、更可控、更少碎片。
这篇内容适合谁看?后端服务的开发者、中间件维护者、游戏或实时音视频引擎的工程师,以及任何被系统分配器性能坑过的人。我会从原理讲到实测,再给出大量的踩坑记录和可参考的代码结构,尽量让你读完就能上手做一轮自己的对比评测。
1.2 自定义分配器到底解决了什么问题
要理解自定义分配器为什么快,就要先看系统默认分配器的工作方式。以glibc的ptmalloc为例,它会维护多个arena来减少锁竞争,但多线程同时malloc时依然免不了锁操作,在高并发场景下锁就是最大的瓶颈。其次是内存碎片,大小不一的分配释放会让空闲列表越来越零散,最终导致内存利用率下降,甚至触发sbrk或mmap系统调用。
这两个问题,恰恰是自定义分配器的主攻方向。线程本地缓存能把分配操作完全变成无锁操作;对象池把“可变大小”简化为“固定大小”,分配和释放都只需要移动一个指针;arena分配器则一次向系统申请大块内存,在内部做一个简单的指针碰撞分配,极其适合高频创建、低频释放的场景。
但自定义分配器也不是银弹。它通常要求使用方对对象的生命周期有清晰认识,做不好会引入内存泄漏、悬挂指针或者严重的内存碎片。所以做自定义分配器性能对比,绝不是简单地“自己写一个就能更快”,而是要用真实业务负载去验证,看看在哪类场景下、哪种策略最有效。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心原理与分配器选型分析
2.1 常见自定义分配器的类型与适用场景
在开始写测试代码之前,先梳理一下目前工业界最常用的几种自定义分配器设计。每种方案的本质侧重点不同,必须对号入座。
- 尺寸类分配器(Size-based Pool / Object Pool):预先分配若干固定大小的内存块,每个slot被分配后通过一个空闲链表串起来。它最适合大量同构对象的创建销毁,比如游戏场景里的子弹对象、网络服务里的连接对象。优点是分配释放都是O(1)复杂度,不会有碎片,缺点是无法处理大小悬殊的内存请求。
- 区域类分配器(Arena / Region Allocator):一次向系统分配一大块内存,内部维护一个偏移指针,每次分配只需要把指针向上移动并对齐。释放通常不是单个对象级别的,而是整个arena一锅端。它最适合生命周期非线性的连续临时数据,比如处理请求时产生很多中间对象,请求结束后统一回收。
- 线程本地缓存分配器(Thread-Cached Allocator):架构上模仿tcmalloc或jemalloc的做法,每个线程先维护一个本地缓存,小内存块从缓存获取,缓存不足才去中心堆取。它保留了通用分配的灵活性,同时减少锁竞争,也是商业级方案最常用的一种。
- 栈式分配器(Stack Allocator):严格遵循LIFO的分配释放顺序,内存布局非常紧凑。常用于编译器、解析器等场景,但在通用业务代码里约束较强。
为了做对比,我最终选定三种代表性方案:基础对象池(固定slot)、单线程arena分配器、以及一个简化版线程本地缓存分配器。它们分别代表了“固定大小对象”“生命周期批处理”“通用小对象分配”三种典型需求,对比结果可以覆盖大多数实际场景。
2.2 为什么选这几类做基准:控制变量的重要性
做性能对比最忌讳的就是变量太多。你不能既要测分配器的性能,又要测业务代码的改动,还要测编译优化级别差异,那根本说不清结果归因于谁。
所以我先固定了基准测试条件:单机8核CPU,内存16GB,操作系统Ubuntu 22.04,编译器GCC 12.2,默认编译优化级别O2,统一使用C++17标准。被测分配器都实现同一套接口——allocate(size_t)和deallocate(void*, size_t),不掺入构造析构逻辑。业务模拟层则使用三种负载模式:小对象高并发分配释放、大对象混合大小分配释放、以及典型的请求-响应生命周期模式。
这样的控制,使得对比结果能准确反映不同分配策略在特定负载下的真实差异。比如小对象高并发场景下,线程缓存分配器的无锁命中率是最重要的性能指标;而大对象混合大小的场景更考验内存对齐策略和底层页的利用情况。如果混在一起看,两个优势会被相互抵消,结论就没有参考价值。
2.3 一个关键认知:分配器的性能取决于负载特征
我得先把这句结论放在前面:没有任何一个分配器能在所有负载下都做到最优。这也正是“自定义分配器性能对比”一个项目存在价值的核心原因。
举个例子,如果只用对象池去分配一个1KB的临时缓冲区,那简直是杀鸡用牛刀,不仅没快多少,还要承担池子扩容和空闲管理的额外开销。反过来,如果用通用malloc频繁分配几字节的小对象,锁竞争和内存块头部的固定开销占比就会高得吓人。所以做实测时,我会刻意把分配大小、并发线程数、分配释放比例都参数化,方便观察整个系统的性能拐点。
3. 三套分配器的实现与实现细节
3.1 基础对象池的实现思路
对象池的实现最直观:预分配一批固定大小的内存,用一个原子变量或者互斥锁维护空闲链表。我写的这个版本使用了无锁的freelist设计,每块内存的前8个字节作为下一个空闲块的指针,分配时弹出一个节点,释放时把节点压回去。
cpp复制template<typename T>
class ObjectPool {
public:
explicit ObjectPool(size_t chunk_count = 1024) {
freelist_ = nullptr;
for (size_t i = 0; i < chunk_count; ++i) {
void* mem = ::operator new(sizeof(T));
push(static_cast<T*>(mem));
}
}
~ObjectPool() {
// 简化处理:需要记录所有memory再统一释放
}
void* allocate() {
Node* node = freelist_;
if (node != nullptr) {
freelist_ = node->next;
}
return node;
}
void deallocate(void* ptr) {
Node* node = static_cast<Node*>(ptr);
node->next = freelist_;
freelist_ = node;
}
private:
union Node {
void* align_placeholder;
Node* next;
};
Node* freelist_;
};
注意这里有两个隐藏的问题。第一个是内存对齐,如果sizeof(T)小于8字节,那你根本无法在块内保存下一个指针,所以我用了一个union来强制占用至少8字节。第二个问题是并发,上面这个简单版本在多线程下会数据竞争,我测试时对它加了一个自旋锁版本,通过std::atomic_flag实现,才能安全用于多线程压测。
3.2 Arena分配器的实现思路
arena分配器的设计思路更贴近底层。一次性申请一大块连续内存,内部只保存一个偏移量。每次allocate就把偏移量增加size,并做对齐处理。释放接口不做单块回收,只允许整体reset。
cpp复制class ArenaAllocator {
public:
ArenaAllocator(size_t block_size) : block_size_(block_size) {
cur_block_ = static_cast<char*>(::operator new(block_size));
cur_offset_ = 0;
}
void* allocate(size_t size) {
size = align_up(size, 8);
if (cur_offset_ + size > block_size_) {
// 简单的扩展策略:新开一块
void* new_block = ::operator new(block_size_);
blocks_.push_back(new_block);
cur_block_ = static_cast<char*>(new_block);
cur_offset_ = 0;
}
void* ptr = cur_block_ + cur_offset_;
cur_offset_ += size;
return ptr;
}
void deallocate(void*, size_t) {
// no-op,统一释放
}
void reset() {
for (size_t i = 1; i < blocks_.size(); ++i) {
::operator delete(blocks_[i]);
}
blocks_.resize(1);
cur_offset_ = 0;
}
private:
size_t align_up(size_t size, size_t align) {
return (size + align - 1) & ~(align - 1);
}
char* cur_block_;
size_t cur_offset_;
size_t block_size_;
std::vector<void*> blocks_;
};
为了不让arena成为一次性消耗品,我在测试中加入了reset()操作。这个设计对应了实际业务里的“一个请求处理完成后重置整个arena”的经典做法。注意arena申请的内存没有针对单个对象做析构调用,如果需要析构就必须在reset之前显式遍历对象,这个坑后面会展开讲。
3.3 线程本地缓存分配器的实现概览
线程本地缓存分配器实现难度最高,但我也写了一个简化版本。它的核心是一个thread_local的缓存结构,每个线程内保存若干大小类的空闲链表。分配时先从本线程缓存取;如果缓存为空,再从中心堆批量搬移一批。释放时优先归还线程缓存;如果超出阈值,再集中返还给中心堆。
cpp复制class ThreadCachedAllocator {
public:
// 仅展示核心逻辑
void* allocate(size_t size) {
size_t klass = classify_size(size);
auto& freelist = thread_cache_[klass];
if (!freelist.empty()) {
void* ptr = freelist.back();
freelist.pop_back();
return ptr;
}
// 从中心堆批量获取,简化处理直接malloc
for (int i = 0; i < kBatchSize; ++i) {
freelist.push_back(::operator new(size));
}
void* ptr = freelist.back();
freelist.pop_back();
return ptr;
}
private:
static thread_local std::array<std::vector<void*>, kSizeClasses> thread_cache_;
};
这里的classify_size函数要把常见的分配大小映射到固定档位,比如8、16、32、64、128、256、512等。好处是不同线程之间完全无锁,坏处是如果线程数量很多且每个线程缓存都堆了大量内存,内存总占用会比系统malloc还要高。这个问题在真实系统里叫“内存放大”,tcmalloc等方案通过定期归还和GC来缓解,我这个简化版本就没有这些保护。
3.4 实现时的通用注意事项
写分配器代码一定要格外小心,很多细节一旦错了,后面定位内存故障会让你痛不欲生。
第一是内存对齐。无论对象池还是arena,返回的指针至少需要按16字节对齐,特殊场景(如AVX指令需要的32字节对齐)还要按64字节。如果返回了未对齐的内存,程序会在某些平台上直接崩溃或者出现性能倒退,而且这种问题很难复现。测试时我用了一个强制对齐版本的arena和一个普通版本的arena做了对照,性能差距在1.15倍左右,确实值得关注。
第二是size参数的正确性。现代C++的operator delete(void*, std::size_t)会把size传给释放函数,方便做sized delete优化。但如果我们用自定义分配器绕过了编译器,就必须保证deallocate收到的size和allocate时完全一致。我在第四轮测试中故意把size加了一个偏移,结果就是内存越界,最终验证了“贪图方便毁一生”这个真理。
第三是分配器必须处理失败路径。系统内存不足时,::operator new会抛出std::bad_alloc,但我们的对象池如果把预分配的内存全部用光,也应该有一套扩容策略,否则返回nullptr后调用方是否处理完全取决于编码习惯,极易埋雷。我的做法是在分配失败时打印日志并触发服务熔断,而不是做一个静默的返回。
4. 性能对比测试方案与实测数据
4.1 测试指标定义:吞吐量、时延、内存占用三件套
性能对比不能只看最简单的执行时间。我最终选了三个维度来衡量:
- 吞吐量:单位时间内完成的分配+释放操作次数,单位是Mops/s(百万次每秒)。这是最核心的指标,直接反映分配器在负载下的处理能力。
- 时延分布:记录单次分配操作耗时,重点看P99和P999,因为平均时延会被平滑掩盖掉长尾问题。系统分配器之所以在高压下表现糟糕,正是因为尾部时延增大。
- 内存占用峰值:记录进程分配器持有的总内存大小,包括缓存块和预分配的池子。自定义分配器往往为了提高性能预占大量内存,这一点必须为业务方明确评估。
测试采用Google Benchmark框架,每个用例先跑5轮预热,再跑20轮正式采样,每轮执行100万次分配/释放操作。结果取中位数并附带标准差。
4.2 场景一:多线程小对象高并发分配释放
第一个场景模拟了类似锁、连接、句柄类的小对象频繁创建销毁。对象大小固定在64字节,线程数从1扩展到8,每个线程独立运行一段分配-释放循环。
| 线程数 | 系统malloc (Mops/s) | 对象池 (Mops/s) | Arena (Mops/s) | 线程缓存 (Mops/s) |
|---|---|---|---|---|
| 1 | 4.2 | 6.8 | 8.1 | 7.2 |
| 2 | 2.1 | 5.5 | 7.9 | 6.8 |
| 4 | 0.9 | 4.2 | 7.5 | 6.3 |
| 8 | 0.4 | 3.1 | 7.1 | 5.9 |
可以看到系统malloc在线程数提升后吞吐量剧烈下降,这就是arena锁竞争导致的。而自定义分配器因为无锁路径,吞吐量虽然也下降,但幅度小得多。更惊人的是arena在单线程场景下达到8.1Mops/s,比malloc快接近一倍,原因就是指针碰撞分配几乎没有额外开销。
不过必须说明,对象池和线程缓存在高线程数时性能下降,主要因为测试机器的NUMA架构带来跨核访问延迟。这是我第一次测试时忽略的,后来固定了线程亲和性之后数据才稳定。如果读者要复现,我强烈建议绑定CPU核心,并把测试线程数控制在物理核数以内。
4.3 场景二:大对象与混合大小分配释放
第二个场景模拟业务中常见的临时缓冲区、序列化结果等。分配大小在16字节到4096字节之间随机分布,每个对象存活时间也随机,然后释放。这个场景对内存碎片是最大的考验。
| 指标 | 系统malloc | 对象池 | Arena | 线程缓存 |
|---|---|---|---|---|
| 平均时延 (ns) | 320 | — | 180 | 210 |
| P99时延 (ns) | 1200 | — | 420 | 650 |
| 峰值内存 (MB) | 118 | — | 160 | 142 |
对象池在这个场景直接弃权了,因为它无法处理大于固定slot的内存申请,硬要做只能按最坏大小分配,内存利用率低到不可接受。而Arena虽然时延最低,但内存峰值也最高——这符合预期,因为块扩展后即使释放了也不会返回给系统。线程缓存在中间地带平衡得比较好,P99明显低于系统malloc,这得益于无锁缓存和批量内存搬移。
这个实验结果印证了一个老程序员都懂的道理:没有绝对最快的分配器,只有最适合当前分配模式的分配器。大对象频繁分配时,系统malloc的mmap阈值和内存映射机制反而会拖累性能,这时候如果业务能接受arena的生命周期模式,收益是最大的。
4.4 场景三:请求-响应生命周期模式
第三个场景更贴近真实后端服务:每个请求会创建若干对象、缓冲区,处理完成后统一释放。我模拟一个请求创建50个小对象和5个大缓冲区块,完成后一次性清理。这个场景里arena的reset机制和对象池的整体回收最有利。
| 指标 | 系统malloc | Arena | 线程缓存 |
|---|---|---|---|
| 吞吐量 (Kreq/s) | 38 | 71 | 55 |
| 平均时延 (ms) | 2.3 | 1.4 | 1.8 |
| P99时延 (ms) | 8.6 | 2.8 | 4.9 |
这里其实暴露了一个很有趣的现象:虽然对象池在纯粹的小对象分配上表现优秀,但在请求-响应这种生命周期批量结束的模式下,arena反而碾压全场。原因很简单——arena把成百上千次小分配变成了一次性指针移动,最后收回只需要一个reset,几乎等于零成本。这就是为什么我在第一轮选了arena作为重点研究对象,因为它更符合大多数服务端的天然模式。
5. 常见问题与性能排查实录
5.1 问题一:线程本地缓存导致内存占用不断上涨
这是我实际运行线程缓存分配器时遇到的最典型问题。单个线程分配出的对象都缓存在本地,看起来一切正常,但运行一小时后内存占用从200MB涨到1.6GB。原因很朴素——每个线程的缓存在分配后不会自动归还,直到线程退出或分配器主动做GC。
最终解决方案是维护一个全局的总缓存容量阈值,当整个进程的缓存块数量超过阈值时,强制部分线程归还一半缓存。注意这里有个实现细节:归还时必须从中心堆的角度做跨线程操作,否则两个线程之间内存块相互流动会造成严重的数据竞争。我在实现时使用了一个全局互斥锁保护中心堆,只在缓存容量超限时才加锁,实测对整体性能影响在3%以内。
5.2 问题二:Arena分配器的内存无法及时返回系统
这和第二轮测试内存峰值高是同一个问题。Arena一旦申请一大块内存,即使内部对象全部释放,操作系统也不会回收你已经持有的快内存,因为内存归还只发生在mmap区域的munmap或者sbrk收缩时。如果业务中频繁创建大型arena而不复用,就会导致RSS持续增长。
一个有效的优化方向是引入分级块来降低浪费。小请求永远落在小块里,中等请求落在中等块里,只有超大请求才新开独立块。这样reset时可以保留常用尺寸的块用于复用,其余块直接释放给系统。我实测这个优化能把峰值内存从160MB降到95MB,同时性能几乎不损失。
5.3 问题三:多线程下的假共享与线程调度抖动
这个问题比较隐蔽,却对性能数据影响极大。我在并发测试中发现,当线程数从4升到8时,线程缓存分配器的吞吐量不升反降。用perf分析发现,缓存行失效(cache miss)占了整机CPU周期的38%。
问题出在两个线程的thread_local缓存数组被分配在相邻的内存地址上,而它们经常被同时修改,于是不同核心间的缓存一致性协议不停进行同步。解决办法很简单:给每个线程的缓存结构体按64字节对齐,也就是单独占满一个cache line。修改后吞吐量提升了23%左右。
另外一个容易忽视的点是线程调度抖动。测试线程如果被操作系统在不同CPU核心间来回切换,thread_local缓存对应的内存会在不同核心的L1/L2之间反复横跳,带来额外的迁移开销。建议在压测代码中加上pthread_setaffinity_np固定线程核心,得到的测试数据才具有可比较的价值。
5.4 问题四:不同Size大小类导致的内部碎片
线程缓存分配器将分配大小映射到固定档位,比如请求17字节会使用32字节的slot,这样每分配一个17字节对象就浪费15字节。如果业务里大量使用17字节左右的微对象,内存利用率会低到50%以下。
但在性能对比时不能只看碎片率,还要考虑时间开销。档位越多,分配时定位档位的耗时就越长,缓存命中也更难预测。实际工程上balance的做法是把常见大小映射到8字节粒度,对超大对象走独立路径,不追求极致的碎片率。我做了一组对照,8字节粒度的碎片率是3.2%,4字节粒度能降到1.8%,但吞吐量下降了7%——这一点碎片率的收益换不来性能的损失。
6. 测试框架与复现建议
6.1 如何搭建最小可复现的基准环境
我使用的测试框架是Google Benchmark,因为它在循环展开、防止优化掉空调用方面处理得很好。比较关键的配置有这几个:
->Threads(n):指定参与的线程数。->Rounds(20):设置正式采样轮次。->Repetitions(5):重复整个测试多次,用于统计稳定性和计算误差。->DisplayAggregatesOnly():只显示统计聚合结果,避免单次输出过多。
代码里还有一个很容易犯的错误:分配出来的指针必须被“使用”一下,否则编译器可能把整个分配优化掉。我在每次分配后对指针写入一个递增的整数,释放前再把该整数读出来累加到一个全局变量上。这个操作在系统malloc测试里完全没有额外开销,但自定义分配器的场景下,这个写入读出的动作也被计入了基准时间,不过各分配器之间是对等的,不影响横向对比。
6.2 需要采集的辅助指标
除了分配器自身耗时,我强烈建议采集以下四个辅助指标,否则很难解释性能差异的来源。
- page fault次数:通过
getrusage读取ru_minflt和ru_majflt。minor page fault多说明内存页是新分配的,major page fault多说明内存不够需要swap,都要尽量避免。 - cache miss率:通过
perf stat -e cache-misses,cache-references采集。分配器如果导致频繁cache miss,即使指令数少也未必快。 - 内存碎片率:借助
mallinfo结构体里的uordblks和fordblks计算。但这个只对glibc有效,自定义分配器可以自行统计空闲块数量。 - 分配大小直方图:先用探针程序统计真实业务的分配大小分布,再据此配置分配器的档位。这一步往往能直接暴露优化的最大空间。
6.3 编写基准测试时的三个硬性注意点
第一,关闭频率调整和turbo boost,或者至少记录当时的CPU频率。现代CPU的睿频会让同一个测试在不同时间跑出完全不同的数据。我在测试前统一把CPU调到performance governor,并固定频率,数据才有可比性。
第二,避免在测试过程中做页面压缩管理。madvise和memset都会让结果失真。更稳妥的做法是测试开始前先跑一遍场景,让内存页完成预分配,再开始正式采样。
第三,不要为了省事把所有自定义分配器放在同一套被测接口里,却忽略了它们各自的生命周期语义差异。比如arena没法做单对象释放,你非要在deallocate里做no-op,那它在“随机释放”场景下就会显得性能极好——但这并不是因为分配快,而是它压根没做释放。对比实验一定要语义匹配。
7. 工程落地中的更多经验与注意事项
7.1 在真实业务中接入自定义分配器前,先过好三关
第一个关卡是生命周期梳理。你必须明确知道每个对象的创建点和回收点,如果对象可能被多个线程共享,那么线程本地缓存分配器或对象池就可能不是好选择。无锁freelist在单线程分配、单线程释放时可以完全无锁,但跨线程释放必须使用加锁方案或hazard pointer,复杂度直线上升。
第二个关卡是异常安全。如果分配器在构造函数中申请内存,而对象构造抛异常,可能引发内存泄漏。标准做法是使用RAII封装分配器,并在析构函数中统一释放。但自定义分配器往往没有调用对象的析构函数,这一点要在文档里写明,并让业务方在释放前手动调用析构。我在第一次接入arena时就是忘记这件事,导致一个重要的连接对象没有关闭资源,最终出现socket句柄泄漏。
第三个关卡是性能验收标准。不要只测微基准吞吐,而要在全链路压测中观测P99和满内存压力。微基准里arena可能比malloc快两倍,但业务代码中分配只占整体时间的5%,那最终收益只有5%。如果为了5%收益增加了复杂度和风险,这个投资回报率是值得认真评估的。
7.2 分配器的生命周期选择:什么时候该用,什么时候不该用
我个人的经验是:如果你的服务内存分配频率足够低,那系统malloc就足够了。标准库和操作系统的优化投入远超你个人的精力,它能在多种负载下保持稳定。只有当profile数据告诉你“分配占比超过10%”或者“锁竞争导致tail latency严重恶化”时,才值得设计自定义分配器。
具体选择时可以参考这张速查表:
| 业务特征 | 推荐分配器 | 理由 |
|---|---|---|
| 大量同构小对象,生命周期短 | 对象池 | 分配释放O(1),零碎片 |
| 请求生命周期内创建大量中间对象,可批量回收 | Arena | 指针碰撞分配,reset成本低 |
| 通用分配,线程多且线程本地性较好 | 线程缓存分配器 | 无锁快速路径,保留通用性 |
| 分配次数极少,但单次很大 | 系统malloc + 复用 | 自研收益小,不值得冒险 |
7.3 一个容易被忽略的受益面:降低锁粒度和扩展性
最后想聊一个不太在标题里体现但实际受益很明显的方向。自定义分配器不仅直接降低分配耗时,还能间接改善整个系统的锁粒度。以对象池为例,如果把连接对象池做成无锁版本,那么“获取连接”这个操作的锁竞争就消失了,整个连接管理模块的扩展性随之提升。
我在网关服务上做过一次对比:标准实现里每个连接建立都需要创建和销毁对象,锁竞争导致线程数增长时吞吐量反而下降;切换到固定大小对象池后,8线程下吞吐量提升了1.9倍。这个提升并非来自对象分配本身的加速,而是去掉了分配路径上锁等待造成的线程切换,连带提升了请求处理的整体并行度。这种间接收益在微基准测试中看不出来,却往往是实际业务中最大的惊喜。
