4. 内存分配器工作原理:glibc malloc/free、ptmalloc、tcmalloc、jemalloc 设计差异
内存分配器,说白了就是用户态程序与内核之间的一道缓冲。你每次 malloc 和 free,背后都有一套复杂的博弈逻辑。我这些年排查过的内存问题,十有八九最后都绕回到分配器的选择上。不同分配器,行为差异很大,选错了,性能崩给你看。
我们先从整体上看看这四个主流分配器的定位。我整理了一张对比表,方便大家快速建立认知。
| 分配器 | 设计目标 | 核心策略 | 典型场景 |
|---|---|---|---|
| glibc malloc | 通用兼容 | 基于 ptmalloc2,使用 arena 和 bin 管理 | 绝大多数 Linux 默认场景 |
| ptmalloc | 多线程支持 | 通过 per-thread cache 和多 arena 降低锁竞争 | 多线程负载均衡型应用 |
| tcmalloc | 高并发低延迟 | 线程本地缓存 + 中央堆分层管理 | Google 系服务、高 QPS 后台 |
| jemalloc | 减少碎片与扩展性 | arena 隔离 + size class 精细划分 | Redis、MySQL、大型分布式系统 |
4.1 他们都在解决什么问题?
为什么会有这么多分配器?核心就两件事:锁竞争和内存碎片。
你想想看,一个简单的 malloc/free,如果所有线程都去抢同一把锁,那并发一上来,CPU 全耗在自旋上了。我在项目中遇到过,一台 64 核的机器,跑一个多线程服务,CPU 利用率不到 30%,但系统吞吐就是上不去。查到最后,锁都怼在 malloc 上。
另一个问题是碎片。频繁申请释放小块内存,堆空间像蜂窝煤一样千疮百孔。明明还有大量空闲,但就是分配不出一块连续的大内存。我曾在一个长连接服务器上见过,跑了几天后,进程 RSS 涨了 3 倍,全都是外部碎片闹的。
所以,不同的分配器,本质上就是在吞吐能力和内存效率之间做取舍。
4.2 glibc malloc 与 ptmalloc:默认的代价
glibc 的 malloc 是基于 ptmalloc2 的。这是 Linux 上最普及的分配器,但说实话,它也是为了兼容性做了很多妥协的方案。
它的核心模型是这样的:
每个线程会去抢一个 arena。如果抢不到,就创建新的 arena。每个 arena 内部维护了 fastbin、smallbin、unsorted bin 等结构。小内存走 fastbin,大一点走 smallbin,再大就走 unsorted bin 和 top chunk。
- fastbin:单链表,LIFO 策略,专用于小内存(默认最大 64 字节)。
- unsorted bin:回收的内存先放这里,下次分配时优先匹配。
- top chunk:竞技场顶部的边界内存,不够用时才去 sbrk 或 mmap 扩堆。
但 ptmalloc 有个历史包袱——内存不归还操作系统。free 释放的内存,大部分只是回到了分配器的 freelist,不会真的还给内核。这导致了 RSS 只增不减的假象。我以前踩过这个坑,一个监控服务跑了两个月,RSS 涨到 8GB,我以为是内存泄漏,结果用 pmap 一看,全是 heap 处于空闲状态。
4.3 tcmalloc:线程自己的小仓库
tcmalloc 是 Google 推出的。它的思路很直接:给每个线程一个本地缓存。小内存的分配和释放,完全在线程本地完成,不需要加锁。
具体的分层是这样:
- Thread Cache:线程私有的内存池,分配小对象(默认 256KB 以下)直接从这拿。
- Central Cache:多个线程共享的中央分配层,线程本地缓存不够了,就在这里进行批量获取。
- Page Heap:底层的大页管理,负责向 OS 申请和释放物理页。
我特别喜欢 tcmalloc 的 spans 机制。它把堆分成 8KB 大小的页,多个连续页组成一个 span。分配时直接按 span 切分,释放时如果整个 span 都空,直接还给 OS。这对减少 RSS 虚高很有帮助。
4.4 jemalloc:碎片控制大师
jemalloc 是 FreeBSD 的默认分配器,在大型系统里很受欢迎。它的设计哲学是:精细化管理每一个 size class。
jemalloc 没有采用传统的 bin 分类,而是自己定义了多个 size class 区间。比如 8、16、32、48、64 等等。每个 size class 都有独立的 freelist,分配时不会串扰。这意味着,你分配了 4 字节,占的是 8 字节的槽位,但和分配 32 字节的槽位完全隔离。外部碎片被压到最低。
它的 arena 也是隔离的。每个 CPU 绑定一个 arena,进一步减少跨核竞争。这也是为什么 Redis 和 MySQL 官方都推荐 jemalloc。
不过我提醒一下,jemalloc 的内存占用率有时候偏高,因为它会预留很多未使用的 run 来避免分配抖动。在我的经验里,jemalloc 的「预分配」策略会让 RSS 看起来比实际内存占用大一些,但在内存充足的情况下,这完全是值得的。
4.5 一张图看懂他们的核心差异
下面我把这四种分配器的核心内存管理策略画成一张框架图。注意看它们从用户请求到返回内存的处理路径差异。
4.6 实战中的选择建议
光知道原理不够,得能用。我根据实际踩坑的经验,给你几个选择方向:
- 通用场景(简单服务、单线程):glibc malloc 就够了,不用换。
- 高并发、小对象、延迟敏感:优先考虑 tcmalloc。我那个微服务就是从 glibc 换成 tcmalloc,吞吐直接翻倍。
- 内存碎片严重、服务需长稳运行:上 jemalloc。MySQL 和 Redis 用它是有原因的,长期运行不会出现堆空间越切越碎的问题。
- 不想换掉 glibc,但需要多线程优化:可以尝试配置 ptmalloc 的 arena 数量,或者用 mallopt 调参,但效果有限。
我曾经在性能测试中,把 jemalloc 直接用在了一个频繁创建销毁大量短暂小对象的应用上。结果 RSS 飞涨,因为 jemalloc 的 run 回收策略有延迟,大量预分配内存无法及时还给 OS。后来换成 tcmalloc 才解决。记住,没有万能的分配器,必须根据你的分配模式来选。
4.7 怎么验证当前分配器?
在排查问题时,确认当前进程用了哪个分配器是第一步。我一般用这几个方法:
# 查看进程的 memory mapping
cat /proc/<PID>/maps | grep -E 'libc|tcmalloc|jemalloc'
# 如果链接了 tcmalloc,能看到类似:
# 7f1234000000-7f1234200000 rw-p 00000000 00:00 0 .../libtcmalloc.so
# 使用 ldd 也可以确认
ldd /proc/<PID>/exe | grep alloc
# 如果用了 jemalloc,环境变量 MALLOC_CONF 可以打印配置
# MALLOC_CONF=stats_print:true <your_program>
如果你在分析 core dump,也可以直接看 debug symbol 里的分配器符号名。我是直接用 gdb 挂在 core 上,调用某个分配器内部函数来确认。
好了,这一章的核心内容就是这些。记住一点:分配器不是黑盒,它直接决定了你的内存和性能表现。选对工具,事半功倍;选错了,排查起来会很痛苦。