
最近我把手头一个高并发服务的内存分配部分翻来覆去折腾了一遍核心就一件事自定义分配器到底值不值得做性能收益有多少又在什么场景下会翻车。网上聊这个话题的文章不少但大多停留在概念层面要么只给一个简单的定时分配次数对比要么干脆只贴代码不讲结论。我这次决定不绕弯子直接设计一组对照实验把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等类型定义。C11 之后还引入了allocator_traits可以帮我们补齐默认模板参数所以只需要实现核心的分配释放逻辑即可。这里有一个常见的误解自定义分配器并不一定意味着内存池。它可以只是对malloc的封装也可以是在某个固定大小的缓冲区上做 bump 指针分配。内存池只是自定义分配器的一种实现形式。而allocator替换方式也有几种在容器模板参数中显式指定例如std::vectorint, MyAllocatorint。scoped_allocator_adaptor用于容器嵌套场景让子容器共享同一个分配器实例。C17 引入的std::pmr::memory_resource提供了运行时的多态分配器机制配合std::pmr::vector使用可以在不改容器类型的前提下替换内存策略。从我实测体验来看如果只是给某个固定容器做替换模板方式最直观但如果要做全局的、跨容器的内存策略管理pmr的抽象层次更舒服。不过pmr需要C17及以上对于老项目来说升级成本要提前评估。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 次模拟流式数据处理的典型负载。这个场景重点看算法本身的分配释放耗时不涉及多线程锁竞争。场景 B16 线程同时进行同样大小的分配释放每个线程独立执行 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::durationdouble, std::milli(end_ - begin_).count(); } private: std::chrono::time_pointstd::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_castuintptr_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_castNode*(addr); n-next head_; head_ n; } } ~FixedPool() { ::operator delete(reinterpret_castvoid*(start_)); } void* allocate() { std::lock_guardstd::mutex lk(mu_); Node* n head_; head_ head_-next; return n; } void deallocate(void* p) { std::lock_guardstd::mutex lk(mu_); Node* n reinterpret_castNode*(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_MAX4这是为了消除glibcarena 自动扩展带来的影响。如果不设置这个环境变量glibc默认可能为每个线程创建独立的 arena高并发下行为会变得不可控导致数据重复性很差。这一点在做对比测试时尤其重要。4. 实测性能数据与结果分析4.1 各场景数据汇总经过完整测试后我把数据汇总成了下表。时间单位为毫秒每格数字代表总耗时括号里是 P99 单次分配释放耗时纳秒。在场景 A 和 C 中总耗时越低越好。分配器场景A单线程定长场景B16线程定长场景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)TLSFreelistAllocator87 ms (18 ns)1210 ms (260 ns)194 ms (30 ns)BumpAllocator12 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、线程缓存这些复杂度。这样每一步的收益都是可量化的出问题也容易定位。我在测试过程中反复用的就是这套方法等于是先搭了一个能快速观察内存分配行为的实验台后续所有实验都在这块地基上进行。希望这份对比数据和分析思路也能帮你少走一些弯路。