内存泄漏这种问题放到生产环境里基本都是先闹一段“越来越慢”“内存见底”“重启就好”的幺蛾子然后大家才被迫认真查。查的时间往往比修的时间还长就是因为好多人一上来就敲 valgrind结果要么根本跑不起来要么把 50 倍开销的锅全甩给工具。Linux 环境下定位内存泄漏perf、Valgrind、Heap Profiler 这三类工具各管一段它们不是替代关系而是递进关系。这篇附录我尽量把每个工具的定位、原理、常用参数、坑点讲透读完之后你至少能知道眼下这个泄漏到底该用哪一个怎么用什么时候换工具。先说个结论perf 适合在生产环境做粗筛Valgrind 适合在开发环境拿证据Heap Profiler我默认指 gperftools 的 heap profiler适合在压测环境看趋势。三者的代价、精度和适用阶段完全不同选错工具基本等于白忙。下面按这个思路展开。1. 先搞清楚你要定位的是哪一种“内存泄漏”1.1 两种增长形态决定完全不同的排查路线常说的内存泄漏其实有两种。第一种是“真泄漏”C/C 里最常见malloc 出来的指针丢了或者 new[] 配 delete 而不是 delete[]链表节点删了头忘了遍历shared_ptr 互相引用导致引用计数永远到不了零等等。这类问题 Valgrind 的 Memcheck 能直接判死刑工具会把它归成 definitely lost。第二种是“业务膨胀”也叫应用内膨胀或逻辑泄漏对象还有人引用着引用计数也是正的但业务上这些对象已经永远不会被用到了。典型例子是缓存不淘汰、日志队列不断堆积、任务队列只进不出、线程池里积累了废弃的上下文。这类问题 Valgrind 看到的结果往往是“still reachable”你没法直接报错必须结合业务判断。很多同学在这上面栽跟头以为 still reachable 不是泄漏就放过了结果 RSS 曲线照样一路往上走。所以先想清楚你的现象是“长时间空闲内存也不回落”还是“并发峰值后内存居高不下”前者多数是真泄漏后者要重点怀疑逻辑膨胀。这一字之差决定你应该首先上 Valgrind 还是先上 perf 加 Heap Profiler。1.2 工具的本质分层采样、插桩与插桩优化把三个工具摆开看它们的实现机制完全不同。perf 是内核事件子系统它从内核视角去采样内存分配事件记录调用栈它不干扰你的业务进程逻辑进程逻辑只是在一个固定频次或者特定事件发生时“拍照”。所以 perf 的开销可以控制在极低水平在生产环境挂几分钟是可行的。Valgrind 是动态二进制翻译整个程序被放到一个模拟 CPU 上执行每条指令都会经过 Valgrind 的翻译层。Memcheck 在这个翻译层上维护“影子内存”记录每个字节的合法性、初始化状态还额外在分配块上下加 redzone 检查越界。代价就是 20 到 50 倍减速程序大一点基本没法在日常环境跑只能拿来跑回归用例或者复现现场。Heap Profiler 走的是另一条路用 LD_PRELOAD 把 glibc 的 malloc/free 替换成 tcmalloc然后在 malloc/free 入口拦截通过哈希统计每个调用栈的分配热度和内存占用周期性地生成快照文件。开销大约 2 到 5 倍压测环境完全扛得住而且不用改业务代码。一句话总结perf 是流量监测告诉你“谁在频繁分配”Heap Profiler 是接口日志告诉你“哪个调用栈的堆内存一直在涨”Valgrind 是抓包取证能精确告诉你“这个指针真的彻底丢了”。1.3 为什么不能只依靠某一个工具有人觉得 Valgrind 最牛能报 definitely lost直接用它不就行了吗问题是生产服务器进程动不动几十个线程、几十 G 内存Valgrind 跑起来慢到业务会直接超时而且日志量大到没人敢完整看。反过来perf 虽然能在生产上跑但它只负责统计分配事件根本不会去追踪指针是否还被引用你没法从 perf 报告里看到“这个块到底有没有被释放”。Heap Profiler 则有大量的快照文件能看出增长曲线但如果你连业务代码都不熟pprof 输出的函数栈也只是个名字判断最后还是得人来做。所以现实场景里一次完整的泄漏排查往往是先用 perf 把嫌疑模块圈出来再切到 Valgrind 去拿精确证据修复之后再挂 Heap Profiler 做整夜验证。三种工具是排查链路的不同环节不是三选一。2. perf生产环境先行的轻量粗筛2.1 perf 能做什么内核事件采样与调用栈perf 的完整能力远超“内存泄漏定位”它本身是 Linux 自带的性能剖析框架但这里我们关心的内核事件子系统和调用栈采样能力。你可以让内核在某个事件发生的瞬间记录当前的进程、函数地址和用户态调用栈之后用 perf report 把数据聚合出来就知道哪个模块是分配的大头。针对内存分配内核相关的 tracepoint 主要有几个kmem:kmalloc、kmem:kfree、kmem:kmem_cache_alloc、kmem:kmem_cache_free还有伙伴系统的 mm_page_alloc。我们关注的是分配和释放的不平衡。如果你在 60 秒内看到 kmem:kmalloc 数量巨大而对应 kfree 几乎没有至少说明这部分分配路径没有释放或者释放发生在用户态的不透明层面值得往下查。需要明白的一点是perf 里看到的是内核态分配事件跟用户态的 malloc 并不是一一对应的。glibc 的 malloc 收到小块请求时往往直接复用 tcmalloc 之前分配好的 arena 空间不一定会触发内核的 kmalloc。所以别指望用 perf 直接算出“泄漏了多少字节”它只能给你“谁在频繁走内核分配路径”这样一个相对粗的方向。2.2 常用的内存类 tracepoint 与命令组合我平时在松一台生产机器做粗筛时的标准姿势是这样# 全系统采样分配事件按调用栈聚合持续60秒 perf record -a -e kmem:kmalloc -F 99 -g -o /tmp/perf.data -- sleep 60-F 99 是采样频率99Hz 对生产环境压力不大。如果怀疑事件太多导致 overhead 高可以降到 49 或者只采样特定进程# 只看指定进程pid的分配事件 perf record -e kmem:kmalloc -p 12345 -F 99 -g -o /tmp/perf.data -- sleep 60分析数据用perf report -i /tmp/perf.data --stdio --no-children--no-children 的意思是只看实际命中的叶子函数不要把所有累加都堆到根上否则你会看到一个很宽的调用栈列表反而抓不住“到底谁分配了最多”。如果内核事件跑不了说明系统里的 perf_event_paranoid 限制太严手动调一下# 临时放开建议只在测试阶段设置 sudo sysctl kernel.perf_event_paranoid1perf 报告里调用栈能不能还原成函数名取决于你有没有符号表和栈帧。编译程序时建议至少加 -g 和 -fno-omit-frame-pointer否则抓出来全是地址还得花时间 addr2line 反解。2.3 perf 的三类盲区实操体会用 perf 定位内存问题有三个坑我是踩过的。第一个坑是用户态 malloc 和内核态 kmalloc 之间的映射问题。前面说过glibc malloc 的 fastbin 会缓存小块内存大量 malloc/free 完全不会触发内核分配。所以如果进程 RSS 只涨不快分配次数也不多perf 的记录里可能干干净净啥也看不出来。这时候你得配合 /proc/ /smaps 或者 VmRSS 的监控先确认“到底有没有人真的在向内核要内存”再决定下一步。第二个坑是快路径分配根本不在 tracepoint 里。比如 per-CPU 分配、页表分配、内核里某些栈路径上的 fastpath这些事件没有暴露成稳定的 tracepoint你采样采不到。遇到这种情况只能接受现实perf 是方向指引不是定案工具。第三个坑是采样可能丢失调用栈。生产进程里经常是深调用栈加高并发-F 调太高反而会因为 perf 自身的事件排队导致丢失栈也经常被截断。所以我一般会先看分配热点排行前 20 的函数再手动 grep 相关的源码路径而不是直接沉到所有调用栈里。这能省很多时间。3. Valgrind慢但能拿到底稿3.1 Memcheck 的原理与四种泄漏归因Valgrind 的 Memcheck 不是传统的调试器它把整个进程放到自己定制的 CPU 模拟层里跑每条指令都要先被 Valgrind 翻译成中间表示再交给宿主 CPU 执行。需要检测内存错误时Memcheck 会维护一份影子内存记录每个地址的已定义/未定义状态并在每次 malloc 返回时记录地址和大小释放时就抹掉记录。程序退出后它扫描全局变量区和栈区找出“没有任何指针可达的已分配内存块”据此判断泄漏类型。Memcheck 输出的泄漏类型有四种很多新手只看 definitely lost这是不够的definitely lost分配后没有任何指针指向100% 泄漏直接判死刑。indirectly lost本身没丢但它被一个 definitely lost 的父块所持有比如结构体里嵌套的指针。possibly lost存在某个指针指向内存块内部但无法确定它是否真的是“所有权指针”常见于指针被转换成整数、再转回来。still reachable全局变量或栈上仍有指针指向该块按规范不算泄漏但如果业务上认定这些块“不该活着”它依然是问题。修复 definitely lost 和 indirectly lost 是硬功夫而 still reachable 需要结合业务去看。比如启动时加载一次的全局配置对象进程跑完后还在理论上“可达”但严格说并没泄漏但如果它是每个请求都 new 出来挂到全局缓存队列里那就算 still reachable 也一样要处理。3.2 让 Memcheck 跑起来参数要点一个可复现泄漏的有罪判定通常这样起valgrind --toolmemcheck \ --leak-checkfull \ --show-leak-kindsall \ --track-originsyes \ --error-exitcode1 \ --log-filevalgrind_%p.log \ ./your_program arg1 arg2--leak-checkfull 是必须的只有 full 才会输出每个泄漏点的完整调用栈。--show-leak-kindsall 会把这四种类型都列出来避免你漏看 possibly lost。--track-originsyes 能追出未初始化值的来源但这开销非常大如果不是怀疑用了未初始化内存我建议关掉以缩短执行时间。--error-exitcode1 是为了让它能作为 CI 的一个判断关卡。--log-filevalgrind_%p.log 会把输出按进程号拆开多进程场景特别好用。如果程序太大别直接全量跑。可以先编一个纯内存分配验证的小用例只请求那几个你认为泄漏的函数路径把数据量调小到可控范围。很多大项目不是不能用 Valgrind是没人愿意拿全量数据去跑一跑一个晚上都不一定完。3.3 Massif从内存曲线到分配热点Memcheck 负责找“是否泄漏”如果你还想看“整个程序的内存使用随业务变化怎么波动”就得用 Massif。Massif 的职责是快照式堆分析。它定时记录当前堆内存占用和各个分配点的大小生成一个 .out 文件再通过 ms_print 或可视化工具还原成曲线。定位思路是跑一段正常的业务操作然后在某个对业务关键的时刻做一次高负载请求看曲线是否突然抬升而且不再回落。valgrind --toolmassif --time-unitB --stacksno \ --massif-out-filemassif.out ./your_programtime-unitB 意思是按分配字节数采样而不是按指令条数采样这对内存问题更直观。跑完用ms_print massif.out | less输出里最上面的曲线和摘要表会标“峰值点”下面每个 snapshot 都会列出那一刻最占内存的调用栈列表。如果峰值点对应的调用栈是一个缓存结构那基本可以判定是业务膨胀而不是指针丢失。3.4 Valgrind 的代价和工程化管理Valgrind 慢到什么程度我实测过一个 8 线程的收单服务正常跑 QPS 上千挂上 Memcheck 之后 QPS 掉到几十一个回归用例从 2 分钟变成 40 分钟。这还是在它内部串行调度线程的情况下多线程时间片切得更碎额外损耗更大。所以把 Valgrind 塞进日常开发 CI 里一定要控制跑测范围和超时指标。我见过很多团队在单测阶段跑一遍全量 Valgrind结果构建卡死最终直接把这条流水线禁掉了。合理的方式是只保留几个核心内存敏感用例每个用例设定最大执行时间 10 分钟超过就视为失败同时开启 --error-exitcode1保证真正的问题能阻塞发布。你要是连这个都跑不动就压缩数据规模用代表性小样本复用同一条代码路径这既能触发问题又能控制在可接受的时间窗口内。4. Heap Profiler压测环境里的折中方案4.1 gperftools 的实现逻辑gperftools 的 heap profiler 是很多人容易忽略的利器。它不靠内核事件也不做二进制翻译而是直接通过 LD_PRELOAD 把 libtcmalloc.so 塞进进程让所有 malloc/free 调用都走 tcmalloc 的代码路径。tcmalloc 内部在分配/释放时会把这些动作记到调用栈哈希表里统计每个栈的累计分配字节数、存活字节数和调用次数然后在达到设定的分配间隔或时间间隔时把当前快照写成一个 heap 文件。这样做的好处很直接不用改代码不用重新编译开销只有 2 到 5 倍压测环境完全扛得住。它跟 Valgrind 最大的不同是它只做统计不做引用分析和边界检查所以它更适合回答“当前内存主要堆在哪些调用栈”而不是“谁泄漏了某个地址”。4.2 接入、启动与文件生成第一步是确认环境里有 libtcmalloc。Debian/Ubuntu 下可以sudo apt install libgoogle-perftools-dev google-perftools或者直接找编译好的文件也行重点是要确认进程真的加载到了 libtcmallocLD_PRELOAD/usr/lib/x86_64-linux-gnu/libtcmalloc.so.4 \ HEAPPROFILE/tmp/app_heap \ ./your_program启动之后程序会按默认的 1GB 分配阈值生成快照文件命名类似/tmp/app_heap.0001.heap /tmp/app_heap.0002.heap ...如果程序整体分配不大想尽快拿到快照可以调小分配间隔export HEAP_PROFILE_ALLOCATION_INTERVAL268435456 # 256MB export HEAP_PROFILE_TIME_INTERVAL60 # 或每隔60秒快照一次快照文件会越来越多跑长时间压测时建议定期清理旧文件避免把磁盘塞爆。观察时重点对比最早的快照和最新的快照如果某个函数在最后一轮里仍然占据大量存活字节而它在早期快照里同样存在那你大概率已经锁定了嫌疑模块。4.3 pprof 报告解析与增长对比gperftools 自带的 pprof 脚本可以直接把 heap 快照渲染成文本、图形或 PDF也可以按函数名搜索。pprof --text ./your_program /tmp/app_heap.0010.heap pprof --pdf ./your_program /tmp/app_heap.0010.heap report.pdf pprof --listSomeFunction ./your_program /tmp/app_heap.0010.heap--text 会输出一个调用栈和字节占比的列表--pdf 则生成带调用关系的图。图上每个节点都有几个数值当前栈自身占用的存活字节、包含所有子调用后的总字节以及样本数。通常我们更关心“总字节”那一列因为它体现的是整棵子树的内存占用。我更推荐直接对比两个快照pprof --text --inuse_objects ./your_program /tmp/app_heap.0001.heap pprof --text --inuse_objects ./your_program /tmp/app_heap.0020.heap如果 0020 比 0001 的某个函数多出几倍那就说明这个模块是增长主场。用 --inuse_objects 还能看到对象个数避免出现“单对象很小但对象数量巨大”的隐蔽问题。4.4 和 Valgrind、perf 的差异在选型上Heap Profiler 介于 perf 和 Valgrind 之间。它比 perf 更贴近用户态的分配行为因为它 hook 的就是 malloc/free你能看到完整的业务调用栈它又比 Valgrind 便宜得多能撑起长时间压测。但它的短板也很明显它没有引用追踪不会告诉你某块内存是不是还“可达”也不会分析越界行为。它只能帮你画出一条内存趋势曲线和分配热点雷达图最后判断还是人工补上。所以在整套流程里它适合在压测和发布前阶段确认修复效果也适合线上发生内存增长后先由运维挂一晚上捞出热点函数再让开发拿着函数名去 Valgrind 阶段做精细化排查。5. 三工具横向对比与组合策略5.1 系统对比表维度perfValgrindgperftools Heap Profiler实现原理内核事件采样动态二进制翻译malloc/free hook定位粒度分配热点调用栈精确到每条泄漏路径分配热点堆缓存趋势性能开销5%~20% 左右20~50 倍减速2~5 倍左右适用阶段生产/预发粗筛开发/CI 取证压测/发布前验证输出内容perf report 火焰图leak summary、调用栈heap 快照、pprof 图是否需要改代码不需要不需要不需要能否追踪指针可达性不能能不能典型误用场景指望它算泄漏量生产环境全量跑想找精确泄漏地址这张表基本就能回答“我该选谁”。如果非要给一条硬性规则线上先用 perf复现后在开发环境用 Valgrind发布前压测时挂 Heap Profiler 一周观察趋势。顺序别倒过来。5.2 三种实战组合打法组合 A线上先定位方向。方法生产机器上停掉一部分流量或者挑一个业务低峰窗口perf record 采样分配事件 10 分钟拿到报告后看前 10 个分配热点。如果热点集中在某个模块直接把这个模块拉下来做本地复现如果热点分散说明全局分配压力大未必是单一泄漏优先检查缓存和对象池。组合 B开发环境精确取证。方法本地写一个能触发该模块的复现用例挂 Valgrind --leak-checkfull拿到 definitely lost 和 possibly lost 的详细栈。修复代码后再跑一遍确认 leak summary 归零。这是最“标准”的流程效率最高因为不用在生产上冒险。组合 C压测环境持续观察。方法把 Heap Profiler 挂到压测机上的被测进程压测模型保持稳定持续跑 1 到 2 小时期间每 30 分钟收回一个快照文件。最后做成快照对比看曲线斜率是趋缓还是持续上扬。如果持续上扬就把对应调用栈拉出来交给开发修修完再挂一遍对比修复前后的曲线是否变平。这三种组合可以独立存在也能串起来。最常见的完整链路就是 A - B - C即线上粗筛、本地取证、回归验证。5.3 参数选择背后的逻辑很多同学会纠结为什么 perf 的 -F 不直接拉满。原因是 perf record 的采样本身也会消耗 CPU在内存分配非常频繁的场景下-F 99 已经能采集到足够多的样本。提到 999 反而可能让 perf 自身的事件队列溢出导致关键调用栈丢失最后报告里的反而不准确。你在压测环境想更精确可以提到 499但生产环境别这么玩。Heap Profiler 的 HEAP_PROFILE_ALLOCATION_INTERVAL 设置同理。设得太小比如 16MB会疯狂生成快照干扰业务设得太大又可能整个压测周期只有一个文件没法对比趋势。我默认是 1GB如果压测总分配量在 10GB 量级那差不多能生成 10 份快照正好够画出增长曲线。Valgrind 的 --track-originsyes 也不是无脑开的。它有额外性能损耗且影响最终判断如果不怀疑未初始化内存建议关闭但如果 valgrind 出现 Conditional jump depends on uninitialised value就重新开一次拿到源头再关掉跑回归。6. 常见问题与排错实录6.1 常见问题速查表现象可能原因处理办法perf record 提示 permission deniedperf_event_paranoid 限制用 root 或调 sysctl kernel.perf_event_paranoid1perf report 看不到用户态函数程序没有符号、帧指针被优化编译加 -g -fno-omit-frame-pointerValgrind 跑到一半被杀超时或 OOM缩小数据规模、拆用例、限制单用例时间Valgrind 报 still reachable未必是真泄漏但需要人工判定结合 RSS 走势确认是否业务膨胀LD_PRELOAD 后 pprof 没数据libtcmalloc 没加载成功ldd 确认或检查是否被沙箱清除了 LD_PRELOADheap 文件生成太多撑爆磁盘分配间隔太小调大 HEAP_PROFILE_ALLOCATION_INTERVALpprof 输出全是十六进制地址代码被 strip 过保留带符号二进制或 debug 包6.2 排查技巧与避坑经验第一个技巧排查前先给进程拍个基线照。记录 /proc/ /status 里的 VmRSS 和 heap 段峰值然后周期性采样。没有基线你很难判断 memcheck 的 “still reachable” 是不是问题。第二个技巧别在已经用着其他内存分析库的环境里挂 gperftools。LD_PRELOAD 会被覆盖或者 hook 链冲突malloc 计数彻底乱掉。一次线上排查时我遇到过业务程序自带了 jemalloc我再挂 libtcmalloc 就互相打架快照文件就没意义了。先查 ldd 输出有没有别的 malloc 替代库再决定要不要用 LD_PRELOAD。第三个技巧编译时保留 debug 符号。分布式发布环境常常会把二进制 strip 掉以减小体积但这对三种工具都是灾难。perf 没符号只能出地址Valgrind 没符号栈会断在汇编层pprof 没符号输出全是 hex。稳妥的做法是发布时同时产出保留符号的 debug 包单独存放排查时直接用同一 commit 的 debug 版对齐。第四个技巧区分并发增长与泄漏。如果一个进程单线程独占内存持续增长而多线程并发时内存反而回落大概率是线程私有数据没清理Valgrind 不一定能查出来优先看线程栈和 Thread Local Storage。第五个技巧修复完一定要回到原始环境做回归。我曾经修掉一个 definitely lost 之后内存下降了一点但整体 RSS 曲线还是向上涨结果发现根因是另一个 still reachable 的缓存队列。Valgrind 清零不代表业务曲线变平必须再用 Heap Profiler 做一轮对比验证。6.3 一个真实场景的排查顺序去年我处理过一个订阅推送服务的案例现象是运行两个小时后内存从 1.2G 涨到 9G重启即好。第一阶段我先在低峰窗口用 perf record 采集了十分钟 kmem:kmalloc报告里有大量调用栈落在消息解码模块和连接管理模块方向锁定到连接管理。第二阶段本地用 Valgrind 跑一个模拟连接断开的小用例memcheck 直接给出 definitely lost栈都在连接对象的引用计数上根因是连接关闭时异步回调持有了已销毁对象的裸指针。第三阶段修复代码后在压测环境挂 gperftools 两小时读取首尾两个 heap 快照确认连接管理模块的存活字节不再增长。整个过程一天完成如果一开始就让整个服务挂 Valgrind大概率一个晚上都跑不完一轮。需要强调的细节是在每个阶段看清工具的适配边界再行动。perf 出了方向就不要执着于让它算出精确字节数Valgrind 出了 definitively lost就不要拿它去评估整个服务性能Heap Profiler 画出曲线就老老实实做前后对比别拿它的热点列表去冒充泄漏证据。我自己实际排查时的习惯是任何内存问题第一件事永远先看 RSS 曲线和 /proc 里的 heap 段变化确认问题真实存在于什么时间窗口然后根据窗口性质决定上 perf 还是 Valgrind最后用 Heap Profiler 做回归。工具选型这件事本质上是在“可观测性、精确性、开销”三个维度里做权衡没有一把刀能切所有菜但只要你弄清楚每个工具管的是哪一段排查效率至少能提高一个量级。最后再分享一个小经验真正难处理的其实不是 definitely lost而是那些“看起来还活着”的对象业务上已经永远不会再访问了。工具只能告诉你对象活着判断它该不该活永远是你的工作。