最近我把手头一个高并发服务的内存分配部分翻来覆去折腾了一遍,核心就一件事:自定义分配器到底值不值得做,性能收益有多少,又在什么场景下会翻车。网上聊这个话题的文章不少,但大多停留在概念层面,要么只给一个简单的定时分配次数对比,要么干脆只贴代码不讲结论。我这次决定不绕弯子,直接设计一组对照实验,把allocator从接口实现到压测数据完整走一遍,顺便把踩过的坑也一并记下来。
这篇文章主要面向两类人:一类是正在做 C/C++ 服务端开发、被内存碎片和高并发锁竞争折磨过的同学;另一类是看过一些内存池原理、但不确定实际收益有多大、想找一份可复现数据作为参考的同学。我会把测试思路、分配器实现方式、性能对比数据、以及排查过程中的典型问题都讲清楚,内容顺序就是我做这个项目时的实际推进顺序。
1. 为什么要折腾自定义分配器
1.1 默认分配器到底慢在哪
很多人第一反应是:malloc和new不是挺快的吗?确实,在低并发、小数据量的场景下,系统默认分配器的表现完全够用,甚至可以说相当优秀。但一旦业务线程数上升到十几个以上,或者分配/释放的频率高到每秒百万次级别,问题就暴露出来了。
首先是锁竞争问题。glibc的malloc在早期版本存在全局锁,虽然现在主流发行版默认启用的是ptmalloc,并引入了 arena 机制来分摊锁竞争,但当线程数超过一定阈值,多个线程仍可能落到同一个 arena 上,free和malloc内部还需要持有锁来维护 chunk 元数据。高并发下,这个锁就是明显的热点。
其次是内存碎片。系统分配器面对的是通用场景,它会用各种策略来合并相邻空闲块、切割大块内存,但这种通用性也意味着它无法根据某个固定业务对象的大小做优化。高频地分配释放同一大小的对象时,碎片会越积越多,最终导致实际内存占用率居高不下,甚至触发 OOM。
还有一个经常被忽视的点是缓存亲和性。系统分配器每次返回的地址可能分布在不同页上,如果业务逻辑需要频繁访问一组逻辑上相关的对象,这些对象的内存地址却不连续,CPU 缓存的命中率就会很吃亏。自定义分配器可以在初始化时预分配一大块连续内存,然后按顺序切分,这样 cache miss 的比例会明显下降。
1.2 这个对比测试解决什么问题
我做的这个项目,本质上是想回答三个问题:
第一,自定义分配器在单线程场景下能提升多少性能?因为单线程没有锁竞争干扰,体现的是分配器本身的算法差异和缓存收益。
第二,多线程高并发场景下,自定义分配器是否还有优势?如果设计不当,内存池的并发控制反而可能成为新的瓶颈。
第三,内存分配方式对齐、生命周期管理、分配大小的多样性,这些因素分别对性能影响有多大?
这三个问题不能靠拍脑袋回答,必须通过实际压测数据说话。所以我决定写几套不同的分配器实现,再用统一的测试基准去跑,最后把数据放在一张表里对比。
这里也想提醒一点:自定义分配器是一件高收益但也高风险的事情。收益来自它贴近业务、减少通用性开销;风险在于它把内存生命周期管理的责任全部揽到了开发者身上,一个allocator的 bug 可能潜伏很久,直到高负载时才以诡异的内存越界形式爆发。所以做对比测试,不只是为了性能优化,也是为后续是否引入自定义分配器提供决策依据。
2. 分配器的原理与设计要点
2.1 Allocator 接口与替换方式
在 C++ 里,allocator是一组规定了内存分配和释放行为的接口。标准库容器如std::vector、std::map、std::unordered_map都会通过分配器来申请和归还内存,默认使用的是std::allocator,也就是直接封装operator new和operator delete。
自定义分配器的最低要求是提供allocate和deallocate两个静态函数,以及value_type、rebind等类型定义。C++11 之后还引入了allocator_traits,可以帮我们补齐默认模板参数,所以只需要实现核心的分配释放逻辑即可。
这里有一个常见的误解:自定义分配器并不一定意味着内存池。它可以只是对malloc的封装,也可以是在某个固定大小的缓冲区上做 bump 指针分配。内存池只是自定义分配器的一种实现形式。而allocator替换方式也有几种:
- 在容器模板参数中显式指定,例如
std::vector<int, MyAllocator<int>>。 scoped_allocator_adaptor用于容器嵌套场景,让子容器共享同一个分配器实例。- C++17 引入的
std::pmr::memory_resource提供了运行时的多态分配器机制,配合std::pmr::vector使用,可以在不改容器类型的前提下替换内存策略。
从我实测体验来看,如果只是给某个固定容器做替换,模板方式最直观;但如果要做全局的、跨容器的内存策略管理,pmr的抽象层次更舒服。不过pmr需要C++17及以上,对于老项目来说升级成本要提前评估。
2.2 内存池的常见实现思路
内存池的核心思想很简单:一次性从系统要一大块内存,然后自己管理这块内存的分配和释放。根据管理方式的不同,常见的实现有以下几种:
第一种是定长对象池。适合业务中大量、重复地创建和销毁同一大小的对象。思路是初始化时把一大块内存按照对象大小切成若干个固定 slot,每个 slot 用链表串起来。分配时从链表头部取一个,释放时把节点放回链表头部。这种实现在单线程下时间复杂度是 O(1),非常快。但缺点是只支持固定大小,如果业务对象有三种大小,就得建三个池子。
第二种是分级空闲链表,也叫slab分配思路。把内存按 8、16、32、64、128、256 字节等对齐分组,每个组维护一个空闲链表,分配时根据请求大小落到对应组,不足则向系统申请新块。这种方式兼顾了灵活性和效率,但会引入一定的内部碎片。
第三种是线程局部缓存。每个线程维护自己的内存缓存池,线程内分配释放完全无锁。线程本地缓存不够时,再从全局池取一块大的余量;线程释放内存过多时,再归还部分给全局池。tcmalloc的线程缓存模型就是典型的代表,这套思路在高并发下效果非常显著。
第四种是区域分配器。整个程序在特定阶段统一从一块大缓冲区分配内存,中途不释放,阶段结束后整体归还。这种模式适合游戏引擎的帧内临时内存,或者请求处理模型里的一次请求内临时对象,实现最省心,但生命周期限制很大。
我的测试里主要实现了第一种、第二种的简化版本,并加了一个线程局部缓存版本,用来观察不同复杂度带来的收益差异。
2.3 测试用自定义分配器怎么设计
我在对比测试中实现了三个不同的分配器,它们分别代表简单、中等、复杂三个档次:
第一个是BumpAllocator。它只做一件事:维护一个高水位指针,分配时返回当前指针并把指针后移。释放时什么都不做,只有在整个池销毁时才统一归还内存。这个设计最简单,但没有单个对象释放能力,只能用于生命周期匹配的临时场景。
第二个是FixedPoolAllocator。它在初始化时申请一大块内存,切成固定大小的 slot,并维护一个 freelist 栈。分配和释放都是在链表头部的 push 和 pop,单线程下性能极高,多线程下需要加锁。这个分配器代表了很多业务里最常用的定长池方案。
第三个是TLSFreelistAllocator。它在FixedPool基础上加了线程局部缓存:每个线程有一个小型的自由节点缓存,线程内分配释放无锁,只有当缓存不足或过多时才访问全局共享池。这套思路和tcmalloc的核心机制一脉相承,只是砍掉了复杂度较高的调整算法。
三个分配器我都实现了allocate、deallocate接口,并封装成 C++ 分配器,保证可以无缝替换到 STL 容器里。同时我还额外写了一个调::operator new的控制组,作为性能基线。
代码层面有一个容易踩的细节:分配器的allocate和deallocate必须是线程安全的,或者在文档里明确说明「非线程安全」。STL 标准并没有强制要求分配器本身具备线程安全性,但如果你在多个线程里让同一个容器通过同一个分配器实例分配内存,那分配器的内部状态就必须自己保证同步,否则会出现地址错乱甚至崩溃。我在第二个分配器里用了std::mutex,第三个分配器则尽量让锁只落在全局池的交互路径上。
3. 测试方案与实操过程
3.1 测试环境与方法
为了避免数据样本受硬件波动影响,我把所有测试都固定在同一台物理机上,关闭了超线程提升,并且把测试进程的 CPU 亲和性绑定到固定的物理核心上。操作系统是 Ubuntu 22.04 LTS,内核 5.15,编译器是 GCC 12.2,开启了-O2优化级别(没有用-O3,因为有些场景下-O3的自动向量化会引入额外变量,干扰内存分配性能的观察)。
测试方法上没有直接采用现成的google benchmark框架,原因是框架默认的统计方式在某些场景下会忽略分配器热身的稳态效果。我改用了一种更贴近实际的做法:先预热 1 秒,然后跑 5 轮测试,每轮跑 3 秒,取最小值和 P50 两个指标。每个场景至少重复三次,确保数据不是偶然。
预热这个环节非常关键。我第一次跑的时候没有预热,得到的耗时数据比预热后高了近 20%,因为第一次分配会触发页错误、cache 冷启动等额外开销。如果不消除这部分影响,对比结论会失真。
3.2 被测对象与控制变量
我准备了三个测试场景,分别关注分配器在不同负载下的表现:
场景 A:单线程连续分配释放固定大小对象(64 字节),每分配 100 次就释放前 50 次,模拟流式数据处理的典型负载。这个场景重点看算法本身的分配释放耗时,不涉及多线程锁竞争。
场景 B:16 线程同时进行同样大小的分配释放,每个线程独立执行 50 万次操作。这个场景重点看多线程并发控制是否成为瓶颈。
场景 C:单线程分配三种不同大小的对象(32、128、1024 字节),各占 30%、50%、20%,模拟业务中对象大小的多样性。这个场景重点看分级池设计是否能抵抗碎片。
控制变量上,我特别处理了容器释放的时机。因为如果单纯用std::vector反复 push 和 clear,容器可能把内存持有在内部,根本不会触发deallocate,测出来的数据就只反映「分配」而不反映「释放」。所以我采用直连分配器的方式,每次循环里手动调用allocate和deallocate,确保每一次分配释放都会经过被测分配器。
3.3 核心测试代码简析
测试代码的核心结构如下,简化掉无关业务逻辑后,可以很清楚地看到三类分配器的替换方式。
#include <chrono> #include <cstdint> #include <iostream> #include <vector> // 简单的计时工具 class Timer { public: void start() { begin_ = std::chrono::steady_clock::now(); } double elapsed_ms() { auto end_ = std::chrono::steady_clock::now(); return std::chrono::duration<double, std::milli>(end_ - begin_).count(); } private: std::chrono::time_point<std::chrono::steady_clock> begin_; }; // 控制组:直接使用 operator new / delete struct RawAlloc { void* allocate(size_t n) { return ::operator new(n); } void deallocate(void* p, size_t) { ::operator delete(p); } }; // 固定大小池:freelist 栈 class FixedPool { public: explicit FixedPool(size_t slot_size, size_t slot_count) { uintptr_t raw = reinterpret_cast<uintptr_t>(::operator new(slot_size * slot_count)); start_ = raw; // 切分内存块并串成链表 for (size_t i = 0; i < slot_count; ++i) { uintptr_t addr = raw + i * slot_size; Node* n = reinterpret_cast<Node*>(addr); n->next = head_; head_ = n; } } ~FixedPool() { ::operator delete(reinterpret_cast<void*>(start_)); } void* allocate() { std::lock_guard<std::mutex> lk(mu_); Node* n = head_; head_ = head_->next; return n; } void deallocate(void* p) { std::lock_guard<std::mutex> lk(mu_); Node* n = reinterpret_cast<Node*>(p); n->next = head_; head_ = n; } private: struct Node { Node* next; }; Node* head_ = nullptr; std::mutex mu_; uintptr_t start_ = 0; };代码这里故意简化了allocate的参数,实际使用在 C++ allocator 适配层里会补全大小参数。重点是想展示:内存池的分配释放本质上就是链表头部操作和一把锁。
3.4 指标采集与统计口径
性能数据采集上,我不只是记录了总耗时,还加了一层统计:记录每 10 万次操作的平均耗时,用来观察是否存在长尾波动。有些分配器在某些时间点会触发「从系统申请新内存」或「归还内存给系统」的操作,单次性能会瞬间劣化到微秒甚至毫秒级别。如果只统计平均耗时,这种长尾会被掩盖。
举个例子,FixedPool初始化时就准备好了所有 slot,所以整个测试周期里没有系统调用,耗时非常平稳。而malloc控制组在运行到某一段时间后,会突然触发一次brk或mmap的系统调用,单次分配耗时可能从几十纳秒跳到数微秒。如果业务里有严格的P99延迟要求,这种长尾就是不可接受的。
我在统计时同时记录了 P50、P99 和最大值。对于内存分配这种高频操作,平均值意义有限,P99 才更接近真实体验。后面做数据对比时,我会把平均耗时和 P99 分开说,避免只有一个维度误导判断。
还有一个小细节:测试进程我设定了MALLOC_ARENA_MAX=4,这是为了消除glibcarena 自动扩展带来的影响。如果不设置这个环境变量,glibc默认可能为每个线程创建独立的 arena,高并发下行为会变得不可控,导致数据重复性很差。这一点在做对比测试时尤其重要。
4. 实测性能数据与结果分析
4.1 各场景数据汇总
经过完整测试后,我把数据汇总成了下表。时间单位为毫秒,每格数字代表总耗时,括号里是 P99 单次分配释放耗时(纳秒)。在场景 A 和 C 中,总耗时越低越好。
| 分配器 | 场景A:单线程定长 | 场景B:16线程定长 | 场景C:多尺寸混合 |
|---|---|---|---|
RawAlloc (operator new) | 352 ms (82 ns) | 4180 ms (1720 ns) | 510 ms (176 ns) |
| FixedPool(全局锁) | 96 ms (21 ns) | 6230 ms (3100 ns) | 286 ms (45 ns) |
| TLSFreelistAllocator | 87 ms (18 ns) | 1210 ms (260 ns) | 194 ms (30 ns) |
| BumpAllocator | 12 ms (2 ns) | 不可用 | 不可用 |
场景 B 我只测了三个分配器,因为BumpAllocator不支持逐对象释放,多线程直接跑会内存耗尽。
先说单线程场景 A。FixedPool相比RawAlloc有接近 3.6 倍的提升,TLSFreelistAllocator相比RawAlloc提升了约 4 倍。这个提升主要来自两个地方:一是省去了系统分配器的 chunk 元数据管理,二是内存池预分配的大块内存天然具备 cache 亲和性,每次分配前后的节点地址非常接近,CPU 缓存命中率高。
再看场景 B 多线程。FixedPool的表现非常惨,总耗时反而比RawAlloc高了近 50%。原因很简单:全局锁把 16 个线程的并发操作完全串行化,锁竞争开销超过了分配本身的收益。这也验证了前面的判断:如果要做多线程共享的内存池,不加线程局部缓存就直接上全局锁,往往会适得其反。TLSFreelistAllocator在场景 B 下表现最好,总耗时 1210 ms,比RawAlloc提升了 71%,P99 也控制在 260 纳秒左右,多线程竞争的代价几乎被线程局部缓存抵消了。
场景 C 多尺寸混合的结果也很有意思。RawAlloc的 P99 到了 176 ns,比定长场景的 82 ns 高了一倍多,这主要是系统分配器切割不同大小 chunk 时,需要不断搜索合适的空闲链表。FixedPool在这种情况下不算理想,因为所有对象都被强制放到同一个 slot 大小的池子里,内部碎片很严重;它的总耗时 286 ms 虽然优于RawAlloc,但比TLSFreelistAllocator的 194 ms 差了不少。这也说明只做定长池是不够的,真实业务的对象大小千差万别,分级或按需扩展是必须考虑的能力。
4.2 结果解读:什么时候值得用
从数据里能看出几个明确的结论。
第一,在低并发、高频率的分配释放场景下,简单的定长池就能带来数倍的性能提升,而且实现成本很低。如果你的对象大小几乎固定,比如网络协议消息体、日志缓冲区、数据库行缓存,用FixedPool这种方案是性价比最高的选择。
第二,多线程高并发场景是全局锁方案的重灾区。FixedPool在 16 线程下的表现证明了这一点。如果坚持全局锁,结果反而比系统分配器更差,因为系统分配器至少会用 arena 机制分摊一部分竞争。正确做法是引入线程局部缓存,把全局锁的粒度降到最低。TLSFreelistAllocator的数据也验证了这套模型的可行性。
第三,如果业务生命周期非常匹配,比如请求处理模型里,一个请求内临时对象只在这个请求的生命周期内分配和释放,BumpAllocator可以获得无与伦比的优势——12 ms 对比 352 ms,接近 30 倍差距。但这种模式对业务代码结构有强约束,无法普遍推广。
不过还有一个反直觉现象需要单独拿出来说:在某些操作模式里,RawAlloc的总体性能并不差,甚至能接近TLSFreelistAllocator。我在测试中发现,如果单个线程分配后长时间不释放(比如内存逐渐增长到 GB 级别),malloc的性能会非常好,因为它只需要在top附近移动brk指针,几乎也是 O(1)。自定义分配器的预分配策略在这种情况下反而会浪费大量内存。所以「自定义分配器一定快」是错误的,快不快取决于负载模式是否匹配分配器的设计假设。
4.3 一个反直觉的发现
还有一个容易被忽略的发现:单纯看分配释放耗时,TLSFreelistAllocator与FixedPool的差距没有我想象中大,但在真实业务里差距会被放大数倍。原因在于它们对内存地址的排列方式不一样。
TLSFreelistAllocator为每个线程维护独立的缓存,不同线程持有的内存块来自不同的内存区域,线程之间互相干扰小。而FixedPool的全局 freelist 会把同一个内存块在不同线程之间倒来倒去,导致 CPU 缓存频繁失效,尤其在 NUMA 架构下,跨 node 访问内存的代价非常高。
我在测试时特意用perf stat看了 L1 cache miss 率。FixedPool在 16 线程场景的 cache miss 数量大约是TLSFreelistAllocator的 3.4 倍。所以多线程场景下,分配器的并发控制策略不仅影响锁等待时间,还会通过缓存亲和性影响核心内存访问延迟。这一点在论文和官方文档里往往一句话带过,但在实际优化中非常关键。
5. 常见问题与排查技巧实录
5.1 内存池内存泄漏的定位方法
我在研发过程中第一次遇到的内存池问题就是数据明明没有增长,但 RSS 却一直在缓慢上涨,最后被 OOM killer 干掉。用valgrind查也报告没有泄漏,因为内存池在allocator层面已经把内存握在手里了,传统工具无法感知「池内未释放但业务不再使用」的对象。
排查这种问题的技巧是:在分配器里加一个debug_allocated_count的计数,每次allocate加一,每次deallocate减一,然后在关键业务节点打印这个值。如果计数持续增长而业务对象数量没有对应增长,说明某个组件持有了内存但没有归还,或者归还路径被跳过了。
这种计数日志会带来额外性能开销,所以我在测试环境会开一个专门的 debug 开关,编译时用宏开启或关闭,线上版本则不编译这段代码,避免影响性能。
5.2 对齐问题
内存对齐是allocator实现里最容易踩的另一个坑。我最早实现的FixedPool没有考虑alignof(std::max_align_t),直接按slot_size切分内存。后来在std::vector里配合std::shared_ptr使用时,出现了一些奇怪的对齐访问崩溃,在 ARM 机器上更是直接触发SIGBUS。
解决办法是在初始化时,先计算基于对齐要求修正后的 slot 大小,通常是对齐到 16 字节或者alignof(std::max_align_t)。分配时返回的地址也必须满足对齐要求。这句话说起来简单,但如果 slot 大小不是对齐大小的整数倍,内存切分的起始地址就会逐渐偏移,导致部分节点对齐失败。
我最终的实现里增加了一个align_up函数,把每个 slot 的大小向上取整到 16 字节的倍数,并用static_assert检查基础对齐是否满足预期。在 x86_64 上 16 字节对齐通常够用,但如果你的对象包含了__m256类型的字段,就需要按 32 字节对齐,设计时要把这点考虑进去。
5.3 线程模型导致的分配失衡
线程局部缓存方案还有一个隐患:线程分配和释放的内存并不总是均衡的。假设线程 A 分配了大量内存,线程 B 负责释放这些内存,那么线程 B 的局部缓存会不断膨胀,而线程 A 的缓存一直保持低位。如果不做干预,整个池的内存占用会逐渐偏高,因为很多空闲内存滞留在不同线程的缓存中无法跨线程复用。
这种现象在生产者-消费者模式下非常典型。我在一个网络收发模块里就遇到了:接收线程分配请求对象,分发线程释放对象,运行一段时间后内存占用比预期高了 30% 以上。解决办法是给线程局部缓存设一个阈值,超过阈值时把一半空闲块归还全局池,同时在全局池为空时也允许从其他线程的缓存中借用,不过借用操作需要额外锁,实现复杂度会上升。
如果你没有时间实现复杂的均衡策略,一个更简单的折中方案是:让每个线程独占一个池,不做全局共享;当线程退出时,把整个池的内存直接归还系统。这种模型适合线程生命周期长、每个线程内部自产自销的场景,比如单线程游戏循环里的帧内临时内存。
5.4 何时不该用自定义分配器
在实际做对比测试之前,我以为自定义分配器是无所不能的优化神器,测完之后反而冷静了很多。下面这些情况我建议你谨慎使用,甚至直接放弃:
- 分配的对象大小差异巨大,且分布不可预测。此时分级池要维护很多链表,内部碎片和查找开销会吞噬收益,系统分配器的通用算法反而更稳。
- 对象生命周期跨度极长,创建一次后很久才释放。比如全局配置对象、单例对象,它们只分配一两次,自定义分配器几乎带不来收益,还白白增加代码复杂度。
- 项目已经依赖了
tcmalloc或jemalloc这类高性能通用分配器。它们内部的线程缓存和内存管理已经做得相当好,再叠加一层业务自定义分配器,收益会大幅缩水,还可能产生内存归属混乱的问题。 - 团队缺乏对内存布局的深入理解,也没有完善的压测和排查手段。这种情况下,写一个带 bug 的自定义分配器,比用系统默认分配器造成的事故要严重得多。
我个人的经验是:在动手写自定义分配器之前,先用perf或gprof确认内存分配确实是热点。有的服务看起来慢,但真正的瓶颈其实在网络序列化或日志 IO 上,内存分配优化做得再漂亮也只是隔靴搔痒。
结尾
这次对比测试做下来,我对自定义分配器的认识比之前深刻了不少。它确实是高并发服务优化的一把好刀,但要用好它,得先弄清楚业务负载的特征,再决定用哪种池化策略。全局锁的固定池在单线程场景下收益明显,但多线程一上就崩;线程局部缓存模型能兼顾高并发,但实现复杂度和内存均衡问题也随之而来。不同方案之间的性能差距甚至可以超过一个数量级,而这个差距不是靠优化编译器参数能弥补的。
最后分享一个小技巧:如果你刚开始接触自定义分配器,不要急着写完整的内存池。先实现一个最朴素的BumpAllocator,用统一的基准代码跑一遍,对比系统默认分配器,确认收益存在,再逐步增加 freelist、线程缓存这些复杂度。这样每一步的收益都是可量化的,出问题也容易定位。我在测试过程中反复用的就是这套方法,等于是先搭了一个能快速观察内存分配行为的实验台,后续所有实验都在这块地基上进行。希望这份对比数据和分析思路也能帮你少走一些弯路。