首页 / 资讯中心 / 文章详情

Glibc malloc、TCMalloc、Jemalloc底层原理与多线程性能对比

Glibc malloc、TCMalloc、Jemalloc底层原理与多线程性能对比 ★ FEATURED ARTICLE
先抛一个不算冷的知识同一个程序把默认的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与PageHeapTCMalloc的思路和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和PageHeapTCMalloc的页记账本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 三档场景的相对耗时对比表表里的数值是单次mallocfree操作的平均相对耗时本地硬件只代表相对量级不追求跨机器可比性。场景Glibc mallocTCMallocJemalloc说明1线程 64B 立即释放1.00.80.85差异不大都命中线程缓存4线程 64B 立即释放1.00.450.5锁竞争开始拉开差距16线程 64B 立即释放1.00.30.32TCMalloc无锁路径优势明显16线程 4KB 延迟释放1.00.550.5central后台回收主导16线程 1MB 延迟释放1.00.350.4Glibc每块都mmap/unmap吃亏单线程 随机大小 8B~64KB1.00.70.75Glibc需要分段查找需要说明在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的设计骨架是多arenatcacheextent某种程度上和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_MAXtcache已覆盖大部分小块分配高并发短生命周期对象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_CONFdirty_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这个每次都在发生、却总被忽略的内存操作有了真正清晰的认知。
阅读完成 · 觉得有帮助?
咨询建站