4. 内存分配器:glibc ptmalloc、jemalloc、tcmalloc 原理对比
内存分配器,说白了就是内存调度的“管家”。你申请内存,它给你划地;你释放内存,它做回收。但不同的管家,干活的方式和效率天差地别。选错了,轻则频繁系统调用,重则内存膨胀、碎片爆炸。我在性能调优项目里,好几次都是因为换了个分配器,系统吞吐就翻了一倍。
今天咱们就把三个主流分配器——glibc 的 ptmalloc、Google 的 tcmalloc、Facebook 的 jemalloc——的核心差异讲透。
4.1 ptmalloc:系统默认的复杂链式管理
ptmalloc 是从 Doug Lea 的 dlmalloc 演变来的,glibc 默认采用它。它的管理单位叫 chunk,每个 chunk 头部带有 meta 信息(prev_size、size、fd、bk)。分配释放就是在一堆双向链表里穿梭。
| bin 类型 | 管理范围 | 特点 |
|---|---|---|
| fastbins | 16 ~ 80B(默认值可调) | LIFO 单链表,不合并相邻块,最快路径 |
| smallbins | 16B ~ 512B | 双向链表,精确 size 匹配 |
| unsorted bin | 回收暂存区 | 最近释放的 chunk 先放这,下次分配时遍历 |
| largebins | > 512B | 以 64B/512B 步长分档,尾插入,复杂度高 |
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 是我个人最喜欢的通用分配器,没有之一。它的设计思路很简单:削低锁竞争,提高空间利用率。
核心结构三个层级:
- arena — 每个 CPU 或每组线程配备独立 arena,减少锁碰撞。NUMA 架构下,它会尽量把内存亲和到本 Node。
- extent — 管理连续的大块物理内存(默认 4MB 或 2MB huge page),通过 rbtree 做地址查找。
- tcache — 每线程本地小对象缓存,免锁访问。tcache 满了才往 arena 批量去取。
它的 size-class 划分极其细密(从 8B 开始每档涨 ~20%),对齐后几乎不会产生内部碎片。配合 bitmap 做空闲页管理,分配快、回收也快。
// jemalloc 典型配置示例(malloc.conf)
// 打开线程后台清理,每 2 秒尝试将多余内存归还 OS
export MALLOC_CONF="background_thread:true,lg_dirty_mult:4"
- 多线程、高并发(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 手动限制住才解决。
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) |
4.4 如何选型?一份接地气的 checklist
我不会跟你扯“要看业务场景”这种废话。直接上 checklist:
- 你的应用是单线程还是多线程?
单线程用 ptmalloc 完全没问题,别折腾。多线程多核?那就别用 ptmalloc,必出幺蛾子。 - 小对象多还是大对象多?
小对象密集(几十字节到几 KB)选 tcmalloc。大对象(几百 KB+)偏多选 jemalloc。 - 需要主动还给 OS 内存吗?
需要长期稳定 RSS(比如服务端守护进程),优先 jemalloc + 开启background_thread自动 purging。 - 遇到未知死锁或内存膨胀,先切分配器验证。 不管你的代码写得多么“内存安全”,分配器本身的 bug 或行为异常是真实存在的。