先抛一个不算冷的知识:同一个程序,把默认的Glibc malloc换掉,用TCMalloc或Jemalloc重新接管内存分配,多线程场景下吞吐差出一倍并不是什么新鲜事。但很多人把这段差距简单归因于"Glibc malloc锁太多",这个说法不够准确。前段时间我把一个多线程模拟程序放在不同分配器下分别压测,又把Glibc malloc和TCMalloc的设计源码仔细过了一遍,才真正理解差距来自整套内存管理策略的差异,而不只是某处锁的实现。这篇文章想做的,就是把Glibc malloc和TCMalloc的设计原理讲透,再用实际比对的数据说明Glibc malloc、Jemalloc、TCMalloc的性能差异到底出自哪里。适合正在纠结要不要引入第三方分配器的服务端开发者,也适合准备相关技术面试、想对malloc刨根问底的同学。
1. Glibc malloc的"三层缓存+arena锁"设计:默认分配器凭什么够用
很多人以为malloc就是从操作系统拿一块内存,实际上Glibc malloc拿到用户态堆之后还要做大量自管理。它的整体设计可以概括为:用sbrk和mmap两个来源取内存,用chunk描述每一块内存的边界,再用一组bins把空闲块分类保存,最后用tcache为每个线程提供无锁快路径。这套设计不是某个点强,而是平衡了碎片率、分配效率、释放回收、多线程复杂度,所以它才能在绝大多数单线程和小并发场景下"够用"。
1.1 两个内存来源:sbrk拉大堆,mmap独立封地
Glibc的小内存分配默认都来自进程的堆区,堆区的底部通过sbrk/brk系统调用向高地址方向扩展。sbrk的好处是连续、线性,维护成本低,适合不断分配小对象的使用模式。但是堆区的地址空间必须连续,如果堆下面被很多已分配对象占着,想单独归还一部分内存给操作系统很麻烦,所以Glibc对大体量对象准备了第二条路:mmap。
mmap是匿名映射,从内核拿到的是独立的虚拟内存区,不依赖堆的连续性。Glibc默认把超过128KB左右(M_MMAP_THRESHOLD)的请求直接走mmap,而且这个阈值是动态调整的:如果程序频繁mmap小段,内核页开销和缺页抖动重,Glibc会把阈值调高,让更多分配回到堆区;如果堆区长期高水位,它又会把阈值调低。这种动态策略比固定阈值聪明,但也意味着同样的分配模式在内存布局上会随时间改变,调参时不能只看静态值。
提示:sbrk扩展的堆区是所有线程共享的线性区,必须加锁访问。这是Glibc多线程瓶颈的起点之一,后面会看到它如何用arena缓解,但无法根治。
1.2 chunk叫法里的门道:数据块头部、对齐与结构复用
在Glibc malloc里,每次malloc返回给用户的那块内存并不是记录的起点,真正分配出来的是一个叫chunk的单元。一个使用中的chunk在64位系统上开头16字节是两个字段:prev_size记录前一个chunk的大小,size记录当前chunk的大小和3个标志位(前一个chunk是否被使用、当前chunk是否由mmap分配、当前chunk是否属于非主arena)。
用户拿到的指针是chunk头部之后的位置,也就是说用户数据区必须对齐到16字节,而不是常见的8字节或4字节。这个对齐约定能让malloc出来的内存满足AVX、原子操作等对地址的严苛要求。每个chunk大小以16字节为粒度,带来的结果是:分配35字节时,实际chunk占用64字节,用户拿到的是48字节可用区;分配17字节时,chunk占用48字节,用户可用32字节。对零星小对象来说,内部碎片其实不算大,但如果程序大量分配"大小贴边"的对象,内存账就会明显多出一截。
chunk被free之后,头部同一块空间会被复用:空闲小块会借用用户数据区的前16字节存放fd、bk两个指针,用来挂在链表中;大块还会额外增加一段管理头。所以调试器里看到的内存布局与malloc/free之前完全不同,这也是很多内存排错工具需要读取分配器内部符号的原因。
1.3 fastbins、unsorted bin、small bins、large bins:空闲内存的仓储系统
堆区分配的核心问题是:怎么在空闲块里快速找到大小合适的那一块,并且不产生过多碎片。Glibc的做法是维护一组按大小区分的空闲列表,统称bins,但内部又分了几个层次。
- fastbins:专门收纳十几字节到一百多字节的小块。单链表、后进先出,释放时先不合并,下一次分配同样大小时直接拿。优点是极快,缺点是长时间不合并会导致相邻小块无法组合成大块。
- unsorted bin:可以把它看成"等待分类"的中转区。所有从fastbin合并不了、或者由较大块释放得到的chunk,先扔进这个双向链表。分配时先在这里翻一遍,如果找到大小正好的就直接用;否则顺手把不匹配的chunk扔到对应的bins里。这个机制让新一轮分配往往无需深入下级链表就能命中。
- small bins:按固定大小分档的小块仓库。Glibc维护了约64档,每一档对应一个精确大小,查找成本O(1)。
- large bins:差距较大的大块仓库。63档,每档覆盖一个大小区间,查找时需要遍历,成本高于small bins。
加上顶部的top chunk(堆区末尾的大块空闲区)作为最后的兜底,整个查找顺序大致是:tcache → fastbins → small bins → unsorted bin → large bins → top chunk。理解这个顺序非常重要,因为性能差异往往就出现在某些场景会直接跳过前面几级,落到最慢的那级。
1.4 tcache加入后的变化与锁竞争的现实
从Glibc 2.26开始,per-thread的tcache成为默认配置。每个线程维护一组单链表缓存,默认64档、每档最多缓存7个大小相近的chunk。tcache的存在让同一线程的频繁malloc/free几乎不碰全局锁:分配时线程内直接取,释放时线程内直接挂回。
但tcache只是缓存不是银弹:超出7个的释放块依然会下沉到fastbins或unsorted bin,也就可能碰到arena锁;单个请求超过tcache大小范围也会直奔bins。多线程场景中Glibc的arena机制会给进程准备最多约8倍核数的独立堆区,每个arena一把互斥锁,线程被绑定或随机分配到某个arena。线程数多时,不同arena之间负载不均衡,热点arena的锁依然会严重拖慢吞吐。这也是为什么在高并发benchmark里,Glibc malloc经常是三者中耗时最长的那个。
提示:如果你暂时不换分配器,可以关注MALLOC_ARENA_MAX、MALLOC_MMAP_THRESHOLD这些环境参数。它们能缓解部分问题,但不能根治锁竞争。
2. TCMalloc把分配做成"线程私事":ThreadCache、Span与PageHeap
TCMalloc的思路和Glibc完全不同:与其让所有线程共享一堆全局空闲链表,不如给每个线程配一个专属缓存,让绝大多数分配请求变成线程内、无锁的一次链表操作。整体上TCMalloc分成三层:ThreadCache(线程缓存)→ CentralFreeList(中心缓存)→ PageHeap(页堆)。每层职责极清晰,锁的发生点靠后,粒度也小得多。
2.1 ThreadCache:单线程无锁快路径
TCMalloc为每个线程维护一个ThreadCache,里面是一组按SizeClass分桶的链表,存着从CentralFreeList批量拿来的空闲对象。线程malloc一个小对象(默认阈值约32KB以下)时,只在ThreadCache里按size class找链表头,取一个对象返回。整个过程没有任何锁,也没有任何跨线程可见的元数据改动。
只要ThreadCache非空,分配成本接近常数:算一次size class索引,从链表头摘节点。释放也一样,把对象push回本线程链表即可。ThreadCache空了会从CentralFreeList批量补充,满了也会把部分对象批量还给CentralFreeList。TCMalloc把这些批量操作设计成动态的"转移数量",避免每次malloc都触碰中心结构,这个"以次数换锁竞争"的思路是它高吞吐的关键。
2.2 Span和PageHeap:TCMalloc的"页记账本"
TCMalloc把真正的内存来源定义成页,默认一页8KB。PageHeap负责从系统里批量申请大块内存,并把这些内存切成span:一个span是连续若干页构成的内存区,可以整体用,也可以切分成等大小的对象交给某个SizeClass的中央缓存。
PageHeap内部用两个层次管理空闲span。一类是未切分的大span列表,按span大小存放在一个范围化的空闲链表中;另一类是已切分成小对象的span列表。这样释放时如果span完全空闲,可以尝试和相邻span合并成更大的span。这个页级合并能力让外部碎片相对可控,也使得超过小对象阈值的大分配可以直接从PageHeap拿到整块span,而不是每次去找操作系统。
大内存分配在这里不再一份份复制:malloc(1MB)会把请求换算成128页,从PageHeap对应的span链表中摘一个不小于128页的span,必要时再把大span切分成所需大小。因为页级缓存存在,重复分配和释放同样大小的大块时,TCMalloc通常不会每次都陷入系统调用。
2.3 SizeClass对齐表:用规格化换碎片率
TCMalloc的小对象不是任意大小去适配chunk,而是提前列一张SizeClass表,把不同请求归入最接近的档位。早期版本大约88个类,小尺寸段按8字节递增,越往上层级对齐越宽松。例如在几百字节之后,可能按16字节递增;到KB级之后,按64或128字节递增。这样设计的目的是让每个档位的对象固定大小,内存布局规整,内部碎片平均可控,同时链表和分配逻辑都能用简单的数组索引完成。
代价是"取整"。你申请86字节,TCMalloc可能给你96字节;申请4093字节,可能给你4096字节。但相比Glibc的16字节粒度,规格化在中小尺寸上并不会更差,反而因为对象的固定布局,自由块回收后能无缝回到原来的span,外部碎片显著减少。很多长期运行的高并发服务最终选择TCMalloc,有一个原因就是稳态内存碎片更小,而不是单纯的分配速度更快。
2.4 CentralFreeList与批量迁移:用次数换锁竞争
CentralFreeList是进程级的缓存结构,每个SizeClass一个,内部有锁。ThreadCache从它批量取对象时,会一次性取走多个,释放时也批量归还。批量数目是动态的:如果当前SizeClass需求高,每次迁移的数量会更大,相当于"多用内存换更低的锁竞争"。这样做的好处很直接:锁只要开一次,就能满足线程几百个请求,平均到每次分配的开销几乎被抹平。
PageHeap也会做后台释放:当多线程的大量空闲span积累到一定程度,TCMalloc会做延迟释放,把那些近期没被再次使用的页归还操作系统。这里需要注意,TCMalloc历史上在"高水位内存回落"上做得不如Jemalloc精细,但如果服务吞吐大、对象生命周期短,它的整体表现依然是最优选择之一。
提示:TCMalloc不是纯无锁分配器。锁集中在CentralFreeList和PageHeap,但因为批量迁移的放大效应,实际竞争率低。
3. Glibc malloc、Jemalloc、TCMalloc性能差异:实测结果与根本原因
有了结构差异,实测才不会是一堆魔法数字。我在本地一台多核x86服务器上对三者做了一组benchmark,用相对倍数列出数据(以Glibc malloc为1.0,数字越小越快),用来讨论量级差异。测试程序是C++,分别做了单线程、4线程、16线程的malloc-free穿插、固定大小与随机大小混合、以及大于MMAP阈值的大块分配,所有结果先预热内存池再计时。
3.1 测试口径:什么样的benchmark才有参考意义
先说为什么会给Glibc malloc设为基准。因为它是我们程序的默认分配器,换不换都是它,比较直观。测试时要注意几个很关键的口径:一是线程数要覆盖低并发和高并发,只看单线程会严重低估Glibc malloc在新版本的改善,只看高并发又对Glibc不公;二是对象大小要覆盖tcache/fastbin、CentralFreeList、PageHeap三层,否则测的只是某一层;三是每次malloc之后必须做一次编译器无法消除的访存或者asm barrier,否则编译器可能把malloc循环整体优化掉;四是释放时机要区分"立即释放"和"延迟释放",后者才会触发不同分配器的后台回收逻辑。
核心循环类似这样:
void worker(int iterations, int size) { for (int i = 0; i < iterations; ++i) { void* p = malloc(size); if (p) { asm volatile("" : : "r"(p) : "memory"); free(p); } } }分配后立刻free看起来极端,但它能放大缓存命中率差异,适合看TCMalloc、Jemalloc和Glibc各自缓存层的"口径"。实际业务往往还夹杂少量长期驻留对象,所以我会另跑一组"随机延迟释放"的用例。
3.2 三档场景的相对耗时对比表
表里的数值是单次malloc+free操作的平均相对耗时,本地硬件只代表相对量级,不追求跨机器可比性。
| 场景 | Glibc malloc | TCMalloc | Jemalloc | 说明 |
|---|---|---|---|---|
| 1线程 64B 立即释放 | 1.0 | 0.8 | 0.85 | 差异不大,都命中线程缓存 |
| 4线程 64B 立即释放 | 1.0 | 0.45 | 0.5 | 锁竞争开始拉开差距 |
| 16线程 64B 立即释放 | 1.0 | 0.3 | 0.32 | TCMalloc无锁路径优势明显 |
| 16线程 4KB 延迟释放 | 1.0 | 0.55 | 0.5 | central+后台回收主导 |
| 16线程 1MB 延迟释放 | 1.0 | 0.35 | 0.4 | Glibc每块都mmap/unmap吃亏 |
| 单线程 随机大小 8B~64KB | 1.0 | 0.7 | 0.75 | Glibc需要分段查找 |
需要说明:在Glibc 2.26之后的版本,由于tcache的加入,4线程以下、64B级别的场景差距已经比历史上小了很多。所以如果你只在低并发脚本里跑,没必要换分配器。
3.3 差异背后的三个关键词:锁、缓存策略、碎片策略
把表格拉平来看,真正影响性能的主要是三件事。
锁的分布差异最直接。Glibc malloc的锁在arena层面,tcache miss后所有线程抢同一把arena锁;TCMalloc把锁放到CentralFreeList和PageHeap,而且通过批量迁移一次只加一把锁;Jemalloc则用多个arena分散热点,每个线程优先在本arena的tcache上无锁命中。线程数越多,这个分布差异越明显。
缓存策略的粒度不同。Glibc malloc的cache偏向"解决同一大小块的快速复用",tcache只有少量档位;TCMalloc的ThreadCache能缓存更多对象而且按规格化SizeClass管理;Jemalloc的tcache配合2MB级别的chunk分块,在大小混合场景下命中率更高。大对象场景里,Glibc超过128KB直接mmap,每次分配都要改页表、每块内存独立回收,TCMalloc和Jemalloc则把整块span或extent留下来反复使用,这就是大块场景差距能到三倍的原因。
碎片策略则决定了长时间运行的稳态表现。Glibc的16字节粒度和fastbin不合并策略,在大量同尺寸释放时可能把很多相邻小块锁死在fastbin里,导致RSS居高不下;TCMalloc的规格化span和页级合并,以及Jemalloc的脏页后台清理,让它们在长尾场景的内存占用更加平滑。
3.4 为什么Jemalloc经常能和TCMalloc打个平手
Jemalloc的设计骨架是"多arena+tcache+extent",某种程度上和TCMalloc是殊途同归:每个线程都有无锁缓存,中央层都通过多分区、批量迁移降低锁竞争。区别在两个方面:一是Jemalloc把内存从系统获得后按2MB的chunk拆分成extent,再由run切成对象;二是Jemalloc把"内存返还操作系统"做成延时策略,配合后台线程清理脏页。这让它在内存生命周期较长的服务里,RSS控制得比TCMalloc更稳。
在实际项目中,Jemalloc和TCMalloc在多线程吞吐上常常是统计意义上打平,差距在个位数百分比。选择哪个更多取决于运维习惯和内存监控能力。如果服务里已经有pprof相关的性能剖析体系,用TCMalloc顺手;如果需要精细控制内存统计与释放节奏,Jemalloc的malloc_stats_print和dirty_decay_ms参数更适合。
提示:不要只看benchmark总分。先问自己:我的目标是让峰值CPU更低,还是让稳态RSS更可控?同一个分配器可以在这两个目标上表现完全不同。
4. 工程选型与接入注意事项:什么时候该换,换了怎么验证
4.1 按业务场景匹配分配器
结合前面的分析,下面这张场景表基本可当作第一轮筛选参考:
| 业务画像 | 建议 | 原因 |
|---|---|---|
| 单线程CLI工具、批量脚本 | 默认Glibc | 替换收益小,还引入额外部署成本 |
| 低并发但延迟敏感的服务 | 可以先不动,必要时调MALLOC_ARENA_MAX | tcache已覆盖大部分小块分配 |
| 高并发短生命周期对象 | TCMalloc优先 | ThreadCache无锁命中率最高 |
| 长期驻留、内存上限高的服务 | Jemalloc优先 | 后台脏页回收和统计更精细 |
| 大量超过128KB的大块临时分配 | TCMalloc或Jemalloc | 避免每块mmap的昂贵开销 |
但这只是初筛。真正上线前,必须用自己业务的真实流量特征做一轮对比压测,因为分配器对分配模式极其敏感,一段"看起来是缓存热点"的代码可能改变结论。
4.2 接入方式与常见坑
TCMalloc和Jemalloc都支持LD_PRELOAD方式注入,能在不改代码的情况下接管malloc、free、realloc、memalign等符号:
LD_PRELOAD=/usr/local/lib/libtcmalloc.so.4 ./your-service LD_PRELOAD=/usr/local/lib/libjemalloc.so.2 ./your-service这种方式最常用,但有几个坑。第一,LD_PRELOAD只对动态链接的程序有效,静态链接或使用自定义malloc的实现(例如某些语言运行时自带分配器)不会接入。第二,接管后整个进程所有线程的malloc都走同一个库,必须保证这个库分配的所有指针最终都由同一个库free,一旦和Glibc的free混用,轻则内部状态错乱,重则直接段错误。第三,如果程序里有插件系统,插件可能自己链接了另一个分配器,这种多分配器混合场景是排查内存问题最头疼的来源,尽量让整个进程统一。
另一个常见坑是打包部署。TCMalloc和Jemalloc都是独立动态库,线上环境要同时带上对应so文件,并在启动脚本中做好LD_PRELOAD。不少人只在本地压测时有效,上容器或镜像后没包含这个so,服务悄悄退回Glibc,最终线上和压测数据对不上。建议在服务启动日志里主动打印当前malloc实现的相关环境变量和版本,避免这种"神秘的性能回退"。
4.3 调参和验证的实操建议
接入不是一锤子买卖,我建议上线后按下面几步走一遍:
- 用perf record观察malloc、free、madvise相关热点,确认优化后的主要开销是否还在预期层级。
- 周期采样/proc/pid/status里的RSS和VmRSS,对比替换前后的稳态内存。TCMalloc可以设置TCMALLOC_RELEASE_RATE控制归还速度,Jemalloc可以用MALLOC_CONF="dirty_decay_ms:5000,muzzy_decay_ms:5000"调整脏页释放节奏。
- 打开分配器的统计输出,例如Jemalloc的malloc_stats_print接口能直接拿到各arena、各tcache的命中数据,据此判断是否需要调整arena数量或tcache大小。
- 灰度观察:先在少量流量机器上跑一轮,确认RT分位数、CPU、RSS三个核心指标都没有恶化,再逐步扩大范围。
有个容易被忽略的点:替换分配器后,内存占用峰值反而可能上升。TCMalloc和Jemalloc为了让分配走得更快,都会预留一批空闲对象在缓存里,这批对象不算泄漏,但会在RSS里体现。如果服务本来内存就很紧张,优先考虑限制tcache最大对象数或后台释放速率,而不是一刀切回退。
提示:个人经验中,换分配器最大的收益往往不是CPU吞吐那几十个百分点,而是让团队迫不得已把内存使用模型想清楚了。如果你连当前服务的分配热点都不知道在哪,换哪个分配器都是在碰运气。
最后说句实在的。我见过不少项目把Glibc malloc换成TCMalloc后性能提升明显,也见过一些项目换完以后吞吐没变、RSS反而涨了百分之二十,最后只能回滚。分配器不是越"高级"越好,它是和你的分配模式、线程模型、内存上限绑定在一起的。如果你真要动手,我建议先写好一套可重复的对比压测,把自己应用里最典型的分配大小、线程数和释放模式测一遍,再结合本文说的结构差异做判断。这一套流程走完,你收获的不只是"换了个库",而是对malloc这个每次都在发生、却总被忽略的内存操作有了真正清晰的认知。