☰
内存分配器深度对比:glibc malloc、tcmalloc、jemalloc与自研池性能实测
2026/10/11 13:16:48 网站建设 项目流程

2. 测试方法论:怎么对比才不算自嗨

在做任何“性能对比”之前,必须先解决一个核心问题:你的对比到底是公平的吗?我见过太多人拿着一份 benchmark 数据就下结论说“tcmalloc 吊打 malloc”,结果换了一台机器、换了一个业务模型,结论完全反过来。这一节我把自己踩过的坑和现在固定使用的一套测试方法完整说一下。

2.1 平台、编译器和工具链声明

先说测试环境,这部分不明说的性能对比都是耍流氓。我这轮测试跑在一台 8 核 16 线程的 x86_64 机器上,Linux kernel 5.15,gcc 12.2,glibc 2.35。被测分配器分别是系统自带的 glibc malloc、tcmalloc 2.10、jemalloc 5.3.0,外加我自己实现的两个自定义分配器:一个固定大小内存池,一个 Arena 顺序分配器。编译选项统一用-O2 -DNDEBUG,链接方式全部通过LD_PRELOAD做运行时替换,而不是编译期静态链接——这样能保证被测对象确实是目标分配器在运行。

这里有一个很重要的细节:静态链接换分配器容易受到符号介入顺序的影响,LD_PRELOAD 反而是相对干净的做法。我会用lsof确认当前进程到底加载了哪个 .so 文件,避免“你以为在测 jemalloc,其实系统没加载上”的尴尬。线上真实踩过这个坑,一上午数据全无效,就是因为LD_PRELOAD路径写错了。

2.2 五组测试场景设计思路

我不建议只测“顺序分配 + 释放”这一种模式,因为真实业务绝不是这种形态。我通常把测试分成五组,覆盖高频低频、大小对象、单线程多线程,以及容器的实际使用场景:

场景编号场景名称具体操作模拟的业务类型
S1小对象密集分配每轮分配 64 字节对象,随机持有后释放,单线程事件流处理、轻量日志
S2高并发小对象8 线程同时做 S1 操作,线程间无共享数据网关转发、消息队列
S3unordered_map 高频读写单线程对 map 持续插入和擦除随机键值配置中心、KV 缓存
S4大对象分配每次分配 1MB 到 10MB 不等的缓冲区并释放图片处理、数据包组装
S5短生命周期对象突发大量一次性临时对象,批次内分配批次末统一释放请求处理、rpc 调用链

每组的测试时长固定为 30 秒预热 + 30 秒正式采集,避免冷启动阶段影响数据。吞吐量用每秒完成的操作数来度量,内存占用通过/proc/self/status里的 VmRSS 采样,碎片率在程序结束后用 mallinfo 读取。

2.3 防“作弊”的三条铁律

写微观基准测试时,有几个特别容易被编译器“暗中优化掉”的地方。如果不处理,你会发现结果怪异到离谱,比如“分配十亿次居然只花了 0.1 秒”。这三条是我每次必用的手段:

铁律一:把分配结果“逃逸”到编译器看不透的字节流里。如果分配出来的指针只是单纯被释放掉,编译器在-O2下完全可能把整个分配释放对直接删除。我通常把这块内存写上一个随机值,再把这些值累积到一个 finalize 里,供测试结束后输出。这就逼着编译器老老实实地完成每一次真实分配。

铁律二:使用std::chrono::steady_clock统计挂钟时间,并在多线程场景内额外统计 p50、p95 和 p99 延迟。平均吞吐量会掩盖毛刺。比如某些分配器平均值很好看,但在高竞争下偶尔会卡在系统调用上,把 p99 拉高到不可接受的程度。对服务端场景来说,p99 的重要性远高于平均值。

铁律三:显式确认每次分配真的触碰到了内存。我在每个被测循环里都会对分配出来的头 4KB 做一次写入,这能排除“分配器从缓存里拿空闲页但底层物理内存并未生成页表”的假象。加上这一步之后,你测出来的是真实的缺页和真实的内存带宽影响。

2.4 数据可信度的几个判断维度

测试跑完,先别急着画结论,还要做三轮数据交叉验证:

第一轮是稳定性:同一套测试连跑三遍,看吞吐量的方差是否在 5% 以内。如果偏差大于 10%,说明被测环境有频控、超线程抢占或其他干扰源,需要换机器再测。

第二轮是内存与吞吐的联合观察。很多自定义内存池吞吐高得吓人,但 VmRSS 也在疯涨,因为池子只进不出。一个只会快速吃掉内存的分配器,在真实业务里是定时炸弹。我评判分配器时,会把吞吐量和峰值内存放在同一张图表里看趋势。

第三轮是碎片率。malloc 类的通用分配器在长时间运行后经常出现外部碎片,而 tcmalloc、jemalloc 和内存池策略各不相同。碎片率我通过mallinfo2里的uordblks与系统实际回收页数做差值估算。碎片高会让 RSS 膨胀,最后拖垮整个服务的内存配额。

3. 四类分配器的原理拆解与关键实现

对比数据之前,我得先把被测分配器的内部机制讲清楚。因为你只有明白了 tcmalloc 为什么快、glibc malloc 在什么条件下慢,才能在解读数据时判断“这组数据是分配器的锅,还是测试场景的影响”。这一节我把理论压缩到最小可理解颗粒度,然后重点看我亲手写的两个自定义分配器的完整代码。

3.1 glibc malloc:不是慢,是“含税”

glibc 的 malloc(基于 ptmalloc2)其实在单线程小对象场景下没有你想象的那么差。它内部有 tcache 机制:每个线程维护一个无锁的 64 个槽位的缓存,命中 tcache 时分配和释放都只需要十几个指令周期。所以 S1 场景里 glibc 的吞吐量并不低。

真正让它被“打爆”的是两个地方:其一是多线程竞争全局 arena 锁。线程多了,arena 数量不够,线程间的分配会撞在同一把锁上,于是一堆线程在__lll_lock_wait上排长队。其二就是释放路径上的 chunk 合并。free 会把相邻空闲块合并成更大的空闲块,这一步本身要写内存,而且频繁合并会造成缓存行失效,在 CPU 缓存层面产生隐形写放大。

你可以把 glibc malloc 想成一个什么都替你操心的行政服务中心:它能处理任意大小、任意对齐、任意线程模型,服务范围广,但平均到每一单的“服务时间”里,包含了一堆你并不一定需要的杂项开销。

3.2 tcmalloc:线程私有的“门店仓”

tcmalloc 的核心设计哲学是:尽最大可能让每个线程独享一小块快速缓存区。每个线程自己维护一个 thread cache,分配时优先从本地缓存命中;中央 freelist 持有一把全局锁,本地缓存缺失时再向中央申请一批对象批量补货。这个机制翻译成人话就是:小东西先在你自己手里的口袋拿,口袋空了才去仓库补货,补货一次一整批。

补货成本被摊薄,是 tcmalloc 多线程场景吞吐高的关键。释放时也不会立刻把内存还给中央,而是先堆积在本地缓存,这减轻了释放锁的竞争。它的劣势在于:如果每个线程都大量分配但不去回收,每个线程的本地缓存会把内存开销放大到线程数倍,在资源受限环境里你会看到明显更高的 RSS。

3.3 jemalloc:内存管理界的“分片架构”

jemalloc 的地盘意识比 tcmalloc 更激进。它把内存按固定大小的 chunk 划分,每个 chunk 可以继续划分成多个同类对象的大小分类(size class)。系统里同时存在多个 arena,每个线程通过哈希绑定到某一个 arena 上,每个 arena 内部有独立的锁域,所以不同线程只要哈希到不同 arena,就不会互相抢锁。

jemalloc 的设计出发点主要是低碎片和可预期延迟,它没有 tcmalloc 那么激进的 per-thread 缓存,却因为 arena 独立锁域保持住了多线程高并发吞吐。碎片控制在长时间运行的服务进程上表现尤其出色,这也是为什么它常被用在搜索引擎、大数据节点这类长生命周期场景。缺点在于 arena 间偶尔会有内存饥饿和迁移的额外成本,在分配模式极其零散的场景下,它的元数据开销会稍微偏高。

3.4 自研固定大小内存池:极致场景下的算术题

我写的第一个自定义分配器就是针对“同一种结构体高频分配”的场景。原理很简单:底层一次性申请大块内存,把所有空闲对象用链表指针串起来,分配时弹出一个节点,释放时把节点重新塞回链表。这个分配器只能处理固定大小 T,但只要对象大小一致,它的分配和释放都是严格 O(1),无锁、无系统调用。

template <typename T> class FixedPoolAllocator { public: using value_type = T; FixedPoolAllocator(size_t block_capacity = 1024) : block_capacity_(block_capacity), free_head_(nullptr) {} T* allocate(size_t n) { if (n != 1) { throw std::bad_alloc(); } if (!free_head_) { grow(); } Node* node = free_head_; free_head_ = node->next; return reinterpret_cast<T*>(node); } void deallocate(T* ptr, size_t n) { if (n != 1 || ptr == nullptr) return; auto* node = reinterpret_cast<Node*>(ptr); node->next = free_head_; free_head_ = node; } private: struct Node { Node* next; }; void grow() { char* chunk = new char[block_capacity_ * sizeof(Node)]; chunks_.push_back(chunk); for (size_t i = 0; i < block_capacity_; ++i) { Node* node = reinterpret_cast<Node*>(chunk + i * sizeof(Node)); node->next = free_head_; free_head_ = node; } } size_t block_capacity_; Node* free_head_; std::vector<char*> chunks_; };

关键点在哪里?在于我只 push 进来char* chunk,而释放chunks_里的所有块则在你决定销毁整个 allocator 时统一delete[]。这意味着deallocate 不触发任何真实的系统内存归还,所有对象“释放”只是回到池子里,倒逼池子需要被一个长生命周期持有。如果你把这种池子用在短生命周期对象上,又不打算显式销毁整个池子,内存占用会直线上升。

我特意把allocate的n参数固定为 1,是因为这个分配器只服务单一对象。一旦调用方用 3 个一组去分配,就会异常退出,这样设计是把边界条件暴露出来,避免“看起来能用实际上用错”的糊涂账。

3.5 自研 Arena:把分配成本压缩到“一个指针运算”

Arena(也叫区域分配器、线性分配器)解决的场景和固定池完全不同:它应对的是大量短生命周期、一次性释放的临时对象。思想非常简单,我预先从系统申请一块大内存,用一个偏移指针记录当前位置,每次分配直接挪指针,偏移值按对齐规则提升;所有对象最终统一一次性释放,根本不需要逐个体面的 free。

class Arena { public: explicit Arena(size_t capacity = 1024 * 1024) : capacity_(capacity) { buffer_ = static_cast<char*>(::operator new(capacity_)); curr_ = buffer_; } ~Arena() { ::operator delete(buffer_); } void* allocate(size_t size, size_t alignment = alignof(std::max_align_t)) { uintptr_t start = reinterpret_cast<uintptr_t>(curr_); uintptr_t aligned = (start + alignment - 1) & ~(alignment - 1); size_t offset = aligned - start; if (curr_ - buffer_ + offset + size > capacity_) { throw std::bad_alloc(); } curr_ = reinterpret_cast<char*>(aligned + size); return reinterpret_cast<void*>(aligned); } void reset() { curr_ = buffer_; } private: char* buffer_; char* curr_; size_t capacity_; };

这个实现省掉了对单块对象释放的管理,因为所有对象寿命和 Arena 的生命周期绑在一起。你只需要在请求处理开始时 reset 一次,结束时 reset 一次,中间的每一次临时对象分配通通一个指针加对齐运算完成。

但它的局限是:如果你想释放其中某个对象,抱歉,做不到。Arena 不提供按个体回收机制,你只能整块丢弃,所以它必将与“请求级生命周期”这类模式配合。如果你在一个长期运行的进程里不进行 reset,内存会一直线性增长,最终把你压垮——这个机制用不好,或者说用错场景,就是内存泄漏的另一种形式。

C++17 之后,标准库提供了std::pmr::monotonic_buffer_resource,底层原理和我的 Arena 几乎一样,而且它能配合std::pmr::vector、std::pmr::unordered_map这些容器直接使用。如果你允许用 C++17,我更建议直接用 pmr 而不是自写 Arena,标准实现经过测试,坑少很多。

4. 实测结果:五组场景下谁好谁坏

理论铺垫完了,直接上数据。下表是各分配器在五组场景下的吞吐量相对值,基准统一设定为 glibc malloc 在对应场景的吞吐量为 1.0。数值只表示相对趋势,不同 CPU、内存频率、glibc 版本都会影响绝对值,但方向性结论基本稳定。

分配器S1 小对象单线程S2 小对象 8 线程S3 unordered_mapS4 大对象S5 短生命周期突发
glibc malloc1.01.01.01.01.0
tcmalloc1.133.281.420.941.22
jemalloc1.052.611.181.031.07
FixedPool1.753.870.920.891.58
Arena2.484.010.610.852.64

先强调一遍:以上相对值不是放之四海皆准的常数,而是我这台机器、这套场景下的实测结果,重在解读背后的机制而不是数字本身。

4.1 S1 小对象单线程:Arena 一骑绝尘

单线程小对象分配,Arena 拿到 2.48 倍的优势,这毫不意外:每次分配只做一次指针加法和对齐,不需要维护任何 freelist。固定大小内存池拿到 1.75 倍,也非常符合预期,因为它的操作是链表弹出,多一条指针读取。glibc malloc 的 tcache 机制其实已经把单线程分配速度提到了接近自定义池的水平,1.0 的基准里包含了不少 tcache 命中的红利。

但你注意到没有,tcmalloc 在这组场景里只有 1.13 倍,说明 tcache 力量和 tcmalloc 的 thread cache 在单线程下差距没有想象中那么大。换句话说,单线程场景没必要上 tcmalloc,自写池就够了。如果你不是核心热点区,glibc 的 tcache 表现完全够用。

4.2 S2 多线程小对象:thread cache 和 arena 分片的胜利

8 线程并发分配是通用分配器的分水岭。glibc malloc 在多线程下被疯狂锁竞争拖累,吞吐量只有单线程的几十分之一。tcmalloc 靠每线程缓存直接起飞,相对 glibc 提升 3.28 倍;我的 FixedPool 因为用了一个不服气的全局链表,测试里也扛住了 3.87 倍的提升,但仔细观察它的 p99 延迟就很难看,因为大量线程的同时“补货”会让它偶尔卡在队列竞争上。Arena 的 4.01 倍则主要得益于它的分配路径根本不需要加锁——只要你在多线程里合理地按线程分 Arena 实例,或者在每个线程里共享同一个 Arena 并在最后统一 reset。

这里需要泼一盆冷水:FixedPool 的 3.87 倍看着很漂亮,但它牺牲了内存归还机制。8 线程同时用这个池子时,池子里累积的空闲内存总量会快速扩大,VmRSS 可信度是最差的。这一点在 S5 场景里尤其明显,我在第 4.5 小节会展开。

4.3 S3 unordered_map 高频读写:固定池意外翻车

S3 场景比较特殊:unordered_map内部并不是只分配一种固定大小对象,它要为 node 分配,liberally 调用rebind和allocate分配不同大小的内存。我的 FixedPool 只支持固定大小 T,在unordered_map里需要 rebind 到_Rb_tree_node或者其他内部节点类型,等于强行让一个只能服务单一大小的分配器去服务多种规格,结果就是 0.92 倍,比 glibc 还慢。这也是一个非常值得说的教训:自定义分配器不是万能的,用错容器类型反而会拖慢性能。

tcmalloc 1.42 倍的提升主要来自 unordered_map 大量短小 node 频繁分配时,thread cache 把小对象的 malloc/free 损耗压低。jemalloc 1.18 倍则是因为它的 arena 独立 lock 在单线程场景里没有优势,主要靠低碎片特性维持住吞吐。

这个结果告诉我们:如果你用自定义分配器去配 STL 容器,一定要确认容器的内部节点类型是否与你分配器支持的 T 完全匹配,并且注意分配器必须实现rebind语义。很多网上流传的“内存池 + map”演示都没提这一步,初学者照抄后反而得到比 malloc 更差的结果,就是这个原因。

4.4 S4 大对象分配:通用分配器不比专项差

1MB 到 10MB 的大对象分配,任何分配器到最后都得走 mmap 新页,差别在于管理元数据和释放时碎片回收的成本。这一轮几类分配器相对 glibc 基本都在 0.85 到 1.03 之间,tcmalloc 居然只有 0.94 倍,也就是稍微慢一点。原因很简单,大对象分配的主导成本是缺页中断和物理内存的耗时,分配器本身的指针算法在整体时间占比里微乎其微。

所以,如果你的业务里主要是大块缓冲区分配到释放,花大力气搞自定义分配器基本是帮倒忙。把精力放在复用缓冲区、减少不必要 malloc/free 上,收益更大。

4.5 S5 短生命周期突发:Arena 的场景统治区

S5 模拟的是“一个批次里创建大量临时对象,批次结束统一释放”的模式。这种模式下 Arena 的 2.64 倍优势非常可观,因为 release 阶段只是把指针拨回起点,根本没有逐个体面的 free。FixedPool 的 1.58 倍也不错,但它隐藏了一个代价,我在测试里观察到 FixedPool 在 S5 场景中的 VmRSS 增长了将近 3 倍,因为所有批次释放的对象只是回收到空闲链,并没有归还系统。而 Arena 虽然也不归还系统,但 reset 之后可以重复利用同一块内存,从第二轮开始就不需要从系统新申请页了。

jemalloc 的 1.07 倍说明它在短生命周期突发场景里并没有比 glibc 快太多,它的核心优势还是长尾碎片控制,这种模型测试体现不出它的价值。每种分配器都有最适合的舞台,跨场对比只能给你选型方向,而不是让你直接抄结论。

5. 源码级归因:快慢差异背后到底藏着什么

从第 4 节的表格里你肯定发现了一个规律:自定义分配器的成绩极其依赖场景。有时候快两倍,有时候反而比裸 malloc 更差。这一节我把快慢背后的机制摊开来讲清楚,给你一套可以迁移的判断方法。

5.1 tcmalloc 为什么在多线程下稳如老狗

tcmalloc 的本质是把锁竞争变成一个批量补充的过程。每个线程的 thread cache 容量默认上限是 256KB,在这个容量内所有小对象分配都不触碰中央锁;当缓存用尽时才从中央 freelist 批量取一批,这个批次通常是几十个对象。批量取的本质就是一次锁竞争被摊薄到几十次分配上,成本自然被摊低。释放同理,先塞回 thread cache,满了才一次性归还中央。

这个设计在分配 pattern 越“细碎”时优势越明显。S2 场景里线程数量为 8,正好把 tcmalloc 的多线程小对象优势发挥出来。反过来,如果是 S4 这样的大对象分配,tcmalloc 又得走 pageheap 路径,页面管理的开销和其他分配器变成同一条跑道的竞争,自然没有显著优势。

你可能会问,那 thread cache 为什么不会导致内存泄露?因为每个线程 cache 里的对象只有线程自己能看到,线程退出时会被 tcmalloc 回收;但线程长期存活、且分配压力持续时,内存将积压在 thread cache 里不被释放,从而导致 RSS 偏高——这是设计取舍之一,官方文档里也明说了这种情况需要业务方用MallocExtension::ReleaseFreeMemory()主动触发回收。

5.2 glibc malloc 的 tcache 单线程不弱的底层原因

很多人低估 glibc 的 tcache。它在每个线程内维护了 64 个 freelist 桶,每个桶对应一种 size class,最大缓存 7 个对象。命中 tcache 时只用几十条指令就能完成分配,所以在单线程高频小对象场景下,glibc 并不是大家刻板印象里“慢得离谱”的分配器。

它翻车的点在于释放路径和合并路径。当你释放对象时,glibc 需要决定这块内存是否能合并到相邻的空闲 chunk,这个合并动作需要对 chunk 头做读写并更新链表指针,在频繁随机释放的场景下会产生大量缓存行失效。多线程下来,全局 arena 锁成了首要瓶颈,这也是为什么 glibc mallopt 里arena_max的调整有时能让多线程应用提速的原因。

5.3 jemalloc 低碎片的代价与收益

jemalloc 最被低估的一点不是快,而是长时间运行之后内存碎片率很低。它的 arena 分片让不同线程分配的内存尽量在独立内存段里,不容易相互穿插形成空洞。同时它对大小分类的管理非常精细,比如 8 字节、16 字节、32 字节这些尺寸都精确划归,避免了 malloc 有时会向上取整到更大 size class 的浪费。

但这个设计有代价:arena 之间会发生对象迁移,且在分配模式频繁变化时元数据维护成本略高。这就是为什么 S1 单线程场景它只比 glibc 快 5%,而 S3 容器场景也只能到 1.18 倍——因为线程绑定 arena 的哈希函数在单线程优势不够明显。选 jemalloc 你要有“为低碎片放弃部分吞吐”的心理准备,它更适合长时间稳定运行的进程。

5.4 自定义分配器为何快中带险

FixedPool 和 Arena 之所以能远超通用分配器,原因是它们把“通用性”三个字扔掉了。固定大小池把所有对象规格统一,这样一个空闲链表就足以管理,省掉了 size class 查找、chunk 合并、锁竞争等一切分支逻辑。Arena 则直接把释放成本全部挪到批次末尾,等于把分配器复杂度降到了最低。

这恰恰是危险所在。通用分配器的每一次分配背后都有一套完整的元数据管理逻辑,出错概率相对低;而自定义分配器把责任从“分配器本身”转移给了使用者——你必须精确知道分配对象的大小、生命周期和释放时机。一旦匹配错,就是踩内存、double free、溢出改链表、碎片化严重等从表层看不出来的幽灵级 bug。我在第 6 节详细讲这些陷阱。

6. 那些年我用自定义分配器踩过的坑

数据聊完,我特别想单独写一节“避坑指南”。因为网上教程普遍爱吹自定义分配器性能多好,但没人提醒你它把多少复杂度转移给了你。这一节全是血泪经验,希望你在动手写第一行分配器代码之前先看完。

6.1 通用容器配自定义分配器的两个隐藏门槛

第一个门槛是allocate 的 n 参数不只是 1。STL 容器在扩容时可能一次性请求 n 个对象的连续内存,你的分配器如果只支持 n=1,就可能直接抛异常或者行为未定义。我的 FixedPool 里特意加了if (n != 1) throw std::bad_alloc(),就是为了防止我将来把它误配到要求批量内存的场景里。可在真实工程里,很多人写 FixedPool 时根本没处理 n,于是容器扩一次容,内存直接踩出池子。

第二个门槛是rebind。std::unordered_map的内部实现用的是allocator_traits<Allocator>::rebind_alloc<U>,它要求你的分配器模板能派生出管理另一种类型 U 的分配器。很多自写分配器把 rebind 写成return Allocator<U>(*this),但如果分配器里绑定了固定 T 的大小,U 和 T 大小不一致时就会出错。分配器必须“支持多种对象类型”是 STL 容器复用你分配器的前提,不满足就别谈性能对比。

6.2 Pool 的生命周期陷阱:内存永远不会还回去

这点是我最想强调的:FixedPool 的 deallocate 不会真正释放内存,只是把对象放回池子。如果你的池子生命周期是整个进程,那你定义的所有对象都会积压在这个池子里,从系统视角看,你的进程迟早吃到堪比内存泄漏的 RSS 增长。

解决办法有两个:要么池子只创建在单个请求处理函数的局部作用域里,请求结束整个池子销毁,内存归还系统;要么池子实现里增加一个内存水位阈值,当占用超过阈值时主动归还一部分 chunk 给系统。我通常选择前者,因为实现简单、语义清晰。如果你把池子写成全局单例,请务必在压测阶段观察 RSS,通常不到两小时就会让人崩溃。

6.3 线程安全的错觉

很多人以为自定义分配器是线程安全的,因为它不需要加锁。实际上 FixedPool 如果只维护一个全局空闲链表,在多个线程同时 allocate 时,freelist 头指针的并发更新会导致两个线程同时拿到同一个节点,这是极难排查的悬空指针 bug。解决办法是做线程局部缓存:每个线程有独立的空闲链表,其他线程释放的对象放回中央队列,再从中央队列“批发”给本地缓存。这实际上又回到了 tcmalloc 的架构,你会发现到最后你是在重新发明另一个 tcmalloc。

6.4 碎片问题:自定义不一定低碎片

很多人误以为自定义分配器天然碎片少。其实 FixedPool 固定大小对象确实没有内部碎片,但如果你用 FixedPool 管理不同大小的多个 T,或者 Arena 分配区间出现大小不均的间隙,碎片会迅速积累。Arena 尤其危险:如果分配请求的 align 规律不稳定,每次 reset 后的偏移会产生不同空隙,长期运行后本来 1MB 的容量可能实际只能装下 800KB 的数据,剩下 20% 被浪费成间隙。

我建议你在自定义分配器里内置一个能力:能统计每次 allocate 请求的实际对齐和大小分布。把这些数据输出成日志,就能清楚看到碎片率到底有多少。很多分配器性能问题不是分配本身慢,而是碎片导致的有效容量缩水严重。

7. 选型决策表与实践建议

把上面的实测数据和原理总结成一张可以直接用的决策表。我自己做架构时,就按照这张表来快速判断该用哪种分配器:

业务特征推荐分配器关键理由
高并发小对象分配tcmallocthread cache 批量补货,多核伸缩性最好
长时间稳定运行的常驻服务jemallocarena 分片,低碎片,内存稳定
单线程高频小对象可选 FixedPool 或 glibc tcache自写池提升有限,风险却高
请求级临时对象,批次结束统一释放Arena / std::pmr::monotonic_buffer_resourceO(1) 分配,整体 reset
大对象分配通用 malloc 即可自定义没有天花板收益
多线程 + 容器高频读写tcmalloc + 默认容器分配策略组合容器节点大小不一,FixedPool 不适合

具体到行动,我建议按以下顺序操作:

第一步,先用 perf 或perf record看当前程序的分配热点分布。如果 malloc/free 的开销占比不到 10%,你没资格谈分配器优化,去优化热点计算逻辑。

第二步,确定热点是“单线程高频”“多线程高竞争”还是“短生命周期对象突发”,再从表格对应行选分配器。千万不要听别人说 tcmalloc 好就直接换,你服务的线程模型和分配分布未必匹配。

第三步,在不替换全局 allocator 的情况下,先用std::pmr自定义一个局部区域的 memory_resource,仅替换热点容器的分配策略。这样影响面可控,回滚也容易。

第四步,如果替换全局分配器,上线前务必做长稳定性测试,跑满 48 小时以上,重点看 RSS 增长曲线。任何一个分配器在测试基准里再快,长时间运行后 RSS 上涨过快也是不可接受的。

8. 一些额外的话

我记得第一次做分配器优化时,天真地以为只要换一个分配器所有性能问题都解决了。到现在实践了多次之后,我最深的体会是:分配器只是冰山一角,真正的问题是代码里分配频率过高、对象生命周期过长、容器选择不当。自定义分配器或者 tcmalloc、jemalloc 本质上都是在“分配行为不可改变”的前提下做减震,而不是根治。

如果你连 perf 都没跑过就准备动手写一个 FixedPool,我劝你先花半小时用perf record -e kmalloc:kfree看看系统调用次数。很多所谓“分配过快”其实只是 buffer 复制、临时对象创建、锁竞争导致的假象。优化掉这些真凶,效果可能比任何自定义分配器都大。

在这篇文章之外,我还想分享一个小技巧:在做分配器 benchmark 时,一定记得把任务集跑成异步模式交错操作再测。我第一次测 tcmalloc 和 jemalloc 都是纯串行分配释放,结果完全看不出差异。后来改成随机交错分配后差异才明显。分配模式越接近真实业务,结论越有参考价值。你今天看到的这些数据,都是在随机交错和多种尺寸混合下得到的,这才是我敢说“趋势应该稳定”的原因。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询