2026/9/18 17:06:23

jemalloc内存泄漏检测实战:从原理到jeprof报告解读

jemalloc内存泄漏检测实战:从原理到jeprof报告解读 1. 为什么是jemalloc内存泄漏检测的另一种打开方式1.1 valgrind的痛点你我可能都遇到过先说个场景。你写了一个C程序跑了三天三夜内存肉眼可见地往上涨top里RES从几百兆涨到几个G。第一反应是什么八成是打开valgrind美滋滋地等着它给你指条明路。结果呢——程序跑起来慢得像蜗牛原本几秒的活它要跑几分钟原本几分钟的活它可能要跑一晚上。更气人的是valgrind在检测未初始化内存访问这类问题时确实很猛但在定位哪些代码路径持续分配内存却没释放这件事上它的报告往往是一长串调用栈列表你得从几百个堆栈里人肉筛选眼睛都看花了还未必找得准。另一个痛点是valgrind是模拟执行它对程序运行时行为的改变太大了。有些跟时间强相关的逻辑、多线程竞态问题在valgrind底下可能压根复现不出来。这时候你就需要另一种思路不打断程序正常执行只做轻量级的采样统计事后把分配栈汇总成报告看趋势。这就是jemalloc profiling干的事。1.2 jemalloc profiling到底做了什么jemalloc本身是一个高性能的内存分配器很多大型项目Redis、Facebook的诸多服务、Rust的默认分配器都在用它。但我今天要讲的是它自带的一个隐藏技能——heap profiling也就是堆内存画像。它的原理不复杂但很巧妙在每次内存分配发生时通过采样默认每分配2的某次方字节数采样一次比如2^19即512KB采样一次记录当前调用栈并把这个调用栈对应的内存占用累计起来。程序运行一段时间或者退出时把这些数据导出成一个heap文件。随后用配套工具jeprof分析这个heap文件就能看到哪些代码路径累计分配了多少内存、当前存活多少内存、哪些分配栈持续增长。换句话说它回答的问题不是你哪里写错了而是你的内存到底被谁、在哪个调用路径上吃掉了。这个采样的设计非常关键。正因为是采样而不是全量记录开销被压得很低。实测中开启profiling后的jemalloc对程序性能影响通常在个位数百分比级别这跟valgrind动辄5到20倍的减速完全是两种体验。代价就是报告是统计意义上的不是每个字节的精确账本但对于定位泄漏和异常内存增长已经足够用了。1.3 它适合什么样的排查场景用一句话概括jemalloc profiling适合回答我的内存去哪儿了而不太适合回答我这里是不是访问了越界内存。你要是遇到段错误、堆内存被写坏之类的问题还是老老实实用valgrind或者AddressSanitizer。但只要是内存只增不减怀疑有泄漏但不知道在哪想量化各个模块的内存占用这类问题jemalloc profiling的效率高得多。另外它还有个优势场景就是排查线上的长驻服务。valgrind对运行中的服务做attach是很麻烦的但jemalloc作为分配器从一开始就编译进程序里只要运行期间配置好MALLOC_CONF环境变量就能在任意时间点手动触发dump把当前堆快照导出来分析。这就相当于给程序装了一个内存体检仪随时能拍CT。2. 五分钟上手从编译安装到第一次检测2.1 先编译一个带prof功能的jemalloc多数Linux发行版的软件源里都有jemalloc但要注意默认编出来的jemalloc是不带profiling功能的。你得自己编译一次加上--enable-prof配置项。wget https://github.com/jemalloc/jemalloc/releases/download/5.3.0/jemalloc-5.3.0.tar.bz2 tar xjf jemalloc-5.3.0.tar.bz2 cd jemalloc-5.3.0 ./configure --prefix/usr/local/jemalloc-prof --enable-prof make -j$(nproc) make install这里说下为什么必须带--enable-prof。profiling功能依赖一组内部的栈回溯和采样计数机制不开这个编译选项的话相关的代码路径就是空的你后面设置MALLOC_CONF里的prof参数也会被静默忽略。另外建议编译时也加上--enable-debug吗我的看法是不必而且加了debug会影响性能生产环境别这么干。要调试等发现问题了再单独编一个带debug的版本也不迟。装完之后确认一下ls /usr/local/jemalloc-prof/lib/ # 应该能看到 libjemalloc.so 和 libjemalloc.so.2 这类文件2.2 程序里怎么接入链接就行接入方式分两种一种是你的程序源码里include了jemalloc的头文件并直接用jemalloc的API另一种是只把libjemalloc.so通过LD_PRELOAD或链接参数给它挂上程序源码不用动。实际排查泄漏时99%的情况是第二种——你手上大概率是个老项目不可能为了排查问题把所有malloc都改成jemalloc的API。先说链接方式gcc -o myapp myapp.c -L/usr/local/jemalloc-prof/lib -ljemalloc -Wl,-rpath,/usr/local/jemalloc-prof/lib再说LD_PRELOAD方式LD_PRELOAD/usr/local/jemalloc-prof/lib/libjemalloc.so.2 ./myapp需要提醒一下LD_PRELOAD的方式虽然对源码零侵入但它只对动态链接的malloc/free生效。如果程序里用了静态链接的glibc或者别的内存池这套方案就不灵了。我一般推荐直接用链接参数这样jemalloc会成为程序默认的分配器最干净、最可控。至于编译时要不要加-g强烈建议加。因为jeprof在生成报告时需要解析符号没有调试信息的话函数名全是地址报告的可读性大打折扣。生产环境如果不想带调试符号至少也要保留符号表strip的时候留一份带符号的副本备用。2.3 跑起来设置MALLOC_CONF拿到第一份heap文件这一步很关键。jemalloc的profiling启动方式不是改代码而是通过环境变量MALLOC_CONF来控制。一个最简的配置长这样export MALLOC_CONFprof:true,lg_prof_sample:19,prof_prefix:/tmp/jeprof ./myapp拆开解释一下prof:true打开profiling总开关。lg_prof_sample:19采样间隔为2^19字节也就是512KB。这个值越小采样越频繁报告越精细但性能开销越大越大性能越好但小内存分配可能压根采不到。我通常以19为基准开始调。prof_prefix:/tmp/jeprof指定生成文件的路径前缀。程序正常退出时jemalloc会把堆快照写到/tmp/jeprof.pid.seq.heap这样的文件里。如果不设这个前缀默认会在当前工作目录生成jeprof.pid.seq.heap。程序稍微跑久一点再退出给采样积累足够多的数据。如果程序启动1秒就退出可能一个样本都采不到。对于要长期运行的服务你可以在运行中通过malloc_conf或者发信号让jemalloc dump但那个复杂一点后面讲。程序跑完看下/tmp目录ls -lh /tmp/jeprof.*.heap看到类似jeprof.12345.0.heap这样的文件就说明采样成功了。文件大小通常几百KB到几MB取决于采样到的调用栈数量和深度。这里有个细节要提一下jemalloc的heap profiling是按dump次数维护文件编号的.0.heap一般是程序启动后的第一份快照。如果程序正常退出最后一份快照会被特殊标记表示这是终止时的堆状态。注意如果你的程序是通过kill -9强杀掉的Jemalloc没有机会做最后一轮dump你可能拿不到退出时的快照。这种时候就需要想办法在代码里主动触发dump或者用其他方式优雅退出。2.4 借助C语言内存管理的直觉来理解采样结果说到这儿我插一句C语言内存管理的话题。很多刚入门的同学会对泄漏有误解以为内存泄漏就是内存用了没释放。其实严格来说泄漏指的是已经失去引用的内存块——你连指向它的指针都丢了想释放也释放不了。而jemalloc的profiling并不直接区分这两种情况它记录的是这个过程到底分配了多少内存、谁分配的。所以你在报告里看到某个调用栈占用很高先别急着断定它是泄漏它有可能是合法的缓存、常驻内存或还没释放的长生命周期对象。真正的泄漏往往表现为调用栈在每次dump中都在增长且从不回落。后面第三部分会细讲怎么看增长趋势。正因为这样我建议在使用jemalloc profiling时先建立一层内存管理体检的思维先看总量、看分布、看增长再定位具体代码。这个思路比一上来就翻代码找free缺失高效得多。3. jeprof报告解读从heap文件到可读的结论3.1 heap文件里到底存了什么如果你直接cat一个heap文件会发现它是二进制的不能直接读。bin文件内部包含的是采样到的调用栈、每次调用栈对应的累计分配字节数、存活字节数、分配次数等信息。所以我们需要jeprof这个解析工具。jeprof是jemalloc自带的脚本安装在/usr/local/jemalloc-prof/bin/jeprof下面。它的原始设计参考了google-perftools的pprof所以用法也带着几分pprof的风格。你可以在编译生成的目录里找到这个脚本它本质上是个perl脚本依赖addr2line来解析符号地址依赖graphviz家族的dot来生成图形化报告——后面第四部分会讲到。先用最简单的方式看看堆整体情况/usr/local/jemalloc-prof/bin/jeprof --text /path/to/myapp /tmp/jeprof.12345.0.heap注意jeprof需要两个参数第一个是程序的符号文件第二个是heap文件。程序如果有strip过这里一定要用带符号的那个版本否则符号解析不出来。输出会是一张按累计分配字节数从大到小排列的表格每行是一组调用栈。类似这样Total: 1024.5 MB 512.0 MB 50.00% 50.00% 512.0 MB get_cache_data 256.0 MB 25.00% 75.00% 256.0 MB parse_and_store ...每次看到这种输出我习惯先看Total再迅速扫一眼最前面几行心里就有数了大头在哪、占比多少、调用路径是什么。3.2 --text和--pdf之外还有几个实用输出模式jeprof支持多种输出格式我挑几个常用的说一下省得你去翻man page模式命令示例用途文本汇总jeprof --text app heap快速扫一眼定位大头调用图PDFjeprof --pdf app heap out.pdf图形化看调用关系适合汇报调用图SVGjeprof --svg app heap out.svg和PDF类似但浏览器里可交互局部分析jeprof --text --focusget_cache_data app heap只看某个函数的分配详情对比分析jeprof --text --baseheap1 app heap2看两份快照之间的增量其中--base这个参数非常实用。你可以让程序分别在T1和T2时刻dump两份heap文件然后用第二份减去第一份得到的差值就是这段时间内各调用栈的内存增量。如果某个调用栈的增量一直在涨那你基本就锁定泄漏点了。这个方法比单看一份快照靠谱得多因为它过滤掉了从程序启动就一直存在的常驻内存。3.3 实操案例一行一行拆解如何锁定泄漏栈举个具体例子。假设我写了一个模拟日志缓冲区的程序它会不停地创建字符串但忘记释放。程序跑了大概2分钟我拿到了两份heap文件间隔60秒。先用增量模式看/usr/local/jemalloc-prof/bin/jeprof --text --base/tmp/jeprof.111.0.heap /usr/local/bin/myapp /tmp/jeprof.111.1.heap输出片段如下Total: 300.0 MB 280.0 MB 93.33% 93.33% 280.0 MB build_log_message 10.0 MB 3.33% 96.67% 10.0 MB read_config 5.0 MB 1.67% 98.33% 5.0 MB main看到build_log_message在60秒内增加了280MB基本可以断定问题在build_log_message内部或它调用的子函数里。这种时候再配合--focusbuild_log_message把它的完整调用链拉出来比如/usr/local/jemalloc-prof/bin/jeprof --text --focusbuild_log_message /usr/local/bin/myapp /tmp/jeprof.111.1.heap这样你会看到build_log_message下方的子函数逐层分配情况比如strdup、malloc(1024)这类细节。接下来就是打开源码对着排查找到那些在循环里malloc却没free的路径即可。这里有个我踩过一次的坑当报告解析出来的符号是??或者一堆十六进制地址时先别急着怀疑工具坏了。很大概率是程序符号表不全或者heap文件里记录的地址是PIE位置无关可执行文件加载后的运行时地址而你的符号文件取自非PIE的构建。最简单的解决办法编译时加-no-pie或确认符号文件与运行文件完全一致并且保存了完整的.symtab和.debug_info。4. PDF报告生成技巧给团队的交付物4.1 一份拿得出手的报告胜过千言万语排查内存泄漏很多时候不只是自己闷头搞定就完事了——你得把问题、证据链、修复建议讲给别人听。团队里其他成员、你的leader甚至客户方都需要一份清晰的东西来确认你真的找到了问题。这时候PDF报告就派上用场了。一张带调用栈的调用图比一大段文字描述直观太多。jeprof本身提供了直接把报告输出成PDF的功能底层调用的是graphviz的dot工具不需要你手动排版。它的命令很简单/usr/local/jemalloc-prof/bin/jeprof --pdf /usr/local/bin/myapp /tmp/jeprof.111.1.heap mem_report.pdf就这么一行PDF就生成了。打开之后你会看到一张有向图每个节点是一个函数节点大小与该函数分配的内存量成正比箭头表示父子调用关系。哪个函数是内存大头一目了然。不过默认生成的PDF有时会比较乱节点太多、连线太密看不太清楚。我一般会做两个优化。4.2 让PDF更好读的两个优化手段第一个手段是加--focus。只聚焦嫌疑函数相关的调用子图把无关的分支砍掉PDF体积和复杂度都会小很多。比如上面的例子只聚焦build_log_message/usr/local/jemalloc-prof/bin/jeprof --pdf --focusbuild_log_message /usr/local/bin/myapp /tmp/jeprof.111.1.heap leak_focus.pdf第二个手段是加--nodefraction和--edgefraction。这两个参数用来过滤太小太细的节点和边。比如--nodefraction0.05表示小于总内存5%的节点不画--edgefraction0.01表示小于1%的调用边不画。这样图面会干净很多突出主要矛盾。还有一个小技巧如果你不想用PDF而是想在浏览器里缩放查看、悬停看详情可以用--svg输出SVG格式/usr/local/jemalloc-prof/bin/jeprof --svg /usr/local/bin/myapp /tmp/jeprof.111.1.heap mem_report.svg这样交付给同事的时候他可以直接用浏览器打开交互体验比PDF更好。我个人一般两种都生成PDF用于正式文档存档SVG用于即时沟通。4.3 报告里记得写什么、不写什么一份合格的内存泄漏分析报告我觉得至少要包含这么几块内容现象描述程序运行时长、内存增长的量化数据比如从200MB涨到1.2GB。检测环境jemalloc版本、编译参数、采样间隔lg_prof_sample、程序符号版本。关键证据增量对比报告和聚焦后的调用图明确指出哪个调用栈在持续增长。修复建议定位到具体代码位置说明疑似泄漏点和初步修法。至于不写什么——我建议别把还没验证的猜测写进去。前面说了采样报告是统计性的它指出的是嫌疑方向不是板上钉钉的错误。你可以在报告里用疑似待确认这类措辞然后附上验证方法。有些人喜欢在报告里堆大量原始heap文件的十六进制数据我也觉得没必要除非目标读者是特别较真的内核级大佬否则核心结论加调用图就够了。我现在的习惯是每次排查完都会把命令和结果整理成一个markdown文件然后转成PDF随代码提交记录一起留档。这样下次再出现类似问题只需要跑一遍增量对比就能迅速对齐之前的结论省了很多重复劳动。5. 常见问题与排查技巧实录5.1 为什么我明明设置了MALLOC_CONF却没生成heap文件这是我被问得最多的问题。排查顺序一般是确认jemalloc是不是真的被加载了——用ldd myapp | grep jemalloc或LD_DEBUGlibs看看。确认jemalloc编译时有没有加--enable-prof——没有的话prof相关配置全都会被忽略而且不会有任何报错。确认程序是不是正常退出。如果程序崩溃或被强杀终止时的dump可能来不及写。确认采样是否真的发生了。lg_prof_sample:19意味着平均要分配512KB才会采一个样本。如果你的程序总共就分配了1MB内存大概率什么都采不到可以把lg_prof_sample调小到16甚至14试试。还有一个容易被忽略的MALLOC_CONF里的配置项之间用逗号分隔不要加空格。写成prof:true, lg_prof_sample:19这种带空格的jemalloc解析的时候会静默失败。这个坑我当年踩过一次排查了半天最后发现是空格的问题。5.2 报告里全是??或者十六进制地址符号解析不出来怎么办这个问题的原因和解决方案我在第三部分已经提到了一部分。这里再补充两个应对手段如果是PIE导致的问题可以在编译链接时加-no-pie或者运行时通过setarch -R关闭地址随机化来做检测。我通常直接用-no-pie因为构建是可控的。如果函数是内联inline的对符号解析也有影响。建议在编译时加-fno-omit-frame-pointer确保栈回溯能拿到正确的帧地址。jemalloc在做栈回溯时依赖帧指针这个选项能让backtrace质量高很多。5.3 报告显示内存还在涨但valgrind说没有泄漏听谁的这其实是两种工具定位不同导致的假分歧。valgrind的--leak-checkfull检查的是不可达的内存块也就是真正失去指针引用的块而jemalloc profiling显示的是内存仍在堆上、由某些调用栈持有。很多情况下内存增长是因为某个容器、缓存、队列没限制大小它里面的内存块依然是可达的valgrind自然不认为这是泄漏。但从系统角度看你的内存确实被无限消耗了这在语义上就是泄漏。我的建议是两者不矛盾而是互补。valgrind确认有没有不可达内存jemalloc回答内存持有者是谁、增长趋势如何。线上服务的内存问题大概率是后者这种逻辑泄漏——内存还有引用但引用集合无界增长。所以看到jemalloc报告里某个调用栈持续增长别急着否定即使valgrind说没问题也值得深入查一查。5.4 采样间隔怎么调才合适lg_prof_sample的参数选择本质是在性能开销和采样精度之间做权衡。我常用的一组经验值lg_prof_sample值实际采样间隔适用场景18256KB快速定位性能开销适中19512KB默认推荐日常排查首选201MB长时间运行性能敏感14~1616KB~64KB小内存对象多精度要求高如果你的程序单次分配都很小比如大量分配几十字节的字符串但总量很大那么即使采样间隔512KB也能反映出统计规律因为每个采样点代表它当时所在的分配路径。采样是均匀分布的小分配路径不会完全漏掉只是单个样本的权重会高一些。所以通常情况下没必要追求过小的采样间隔。提示在最终定位阶段我一般会把lg_prof_sample调到16就算到底了。再小jemalloc自身的采样式profiling开销就会变得明显程序运行行为可能偏离真实场景反而影响判断。5.5 我自己常用的三板斧排查流程最后分享一个我自己固定用的排查套路你可以直接抄作业第一板斧带prof功能重新编译glemalloc程序链接好后先用lg_prof_sample:19跑一趟确认能产出heap文件。第二板斧在怀疑泄漏的时间窗口内隔30到60秒主动触发两次dump用--base增量模式看增长最快的调用栈。这一步能过滤掉常驻内存的干扰直接暴露净增长从哪来。第三板斧拿到嫌疑调用栈后用--focus和--pdf生成一份聚焦报告同时打开源码逐行核对分配和释放路径。修复后重跑一次确认相同时间窗口内的增量明显回落到正常水平。关于第二板斧里的主动触发dump我再多说一句。对于持续运行的服务你可以在代码里通过调用mallctl来手动dumpp。大概是这样#include jemalloc/jemalloc.h // 在需要dump的时刻执行 const char *cmd prof.dump; int ret mallctl(cmd, NULL, NULL, NULL, 0);这样做的好处是完全掌控dump时机比如在压测的高峰、低峰各打一个点对比起来非常有说服力。比起依赖程序退出时的自动dump这种手动方式更适合生产环境和长时间压测。写在最后的一点体会用jemalloc做内存泄漏检测最大的感受就一个字快。快在有现成的工具链快在性能损耗低快在增量对比能迅速缩小排查范围。当然它不会替代valgrind也不会替代你自己的代码审查但它绝对值得放进你C语言内存排查的武器库里。我现在遇到内存问题第一反应已经不再是上valgrind跑一晚上而是先让jemalloc产出几份heap快照看看增长趋势再决定下一步。给团队做汇报时一份带调用图的PDF比什么都好使。最后再提醒一句检测工具永远只能帮你缩小范围真正修好代码还得靠对业务逻辑的理解和一份扎实的代码走查。但如果能让这个排查过程从几天缩短到半小时那花在工具配置上的那五分钟无论如何都是值的。