4. 内存分配器:glibc ptmalloc、jemalloc、tcmalloc 原理对比

内存分配器,说白了就是内存调度的“管家”。你申请内存,它给你划地;你释放内存,它做回收。但不同的管家,干活的方式和效率天差地别。选错了,轻则频繁系统调用,重则内存膨胀、碎片爆炸。我在性能调优项目里,好几次都是因为换了个分配器,系统吞吐就翻了一倍。

今天咱们就把三个主流分配器——glibc 的 ptmalloc、Google 的 tcmalloc、Facebook 的 jemalloc——的核心差异讲透。

三大内存分配器核心框架 glibc ptmalloc bins 链表 + top chunk ▸ fastbins (LIFO quick path) ▸ smallbins (精确拟合) ▸ unsorted bin (回收暂存) ▸ largebins (大块尾插入) ▸ top chunk (brk 扩展) ❌ 线程锁争用严重 ❌ 内部碎片 + 锁膨胀 jemalloc arena + extent + tcache ▸ 多 arena (NUMA-aware) ▸ extent 大页管理 ▸ tcache (线程本地缓存) ▸ size class 幂等对齐 ▸ rtree 地址反向查找 ✅ 低碎片、高扩展 ⚠️ 内存池可能膨胀 tcmalloc ThreadCache + CentralFreeList ▸ 线程本地 free list ▸ CentralFreeList 全局批处理 ▸ PageHeap 大页池 ▸ span 跨页回收 ▸ 小对象 0 碎片 ✅ 极速小对象分配 ⚠️ 大对象性能退化

4.1 ptmalloc:系统默认的复杂链式管理

ptmalloc 是从 Doug Lea 的 dlmalloc 演变来的,glibc 默认采用它。它的管理单位叫 chunk,每个 chunk 头部带有 meta 信息(prev_size、size、fd、bk)。分配释放就是在一堆双向链表里穿梭。

ptmalloc 核心 bins 分类速览
bin 类型 管理范围 特点
fastbins 16 ~ 80B(默认值可调) LIFO 单链表,不合并相邻块,最快路径
smallbins 16B ~ 512B 双向链表,精确 size 匹配
unsorted bin 回收暂存区 最近释放的 chunk 先放这,下次分配时遍历
largebins > 512B 以 64B/512B 步长分档,尾插入,复杂度高
我的一点习惯:ptmalloc 内部的锁是 per-arena 的。从 glibc 2.10 之后引入了「per-thread cache(tcache)」,但在高并发下 arena 锁仍然会成为热点。我建议你在 32 核以上机器跑多线程服务时,先用 perf top 看看 __lll_lock_wait 是不是在啃 CPU,如果冒尖就果断换分配器。

它有什么坑呢?碎片。 ptmalloc 的快路径(fastbins)不进合并,小对象释放后依然占据物理页,系统压力大了也不一定会还给 OS。我记得在 2018 年调优一个实时推荐系统,8 小时连续运行后 RSS 从 2G 膨胀到 12G,释放完全不可控。后来我切到 jemalloc,配合 background_thread 定期 purge,RSS 稳稳压在 3G 以下。

避坑:我曾经遇到过一个诡异死锁事故——多线程同时调用 free() 后线程被 __lll_lock_wait_futex 全部挂起。原因是 ptmalloc 在收缩 top chunk 时需要持有主 arena 锁,而别的线程在 fastbin 分配路径上正好也在等同一把锁。这个问题只有切分配器才能根治。

4.2 jemalloc:多 arena + 精细 size class

jemalloc 是我个人最喜欢的通用分配器,没有之一。它的设计思路很简单:削低锁竞争,提高空间利用率。

核心结构三个层级:

  1. arena — 每个 CPU 或每组线程配备独立 arena,减少锁碰撞。NUMA 架构下,它会尽量把内存亲和到本 Node。
  2. extent — 管理连续的大块物理内存(默认 4MB 或 2MB huge page),通过 rbtree 做地址查找。
  3. tcache — 每线程本地小对象缓存,免锁访问。tcache 满了才往 arena 批量去取。

它的 size-class 划分极其细密(从 8B 开始每档涨 ~20%),对齐后几乎不会产生内部碎片。配合 bitmap 做空闲页管理,分配快、回收也快。

// jemalloc 典型配置示例(malloc.conf)
// 打开线程后台清理,每 2 秒尝试将多余内存归还 OS
export MALLOC_CONF="background_thread:true,lg_dirty_mult:4"
什么时候选 jemalloc?
  • 多线程、高并发(10 线程以上)
  • 多 socket / NUMA 机器
  • 需要主动归还内存(je_malloc_stats_print 配合监控)
  • 长期运行的服务(Redis、MongoDB、MySQL 很多默认都搭配 jemalloc)

那它有缺点吗?有。arena 数、tcache 大小、背景清理线程这些参数调起来很讲究。我记得有次在 4C8G 的 Docker 容器里跑 jemalloc,arena 默认是 4 倍 CPU 数,结果每个 arena 都蹭一大块 extent,RSS 瞬间多出 200M。后来我把 narenas:2 手动限制住才解决。

排查技巧:线上怀疑 jemalloc 内存膨胀,别猜,直接用 je_malloc_stats_print 贴到 stderr 看 stats.arenas.i.pdirty 和 stats.arenas.i.dirty 的数据,或者让 MALLOC_CONF="stats_print:true" 启动时 dump 出来。比望远镜瞎猜靠谱一百倍。

4.3 tcmalloc:线程局部分配极速派

tcmalloc 是 Google 捣鼓给多线程场景的。它的思路比 jemalloc 更激进:小对象尽量在线程本地解决,绝不上锁。

架构只有四层:

  • ThreadCache — 线程本地 free list,按 size class 分桶,存取零锁。
  • CentralFreeList — 全局“批发仓库”,ThreadCache 按批从这取/还对象。
  • PageHeap — 管理物理页,用 span 结构把连续页按需切分或合并。
  • 小 / 大对象分水岭(通常是 32KB),小于的走 ThreadCache,大于的直接 PageHeap 分配。

为什么它这么快?一次 malloc 在最热路径上就是一个数组下标 + 一个 pop 操作,只要 ThreadCache 有货,完全不需要系统调用,连 CAS 都不用。

// tcmalloc 监控示例代码(取出 ThreadCache 命中率)
#include <gperftools/malloc_extension.h>
int main() {
    MallocExtension *ext = MallocExtension::instance();
    size_t tcache_hits = 0, tcache_misses = 0;
    ext->GetNumericProperty("generic.thread_caches_hit_count", &tcache_hits);
    ext->GetNumericProperty("generic.thread_caches_miss_count", &tcache_misses);
    printf("hit rate = %.2f%%\n", 100.0 * tcache_hits / (tcache_hits + tcache_misses));
}
三款分配器关键指标对比
维度 ptmalloc jemalloc tcmalloc
小对象分配速度 中等(tcache 加持后提高) 快 极快
多线程扩展性 差(全局锁全局 hot) 好(per-arena 锁) 很好(ThreadCache 无锁)
内存归还效率 低(top chunk 难收缩) 高(purge + background_thread) 中(需手动调用 ReleaseFreeMemory)
内部碎片 中 低 极低(小对象)
调试/监控工具链 弱(valgrind / mtrace) 强(je_malloc_stats_print) 强(tcmalloc stats dump)
慎用 tcmalloc 大对象场景:我踩过一次坑——一个图计算引擎大量分配 1MB ~ 8MB 的大块,跑在 tcmalloc 下被 PageHeap 切成许多 64KB 的 span,释放后又被转成 hole 挂在空闲链表里,永远不会合并。结果就是跑一晚上 RSS 从 500M 涨到 16G。换用 jemalloc 后,extent 层面自动做 coalesce 才搞定。

4.4 如何选型?一份接地气的 checklist

我不会跟你扯“要看业务场景”这种废话。直接上 checklist:

  1. 你的应用是单线程还是多线程?
    单线程用 ptmalloc 完全没问题,别折腾。多线程多核?那就别用 ptmalloc,必出幺蛾子。
  2. 小对象多还是大对象多?
    小对象密集(几十字节到几 KB)选 tcmalloc。大对象(几百 KB+)偏多选 jemalloc。
  3. 需要主动还给 OS 内存吗?
    需要长期稳定 RSS(比如服务端守护进程),优先 jemalloc + 开启 background_thread 自动 purging。
  4. 遇到未知死锁或内存膨胀,先切分配器验证。 不管你的代码写得多么“内存安全”,分配器本身的 bug 或行为异常是真实存在的。
我建议:如果你在做一个新服务,不知道选哪个,那就默认 jemalloc 起步。内存占用和性能方面综合来看最稳当。真的,我被 ptmalloc 坑过四次之后,就再也没在正式服务里用过它了。