打开任何一篇分配器对比文章,你会看到 micro-benchmark:malloc/free 100 万次,记录耗时。jemalloc 3.2 ns,tcmalloc 2.8 ns,mimalloc 2.5 ns,glibc 4.1 ns。
然后呢?你选了最快的那个,部署到生产环境,跑了三天,RSS 涨到了物理内存的 80%。OOM killer 来了。你切回 glibc,问题消失了。
分配器的性能不是 malloc/free 的速度。是长时间运行后的碎片率、多线程竞争下的尾延迟、以及大小类切换时的内存利用效率。
一、四个分配器的设计差异
先把架构搞清楚,然后才能理解性能数据。
glibc (ptmalloc2)
Linux 默认分配器。Arena-based 设计。
- 每个线程绑定一个 arena(最多
8 × CPU 核数个 arena) - 小对象(<512B)用 fastbins + smallbins,LIFO 链表
- 大对象(>128KB)直接 mmap
- 碎片来源:bins 之间的间隙 + arena 之间的不均匀
glibc 的 fastbin 在高并发下有一个反直觉的行为:它用的是单链表 + mutex,不是无锁。在高并发 malloc/free 时,arena lock 竞争是主要瓶颈。
jemalloc
FreeBSD 默认分配器,Facebook 用了十几年。
- Thread cache + arena + extent 三层架构
- 小对象按 size class 分桶(8B, 16B, 32B, 48B, …),每个桶预分配 slab
- 碎片控制:定期做 dirty page purge,把不用的页面还给 OS
- 优势:碎片率控制最好,长时间运行 RSS 最稳定
tcmalloc (Google)
Google 全家桶用的分配器。
- Per-thread cache + central free list + page heap 三层
- Transfer batch:线程 cache 用光时,从 central list 批量拿一批(减少锁竞争)
- 优势:多线程小对象分配速度最快
- 弱点:碎片控制不如 jemalloc,大对象分配较慢
mimalloc (Microsoft Research)
最年轻的选手,2019 年发布。
- Per-thread heap,每个 heap 由固定大小的 page 组成
- Free list sharding:把 free list 分散到每个 page 里,减少 cache miss
- 延迟释放:其他线程释放的块先放进 local free list,稍后再合并
- 优势:micro-benchmark 最快,cache 友好度最高
二、Micro-benchmark:参考但不要信
标准测试:单线程和多线程下 malloc/free 混合操作。对象大小分布:70% <256B, 20% 256B-4KB, 10% >4KB。
单线程
| 操作 | glibc | jemalloc | tcmalloc | mimalloc |
|---|---|---|---|---|
| malloc 64B (ns) | 3.8 | 3.2 | 2.9 | 2.5 |
| malloc 1KB (ns) | 4.5 | 3.8 | 3.4 | 2.8 |
| malloc 64KB (ns) | 18 | 15 | 22 | 14 |
| free (ns) | 3.2 | 2.8 | 2.5 | 2.2 |
| malloc+free 交替 (ns) | 6.5 | 5.5 | 5.0 | 4.2 |
mimalloc 在单线程下最快。原因是 free list 在同一个 page 里,cache locality 极好。
16 线程
| 操作 | glibc | jemalloc | tcmalloc | mimalloc |
|---|---|---|---|---|
| malloc 64B (M ops/s) | 38 | 95 | 120 | 110 |
| malloc 1KB (M ops/s) | 32 | 85 | 105 | 98 |
| free (跨线程) (M ops/s) | 18 | 52 | 75 | 68 |
glibc 在多线程下被吊打。arena lock 的竞争太重了。其余三个都有 per-thread cache,差距在 20% 以内。
但这些数字在真实应用中几乎没有参考价值。因为真实应用的 malloc/free 模式和 micro-benchmark 完全不同:对象生命周期不均匀、分配和释放不在同一个线程、内存使用量有波峰波谷。
三、碎片率:24 小时实测
测试方法:把四个分配器分别嵌入同一个 HTTP 服务器(基于 libevent 的 JSON API 服务),用模拟流量跑 24 小时。
流量特征: - 请求体 200B-10KB(JSON 解析 + 序列化,大量小对象) - 每个请求分配 15-30 个对象,生命周期从 100us 到 5s 不等 - 稳态 QPS: 20,000 - 每 2 小时有一次流量高峰(QPS 翻倍,持续 10 分钟)
| 指标 | glibc | jemalloc | tcmalloc | mimalloc |
|---|---|---|---|---|
| 初始 RSS | 48 MB | 52 MB | 55 MB | 50 MB |
| 4h RSS | 85 MB | 68 MB | 92 MB | 78 MB |
| 12h RSS | 145 MB | 82 MB | 138 MB | 105 MB |
| 24h RSS | 210 MB | 95 MB | 185 MB | 128 MB |
| 碎片率 (RSS/实际使用) | 3.5x | 1.6x | 3.1x | 2.1x |
jemalloc 在长时间运行后碎片率最低。 24 小时后 RSS 只有 95 MB,而 glibc 涨到了 210 MB。差 2.2 倍。
原因: 1. jemalloc 的 dirty page purge
机制定期把不用的页面通过 madvise(MADV_DONTNEED)
还给 OS 2. jemalloc 的 size class 更细粒度(比 glibc 多 50%
的 size class),内部碎片更小 3. glibc 的 arena
之间内存不共享,一个 arena 的空闲内存不能被另一个 arena
使用
tcmalloc 碎片率高的原因:per-thread cache 的 transfer batch 机制会预取一批对象,但如果线程的分配模式变化了(比如请求类型变了),预取的对象就浪费了。Google 内部已经意识到这个问题,新版 tcmalloc 加了更积极的回收策略。
流量高峰后的恢复
关键指标:流量高峰(QPS 翻倍 10 分钟)结束后,RSS 能不能降回去?
| 分配器 | 高峰 RSS | 高峰后 30 分钟 RSS | 回收率 |
|---|---|---|---|
| glibc | 320 MB | 285 MB | 11% |
| jemalloc | 145 MB | 105 MB | 28% |
| tcmalloc | 280 MB | 230 MB | 18% |
| mimalloc | 195 MB | 148 MB | 24% |
glibc 几乎不回收。这是 Linux
运维常见的困惑:“为什么我的进程 RSS 只涨不降?”——因为 glibc
的 ptmalloc 不积极做 madvise。内存 free
了但没还给 OS,留在 arena 里等下次分配。
如果你的应用有明显的波峰波谷,jemalloc 的碎片回收能力值三倍内存。
四、尾延迟:分配器也会卡你
分配器不只影响内存占用。在错误的时机,一次 malloc 可能耗时几十微秒。
测量方法:在 HTTP 请求路径上记录每次 malloc 的耗时,统计分位数。
| 分位数 | glibc | jemalloc | tcmalloc | mimalloc |
|---|---|---|---|---|
| P50 | 3.5 ns | 3.0 ns | 2.8 ns | 2.4 ns |
| P99 | 45 ns | 28 ns | 22 ns | 18 ns |
| P99.9 | 850 ns | 180 ns | 120 ns | 95 ns |
| P99.99 | 38 us | 2.5 us | 8.2 us | 1.8 us |
| 最大值 | 210 us | 15 us | 42 us | 12 us |
glibc 的 P99.99 比其他三个高一个数量级。原因:arena lock 竞争。当多个线程同时需要从同一个 arena 分配,其中一个拿到锁,其他全部等待。38 微秒的 malloc 对于 P99.9 延迟要求在 1ms 以内的服务来说,是不可接受的。
tcmalloc 的 P99.99 也不低(8.2 us)。原因是 central free list 的锁——per-thread cache 用光后需要从 central list 批量取,这一步有锁。
mimalloc 和 jemalloc 在尾延迟上表现最好。mimalloc 的 free list sharding 设计使得几乎所有分配都能在 page-local free list 里完成,不需要任何锁。
五、选型建议
| 场景 | 推荐 | 理由 |
|---|---|---|
| 长时间运行的服务 (>24h) | jemalloc | 碎片率最低,RSS 最稳定 |
| 延迟敏感服务 (P99.9 < 1ms) | mimalloc 或 jemalloc | 尾延迟最低 |
| 多线程高吞吐量 | tcmalloc | 多线程小对象分配最快 |
| 内存受限环境 | jemalloc | 碎片回收最积极 |
| 通用 / 懒得换 | glibc | 默认就是它,没有切换成本 |
怎么切换:
# 编译时链接
gcc -o myapp myapp.c -ljemalloc
# 运行时注入 (不需要重编译)
LD_PRELOAD=/usr/lib/x86_64-linux-gnu/libjemalloc.so.2 ./myapp
# Rust 项目
# Cargo.toml: [dependencies] tikv-jemallocator = "0.5"不要看完这篇文章就换分配器。先在你的应用上跑 24 小时对比测试。分配器的表现高度依赖你的 malloc/free 模式——上面的数据是这个特定 HTTP 服务器的,你的应用可能完全不同。
延伸阅读:
- SQLite 是怎么做到”十亿行每秒”的 – SQLite 用自己的 page allocator 避免 malloc 碎片
- Go 调度器深度拆解 – Go 的内存分配器 (mcache/mcentral/mheap) 借鉴了 tcmalloc 的设计
参考资料:
- Evans, J. (2006). A Scalable Concurrent malloc(3) Implementation for FreeBSD. – jemalloc 的原始论文
- tcmalloc 设计文档 – Google 官方
- Leijen, D. et al. (2019). Mimalloc: Free List Sharding in Action. Microsoft Research.
- glibc malloc internals – ptmalloc2 的实现细节