先放一个结论放在开头,可能跟很多人想的不一样:关于自定义分配器的性能对比,网上能找到的benchmark很多都做得不太走样,一个循环里先new一万次再delete一万次,然后得出结论——malloc真慢,手写池真快。这个结论放到真实业务里基本没有参考价值。我这两年被分配器问题折腾过不少次,也陆续手写过对象池、Arena、大小分类分配器,跟glibc的malloc、jemalloc、tcmalloc都做过比较细的对比测试。这篇文章把测试方法、踩过的坑和几次有代表性的数据结果整理出来,希望能帮你少走点弯路。
1. 先别急着否定malloc:性能对比的真实坐标
1.1 单线程小循环测试的甜蜜陷阱
很多人对malloc的抱怨来自一个特别典型的小测试:单线程、固定大小、连续分配再连续释放,然后跟自己的池化分配器对比,得到“人肉分配器快十倍”的结论。这个测试其实有一个巨大的隐性前提——分配器每次都能从无锁的路径拿到内存,而且分配之后马上释放,所有内存都在热缓存里。
glibc从2.26版本开始引入了tcache,每个线程维护自己的小块内存缓存,取内存和还内存大多数情况下根本不进入arena的锁区域。我拿一台跑Linux 5.15、glibc 2.35的机器做过一次比较简单的循环:单线程,每次分配128字节,分配一万次后全部释放,再重复100轮取中位数。malloc单次op大约在80纳秒上下,如果自己写一个简单的对象池,单次分配加释放能做到20纳秒以内。看起来确实是池快,但你注意一个前提——每轮分配的块数完全一样,释放时机完全集中,这跟真实业务的分配模式差很远。
真实服务里的分配模式往往是分配大小不固定、生命周期长短不均、跨线程释放交错混合。一旦进入这种状态,malloc的tcache命中率就会下降,arena锁竞争开始暴露,这时手写分配器才有机会体现优势。所以第一步不是急着写池,而是先想清楚:你的业务分配模式,到底落在哪个形态里。
1.2 malloc真正值钱的地方:通用性和局部性管理
malloc在大多数场景下没有被替换,不是因为它快,而是因为它足够通用。它管理的是任意大小、任意顺序、任意生命周期的内存块,并且能通过brk和mmap的组合,在内存紧张的时候真正把页归还给操作系统。
还有一个很多人容易忽略的点:malloc在分配顺序连续的对象时,天然保证了它们的内存空间是接近的。你连续new一百个结构体,它们在堆上的位置大概率是连续的。这种顺序分配带来的缓存局部性是隐含福利。自定义池如果做成了“每次分配返回最新释放的那个块”,释放顺序一旦跟分配顺序不一致,就会产生地址跳跃,缓存命中率反而可能下降。这是我在一次高吞吐网关项目里实际碰到的情况——用了对象池之后,平均延迟没变好,P99反而变差了,查到最后就是局部性碎掉了。所以做性能对比的时候,不能只看分配器本身的开销,还得看它对业务内存访问模式的连带影响。
1.3 自定义分配器的不可替代之处:批量生命周期回收
那自定义分配器还有没有存在的必要?有,而且价值非常大,但价值点不在“每次分配少花几十纳秒”,而在“利用业务生命周期做批量回收”。
举个例子,一个游戏服务器每个帧会创建大量临时对象,帧结束之后这些对象全部不再使用。如果让malloc逐个释放,它要反复操作链表、合并相邻空闲块,还要考虑tcache的容量限制。但如果用一个Arena分配器,帧开始时把游标指到起始位置,帧结束后只需把游标重置,一次操作回收一整帧内存。省掉的不只是“释放”的耗时,还有释放时触发的内存整理和元数据写入。
同理,连接池、会话管理、RPC请求上下文这类“按请求/按会话分配和释放”的业务,用对象池或者Arena可以做到分配释放都不碰锁、不碰系统调用。这才是自定义分配器的核心价值所在。性能对比也应该围绕这个“生命周期匹配”来设计,而不是简单比谁在纯分配场景里跑得快。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 几种主流自定义分配器的实现原理与适用范围
2.1 对象池:复用自由链表,成本最低收益最直接
对象池是最常见、也最容易写过头的一种。核心思路是:预先分配若干个固定大小的块,用单向链表串起来。分配时从链表头部取一个节点,释放时把这个节点重新挂回链表头部。整个过程不涉及系统调用,也不涉及任何锁(单线程或线程局部线程安全的情况下)。
一个极简的实现长这样:
cpp复制template <typename T>
class ObjectPool {
public:
explicit ObjectPool(size_t preAlloc = 1024) {
grow(preAlloc);
}
T* alloc() {
if (!freeHead_) {
grow(kGrowSize);
}
Node* n = freeHead_;
freeHead_ = freeHead_->next;
return reinterpret_cast<T*>(n);
}
void dealloc(T* p) {
Node* n = reinterpret_cast<Node*>(p);
n->next = freeHead_;
freeHead_ = n;
}
private:
void grow(size_t count) {
size_t bytes = count * kNodeSize;
char* chunk = static_cast<char*>(::operator new(bytes));
for (size_t i = 0; i < count; ++i) {
char* addr = chunk + i * kNodeSize;
Node* n = reinterpret_cast<Node*>(addr);
n->next = freeHead_;
freeHead_ = n;
}
}
struct No
