很多朋友来问 Linux 内存泄漏排查,开口往往是同一句话:“我拿 Valgrind 跑过了,没报 definitely lost,内存怎么还在涨?”或者反过来,“perf 不是号称能分析内存吗,为什么抓不到泄漏点?”
问得多了,我发现问题多半出在一个前提上:大家把 Valgrind、perf、Heap Profiler 当成了可以互相替换的同一种工具。实际上,这三者在 Linux 内存泄漏定位体系里各站一层,原理天差地别,适合的场景也完全不一样。
这篇附录想做的事情很简单:把三个工具放到同一张坐标系里,讲清楚它们各自在查什么、怎么查、能查到什么程度,最后给出一条从发现内存水位上涨到钉死泄漏代码的完整路径。这篇文章更偏“方法论 + 实操命令”,而不是某个工具的手册,原因是工具本身会迭代,排查思路才是能反复复用的东西。
1. 先搞清楚三个工具分别站在哪一层
1.1 为什么要先看原理层级
内存泄漏定位这件事,麻烦在于“内存泄漏”本身是一个很宽泛的结果描述,而不是一个标准化的错误类型。
对很多人来说,Leak 就是“内存一直涨、最终 OOM”。但落到操作系统层面,导致内存涨的原因可能是堆内存没释放、可能是缓存层主动不归还、可能是 mmap 段持续扩大、可能是线程栈反复创建销毁、甚至可能是分配器自己把空闲块留在缓存里不还给内核。不同原因,只有不同工具能观测到。
perf、Valgrind、Heap Profiler 三者的根本差异不是“哪个更准”,而是观测粒度不同:
- perf 站在内核视角,不碰你的程序,只采样事件。
- Valgrind 站在模拟器视角,把程序整个“囚禁”起来,逐指令监视。
- Heap Profiler 站在分配器视角,替换掉 malloc 层,记录每一次分配的调用栈和大小。
搞清楚这一点,后面所有命令和结论就顺理成章了。
1.2 perf:内核视角的“录像机”
perf 是基于内核 perf_event 子系统的性能采样工具。它不注入你的进程,不替换任何函数,也不改变程序的执行路径。它做的事情是:在内核里按一定频率采集硬件计数器、软件事件、tracepoint,再把采样到的指令地址或调用栈输出。
对于内存问题,perf 最常见的用法是采集缺页事件(page-faults)、内存访问延迟事件、或者通过 perf mem 对访存指令做采样。它能告诉你的是:在时间维度上,系统或进程发生了多少次缺页,缺页时 CPU 正在执行哪个函数。这个信息很有价值,但它不能直接告诉你“这里有 10 个字节没人释放”。
我用一个类比形容 perf:它在十字路口装了一个全景摄像头,能精确记录每辆车经过的时间和方向,但它不会替你去查某辆车的驾照和违章记录。它适合做整体流量的管控和嫌疑区域的缩小。
1.3 Valgrind:把程序放进显微镜下观察
Valgrind 的 Memcheck 工具是另一个极端。它不借用内核机制,而是动态二进制插桩——程序跑的不是原生机器码,而是先被 Valgrind 翻译成中间表示,在每条指令周围插入检查逻辑,同时用自己的内存管理模块替换掉 malloc/free。
换句话说,Valgrind 对程序做了全量、逐指令的内存状态跟踪。哪块内存分配了、有没有写越界、free 之后有没有再访问、程序退出时哪些内存还活着且没有指针指向它们——它都清清楚楚。
代价也很明显:程序跑在 Valgrind 上相当于套了个翻译层,速度慢 20 到 50 倍是常态,而且对运行环境要求苛刻,几乎不可能用在线上。它适合的是拿到一个相对可控的复现场景,把代码放到显微镜底下做最终定罪。
1.4 Heap Profiler:把分配器换成有计数的货架
Heap Profiler 一般指的是 Google gperftools(原 tcmalloc)里的 heap profiler,和 Valgrind 一样在用户态工作,但它没有做指令级插桩,而是选择了一个更实用的切入点:替换掉 malloc 和 free,在每次分配时记录调用栈、线程信息、分配大小,并定期或按一定条件导出堆快照。
从效果上看,它像是给仓库里每个货架装了一个计数器。哪个部门领走的货多、按什么频率领、用了之后是归还还是压箱底,都能统计出来。而且它不像 Valgrind 那样把一个程序拖慢几十倍,短时间挂在预发或低峰期服务上,可接受度远比 Valgrind 高。
启动方式也很灵活:可以通过链接 libtcmalloc 的方式静态替换,也可以用 LD_PRELOAD 在启动时加载。不需要改代码,这对排查存量服务很有用。
2. perf 实战定位内存泄漏:不直接报错,但能锁定战场
2.1 核心思路:先确认内存增长形态
很多人一上来就想拿 perf 抓“泄漏调用栈”,这个预期本身就是错的。perf 不是拿来直接出结论的工具,它的价值是“缩小范围”——先帮你确定泄漏是否真实存在、大致分布在哪个模块、是大块映射还是小块堆分配。
我一般建议先从最土的方法开始:连续观察进程内存状态。
while true; do grep -E 'VmRSS|VmData|VmSize' /proc/<pid>/status sleep 10 done把几组数据画出来,先回答三个问题:
- 内存是否在持续增长,还是只在高水位横盘。
- RSS 涨得快还是 VmData/VmSize 涨得快。
- 如果你手动结束进程、重启之后内存是否回落。
如果内存涨一小段就稳定,很可能是分配器缓存或者阶段性负载问题;如果单调递增不回头,才值得动用后面的采样工具。这里最容易踩的坑是把 tcmalloc/jemalloc 的“进程高水位”当成泄漏,它们在内存池策略上本来就倾向于保留空闲页不还给内核。
2.2 用 perf stat 观察缺页事件是否同步增长
确认完进程内存曲线后,下一步是做事件观测。
perf stat -e page-faults,minor-faults,major-faults -p <pid> -- sleep 60这条命令会在 60 秒内统计该进程的所有缺页事件。如果内存曲线持续上涨,同时 minor-faults 也随时间线性增长,说明有持续的、新的匿名页被分配并触碰。这里可以做一个简单的换算:一次 minor fault 通常意味着触达一个之前未触碰的页,如果每秒几百次缺页且每次分配 4KB 匿名页,那么一个小时内新增可达几百 MB,基本可以确认存在分配行为在持续发生。
但如果缺页事件很少,内存却仍然上涨,那问题往往不在常规 malloc,而在 mmap 大块映射、文件页缓存、Shared Memory 或者显存/驱动映射。此时你再拿用户态堆分析工具去查,大概率一无所获,因为方向就错了。
2.3 抓缺页调用栈,锁定嫌疑函数
确认缺页伴随增长后,下一步是用采样记录调用栈:
sudo perf record -e page-faults -g -p <pid> -- sleep 30 sudo perf reportperf record 会在每个缺页事件发生时记录当前的函数调用栈。跑完看 callchain,通常会看到某些函数路径频繁出现,比如正在解析配置、正在构造缓存对象、正在字符串拼接。这些栈不是泄漏本身,但它是泄漏发生现场。
我遇到过一个案例:进程 RSS 持续上涨,perf 抓到 page-fault 几乎都落在某个 xml 解析函数上,紧接着往里查才发现是半结构化日志解析后生成的对象没有进入销毁队列。perf 没直接告诉我“这里泄漏了”,但它把搜索范围从一个几千行代码的服务缩小到了几十行调用栈。
这里要提醒一句:perf record -e page-faults的采样开销要比普通 cpu-clock 大不少,不要挂太久,一般抓几十秒就够了。另外,云主机和部分虚拟机环境下 perf_event 权限可能受限,如果报Permission mmap之类的错误,检查/proc/sys/kernel/perf_event_paranoid,必要时换一台物理机,或者退回用 /proc 脚本采集数据。
2.4 perf 的边界边界在哪里
perf 能解决“从无到有、从大到小”的问题,但有两件事它做不到:
- 它不能告诉你某个内存块是否还被指针引用,也就无法定义严格意义上的“泄漏”。
- 它不能提供分配/释放的配对信息,所以看到某个函数频繁分配,可能是正常流量,也可能是泄漏,光靠 perf 伤难以下最终结论。
所以我把 perf 定位成“侦察兵”。它的产出是一份有嫌疑的地图,而不是一张起诉书。
3. Valgrind 精确到字节的插桩式排查
3.1 Memcheck 到底是怎么工作的
Valgrind 的 Memcheck 常被直接称为“Valgrind”,这让很多人误会它是一个通用性能工具。其实 Valgrind 是框架,Memcheck 是它最常用的内存检查工具。
Memcheck 截获程序对内存的每一次读、写、分配、释放,并且维护两块元信息:一块记录每个字节是否可读/可写,另一块记录每个字节之前的状态。程序退出时,它再扫一遍堆里的内存块,检查是否还有没被 free 的块,并且通过扫描栈和全局变量看这些内存块还有没有指针引用。如果既没释放又没引用,就报告为 definitely lost。
正是这种“逐字节跟踪 + 引用可达性分析”的机制,让 Valgrind 成为三者中唯一能给出“确定泄漏”结论的工具。它对 definite lost 的判断非常硬,不会因为采样覆盖率不足而漏检,因为它是全量检查。
3.2 一个完整的排查命令组合
假设我们有一个最简单的泄漏程序:
#include <stdlib.h> #include <unistd.h> void make_leak(void) { char *p = (char *)malloc(1024); } int main(void) { for (;;) { make_leak(); usleep(100000); } }用 Valgrind 跑这个程序,最常用的命令是:
valgrind --leak-check=full --show-leak-kinds=all --track-origins=yes ./leak_demo各选项我一般这样选:
--leak-check=full:让程序结束时不只输出汇总,而是输出每个泄漏点详细调用栈。--show-leak-kinds=all:展示 definite、indirect、possible、still reachable 四类详情,避免漏掉可疑情况。--track-origins=yes:可以追踪未初始化值的来源,排查问题时会一并带上。
跑完后的报告里会出现一段类似这样的关键信息:
1,024 bytes in 1 blocks are definitely lost in loss record 1 of 1 at 0x484A2A3: malloc (vg_replace_malloc.c:423) by 0x109156: make_leak (leak_demo.c:5) by 0x109175: main (leak_demo.c:10)这里的make_leak到main就是完整证据链。拿到这组栈,接着去代码里查为什么分配后没有保存指针即可。
如果你已经有比较有把握的怀疑函数,希望 Valgrind 在出错时立刻让你定位,可以加--error-exitcode=1,让 CI 或自动化脚本在发现泄漏时非零退出,构建直接失败。
3.3 读报告:definitely lost 之外那些项怎么办
新手最常见的困惑是看到still reachable和possibly lost就慌了。我的处理习惯如下:
definitely lost:真正的泄漏,必须处理。indirect lost:跟随 definitely lost 的结构体里继续泄漏,通常和前者同一根源。possibly lost:指针可能还被存在某个寄存器或某个被覆盖的局部变量里,Valgrind 无法完全确定。如果发生在自定义内存池、位压缩指针场景,容易出现误报,需要人工看代码。still reachable:程序退出时仍然有指针指向这块内存,只是没释放。严格来说这不是泄漏,因为理论上还能访问到。但如果它是全局容器里的缓存没有做退出清理,也不算大问题。
实际排查时我会优先处理 definitely lost,修完再重跑一遍。still reachable 是否处理,取决于你的出口路径是否安全。
3.4 Valgrind 的代价和限制
Valgrind 不是万能药,代价非常明显:
- 慢。同一个程序跑在 Memcheck 下,通常慢 20 到 50 倍。对于一个原本要跑 1 小时的批量任务,你可能要等大半天。
- 不适合线上。靠 Valgrind 直接挂在生产进程上不可行,变相改变程序时序,也可能掩盖与竞态相关的内存问题。
- fork/exec 的子进程默认不检查。如果你的泄漏发生在客户端创建的子进程里,要加
--trace-children=yes。 - 大内存程序会顶不住。Valgrind 需要大量额外内存来维护元数据,本身可能触发内存不足。
- 对系统调用直接分配的内存、驱动映射、硬件缓冲区域基本看不到。
所以 Valgrind 的正确使用姿势是:在可控的测试环境和插桩环境里复现问题,用性能换确定性,把最后那道“锤子”砸下去。
4. Heap Profiler:线上友好型快照对比
4.1 原理:替换 malloc 和堆快照
gperftools 的 Heap Profiler 为了兼容线上环境,选择了“替换 malloc”而不是“模拟指令执行”。它包装了 malloc/free/realloc,在分配入口记录调用栈和大小,同时维护当前存活堆的统计信息。
启动时通过环境变量控制快照策略,比如:
LD_PRELOAD=/usr/lib/libtcmalloc.so.4 \ HEAP_PROFILE_ALLOCATION_INTERVAL=1073741824 \ HEAP_PROFILE_TIME_INTERVAL=60 \ ./your_service这里的含义是:每分配 1GB 内存或每经过 60 秒,生成一个堆 profile 文件。你也可以用HEAP_PROFILE_MMAP=1追踪 mmap 大块映射。生成的heap.0001.heap文件保存的就是该时刻所有存活分配的调用栈统计快照。
4.2 用 pprof 看快照
拿到的 profile 是一堆带调用栈统计的数据,没法直接读,要用 pprof 分析。现代环境我一般用go tool pprof,它向下兼容 gperftools 生成的 profile。
go tool pprof -top ./your_service heap.0001.heap输出里会按“当前存活空间”把调用栈排出来,和 CPU profile 的 top 排序类似。占用最多空间的函数、库、模块会一目了然。
如果只看总体趋势,不关心具体栈,可以先用 pprof 的文本模式过一遍:
go tool pprof -text ./your_service heap.0001.heap一般来说,top 里长期占据前列的分配栈就有重大嫌疑。但注意,只看单个快照容易把“瞬时峰值”当成“泄漏增长”,真正要做的操作是快照对比。
4.3 快照对比是抓泄漏增长的关键操作
内存泄漏定位应该看“增长”,而不是看“存量”。一个正常服务也可能在启动后缓存大量数据,单看某一时刻的 heap profile,内存占用极高不代表泄漏。
正确做法是选两个不同时间点的快照做 diff。比如线上服务启动 10 分钟后取一个基准快照,运行 2 小时后再取一个快照,然后用 pprof 对比增长方向:
go tool pprof -diff_base=heap.0001.heap -top ./your_service heap.0002.heap这样看的是第二个快照相对第一个快照的增量。如果某个调用栈的“存活空间”随时间线性增长,而且增长量与业务波动不大相符,那基本可以锁定为泄漏点。再配合调用栈里的函数名,回代码里查是否有只分配不释放的对象即可。
比如我抓过一个网络代理服务,单看快照占比最高的都是连接池对象,但 diff 之后发现增长最快的是一个 metrics 统计用的字符串列表——每个请求都往里 append 一条记录,却没有清理逻辑。这类问题用 Valgrind 在压测环境里也能复现,但用 Heap Profiler 直接在预发环境观察增长,定位效率高得多。
4.4 什么时候用 Heap Profiler 而不是 Valgrind
我认为这两者的选择不应该是对立关系,而是前后关系:
- 当服务规模较大、无法接受 Valgrind 的性能损耗时,先上 Heap Profiler。
- 当内存上涨来自多模块协作、不确定性高时,用 Heap Profiler 先做大数据量上的“方向扫描”。
- 当已经用 Heap Profiler 缩小到某个模块甚至某个函数时,可以针对性地用 Valgrind 在测试环境尽量复现,获得更严肃的证据。
需要注意,Heap Profiler 本质上替换了内存分配器,程序的分配行为和 glibc malloc 下的表现会有差异。某些程序在 tcmalloc 下可能不泄漏、在 ptmalloc 下泄漏,反之也有。所以它给出的结论要结合分配器差异来解释,不能直接当成“程序实际运行状态”的定论。
5. 三个工具的横向对比表与选型决策路径
5.1 核心参数对比
| 对比维度 | perf | Valgrind (Memcheck) | Heap Profiler (gperftools) |
|---|---|---|---|
| 工作层级 | 内核采样 | 动态二进制插桩 | malloc 替换 |
| 是否改变程序执行 | 基本不改变 | 改变极大,慢 20-50 倍 | 改变分配器行为 |
| 能否在线使用 | 可以 | 几乎不行 | 短时、预发场景可用 |
| 泄漏判定能力 | 不能直接判定 | 可以直接判定 definite lost | 通过快照增长间接定位 |
| 典型输出 | 事件采样 + 调用栈 | 精确泄漏报告 | 堆快照 + pprof 增量分析 |
| 对进程内部原理依赖 | 依赖内核事件 | 不依赖内核、依赖 CPU 架构 | 依赖符号和动态链接节奏 |
| 盲区 | 内部状态不可见 | 系统调用 mmap/驱动映射难追踪 | 非 malloc 分配、全局对象难覆盖 |
这个表格做出来之后,你会发现三者的关系不是替代关系,而是从外到内、从粗到细、从线上到离线的层层递进。
5.2 从报警到收敛的实战流程
我实际带队排查内存泄漏时,基本按下面这条路走,每一步都有明确产出:
第一步,先用 /proc 或监控系统确认内存增长的形态,判断是 RSS 还是 VmSize,区分堆、缓存、mmap 哪个在涨。
第二步,如果疑似堆增长,用 perf 抓缺页事件,结合进程内线程栈缩小到具体模块。这一步的核心产出是“战场地图”。
第三步,针对地图上的那些函数,如果服务允许短时挂预发,用 Heap Profiler 开一两个小时的快照,对比增长,找出增长最快的调用栈。
第四步,拿着这个调用栈最近代码里回查,或者构造复现用例,用 Valgrind 在测试环境下验证,拿到 definitely lost 级别的结论。
这套流程里,perf 是侦察兵,Heap Profiler 是数据分析师,Valgrind 是检察官。只靠任何单点工具,都容易出现“查了半天没有结论”的尴尬。
5.3 常见误判与现实经验
最后分享几个我踩过、也看别人反复踩的坑:
第一,Valgrind 报告“干净”不代表内存真的没问题。它看不到进程外的映射、系统分配、以及部分现代指令集的模拟盲区。尤其多进程服务,漏了子进程就等于漏了主要泄漏源。
第二,perf 抓到的 page-fault 栈只是“分配发生地”,不代表“泄漏发生地”。有些分配是正常流量,问题在于某处没有回收,这时候要配合快照对比,不能盯着单个函数下结论。
第三,Heap Profiler 和 Valgrind 结论不一致时,先别急着怀疑工具坏了。它们原理不同,盲区不同,一个说增长另一个说没泄漏,很多时候是因为问题的形态不在另一者的检测范围内。这时候回看进程的 VmData/RSS 增长来源,往往能找到第三个答案,比如缓存碎片或 mmap 映射。
第四,调优或排查内存泄漏时,尽量在相同优化等级下比较结果。release 编译的指针生命周期和 debug 模式有明显差异,用 debug 版跑 Valgrind 和用 release 版跑 Heap Profiler,根因可能完全不是一回事。
我个人在实际操作中的体会是:内存泄漏排查很少是靠某一个“神器”一锤定音的,它更像一个取证过程。perf、Valgrind、Heap Profiler 各有各的观测维度和成本曲线,把三条证据链搓在一起,结论才能让人信服。
另外一个一直留存在心的小经验是:Heap Profiler 的快照对比别只在内存涨到很高的阈值后再取,应该在刚启动和上涨初期多留几份,很多重要线索藏在增长曲线变陡的那个拐点附近。真等到报警时再开工具,往往已经错过最容易定位的窗口期了。