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_CONF="prof: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 | 快速扫一眼,定位大头 |
| 调用图PDF | jeprof --pdf app heap > out.pdf | 图形化看调用关系,适合汇报 |
| 调用图SVG | jeprof --svg app heap > out.svg | 和PDF类似,但浏览器里可交互 |
| 局部分析 | jeprof --text --focus=get_cache_data app heap | 只看某个函数的分配详情 |
| 对比分析 | jeprof --text --base=heap1 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内部或它调用的子函数里。这种时候再配合--focus=build_log_message把它的完整调用链拉出来,比如:
/usr/local/jemalloc-prof/bin/jeprof --text --focus=build_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 --focus=build_log_message /usr/local/bin/myapp /tmp/jeprof.111.1.heap > leak_focus.pdf第二个手段是加--nodefraction和--edgefraction。这两个参数用来过滤太小太细的节点和边。比如--nodefraction=0.05表示小于总内存5%的节点不画,--edgefraction=0.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_DEBUG=libs看看。 - 确认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-check=full检查的是"不可达的内存块",也就是真正失去指针引用的块;而jemalloc profiling显示的是"内存仍在堆上、由某些调用栈持有"。很多情况下,内存增长是因为某个容器、缓存、队列没限制大小,它里面的内存块依然是可达的,valgrind自然不认为这是泄漏。但从系统角度看,你的内存确实被无限消耗了,这在语义上就是泄漏。
我的建议是:两者不矛盾,而是互补。valgrind确认"有没有不可达内存",jemalloc回答"内存持有者是谁、增长趋势如何"。线上服务的内存问题,大概率是后者这种"逻辑泄漏"——内存还有引用,但引用集合无界增长。所以看到jemalloc报告里某个调用栈持续增长,别急着否定,即使valgrind说没问题,也值得深入查一查。
5.4 采样间隔怎么调才合适
lg_prof_sample的参数选择,本质是在"性能开销"和"采样精度"之间做权衡。我常用的一组经验值:
| lg_prof_sample值 | 实际采样间隔 | 适用场景 |
|---|---|---|
| 18 | 256KB | 快速定位,性能开销适中 |
| 19 | 512KB | 默认推荐,日常排查首选 |
| 20 | 1MB | 长时间运行,性能敏感 |
| 14~16 | 16KB~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,比什么都好使。最后再提醒一句,检测工具永远只能帮你缩小范围,真正修好代码,还得靠对业务逻辑的理解和一份扎实的代码走查。但如果能让这个排查过程从"几天"缩短到"半小时",那花在工具配置上的那五分钟,无论如何都是值的。