内存泄漏是指程序分配了堆内存,但在不再需要时未能正确释放,导致内存消耗持续增长。严重的泄漏会让进程最终耗尽系统内存,触发 OOM Killer 被杀死,或在容器环境中引发重启。排查内存泄漏需要从宏观趋势到微观定位,逐层逼近问题的根源。
22.2.1 增长趋势判断
首先需要确认进程的内存是否真的在持续增长,而不是正常的业务波动或缓存占用。
1. 使用 top 或 htop 观察实时内存
以 top 的 RES 或 %MEM 列作为起点:
top -p <PID>
关注 RES(resident memory,物理内存占用)是否随时间单调递增,且在工作负载稳定后仍不回落。需要注意的是,VIRT 包含映射的文件和尚未分配的虚拟空间,对泄漏判断价值有限,重点看 RES 和 SHR。
2. 通过 /proc/<PID>/smaps_rollup 记录进程内存
定期采集 PSS(proportional set size,按共享比例分摊后的物理内存)比 RSS 更能反映真实消耗:
while true; do
cat /proc/<PID>/smaps_rollup | grep -E '^Pss|^Rss' >> mem.log
sleep 60
done
如果 PSS 保持缓慢上扬,且不随请求低谷下降,泄漏的嫌疑很大。
3. 使用 pmap 查看内存映射详情
pmap -x <PID> | sort -nk3
重点关注 [heap] 段的 RSS 是否异常增大。如果是 C/C++ 程序,堆的增长通常直接体现为 [heap] 区域膨胀;对于使用 malloc 的内存池,也可以通过观察 /proc/<PID>/maps 中匿名字段的总量变化来辅助判断。
4. 结合业务指标排出误判
有些内存增长是合法的,比如 JVM 堆的逐渐扩增、Redis 的写入缓冲区、缓存程序的数据预热。需要结合应用的特征来判断:是否达到了预期的稳态?重启后是否会快速复现?如果重启后内存使用立刻回到高位,可能是缓存而非泄漏;如果重启后从低位开始又慢慢涨上去,泄漏的可能性很大。
22.2.2 堆内存分析
一旦认定存在泄漏,下一步是弄清楚“堆里到底被什么东西占满了”。
C/C++ 程序
- 使用
valgrind --tool=massif采样堆快照
valgrind --tool=massif --massif-out-file=massif.out ./my_app
ms_print massif.out
Massif 会定时记录堆的快照,展示哪些分配点累积了最多内存。报告中的“peak”位置通常指向最可疑的分配点。缺点是运行在 valgrind 下性能下降严重,不适用于高负载生产环境。
- 使用
heaptrack进行无损分析
heaptrack ./my_app
heaptrack --analyze heaptrack.my_app.<PID>.gz
Heaptrack 通过 LD_PRELOAD 机制拦截内存分配,性能开销比 valgrind 小,适合在测试环境或短暂运行的服务上使用。它生成的 GUI 报告和命令行摘要可以直观显示仍在存活中的分配的总量与调用栈。
- 使用
gperftools的堆分析器
通过链接 tcmalloc 和设置环境变量 HEAPPROFILE,可以定时 dump 堆的 profile,再用 pprof 分析:
pprof --text ./my_app heap.prof
适合已使用 tcmalloc 的程序,性能影响极低,甚至可以在生产环境中短期开启。
Java 程序
- 使用
jstat监控 GC 和堆容量
jstat -gcutil <PID> 1000
查看老年代(OU)和元空间(MU)使用是否持续上升,即使触发 Full GC 后仍不下降,说明存在泄漏。
- 获取堆直方图快速定位大户
jmap -histo:live <PID> | head -30
这个命令会触发一次 Full GC,然后列出存活对象的类名和占用字节数。如果某个业务类的实例数异常高,就很可能存在引用未断开。
- 导出堆转储并离线分析
jmap -dump:live,format=b,file=heap.hprof <PID>
使用 Eclipse MAT 或 VisualVM 打开 heap.hprof,执行“Leak Suspects”分析,工具会给出持有内存最多的对象链,直接指向泄漏的 GC Root。
Go 程序
- 使用 pprof 的堆分析
在程序中引入 net/http/pprof 或通过信号触发 profile 写入:
go tool pprof http://localhost:6060/debug/pprof/heap
# 交互界面中执行 top、list 等
Go 的 pprof 可以显示按分配或按 inuse 空间统计的调用栈,inuse_space 的值即为仍在使用的内存,持续增长的函数即为泄漏源。
通用方法:使用 eBPF 动态追踪分配
对于无法重启的生产环境,可以使用 bpftrace 追踪 malloc 和 free 调用,统计未释放的分配:
bpftrace -e 'uprobe:/lib/x86_64-linux-gnu/libc.so.6:malloc { @alloc[ustack] = count(); }
uprobe:/lib/x86_64-linux-gnu/libc.so.6:free { @free[ustack] = count(); }'
虽然输出量巨大,但可以结合过滤条件长期观测,适合排查偶发性泄漏。
22.2.3 泄漏定位
从堆分析拿到“哪个分配点产生的内存最多”后,要将信息映射回源代码。
1. 利用符号表和 Debug 信息
确保程序和库未被 strip,且编译时带有 -g 选项,这样工具给出的栈回溯才能包含函数名、文件名和行号。若程序是发行版二进制,请安装对应的 debuginfo 包。
2. 阅读 massif / heaptrack 报告中的调用链
以 heaptrack 为例,它会显示每一处分配的累计未释放量,点击后可以看到完整的调用栈,最顶层就是分配发生的代码位置。例如:
most memory was allocated in
... /path/to/file.c:42 (function leaky_loop)
直接定位到 file.c 第 42 行的循环中反复 malloc 但未 free。
3. Java 中的对象引用链
使用 MAT 打开堆转储后,选择“Histogram”视图,找到可疑类,右键选择“List objects -> with incoming references”,然后选择“Path to GC Roots -> exclude weak/soft references”。工具会展示一条从某个 GC Root 到目标对象的引用路径,例如:
Thread @ 0x...
-> MyCache @ 0x...
-> HashMap$Node[] @ 0x...
-> MyLeakyObject @ 0x...
这表明 MyCache 中的 HashMap 一直没有清理 MyLeakyObject,开发者就可以去检查缓存的淘汰策略。
4. 使用地址消毒剂(AddressSanitizer)在线检测
在测试环境中重新编译程序,加入 -fsanitize=address 选项。ASan 会在进程退出时报告所有未释放的内存块及其分配位置:
=================================================================
==12345==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 1000 byte(s) in 1 object(s) allocated from:
#0 0x7f... in malloc
#1 0x4... in leaky_function /src/leak.c:15
这是最精确的定位方式之一,但会消耗 2-3 倍内存并显著降低运行速度,适合测试和 CI 阶段。
5. 代码审查辅助
如果以上工具仍无法覆盖(如内核模块或特殊运行环境),可以返回代码,重点检查:
- 循环内的动态分配是否都有对应的释放;
- 资源管理类是否遵循 RAII(C++)、
try-finally(Java)或defer(Go); - 缓存、事件监听器、回调注册是否存在移除机制;
- 全局容器(如静态
Map)是否只增不删。
内存泄漏排查的总体思路是:先用低开销监控手段确认趋势,再用快照或采样工具掌握堆内存的宏观构成,最后借助分析工具将粗粒度嫌疑沉淀到具体代码行。 多数情况下,从趋势判断到定位完成,并不需要复杂的方法论,一套 heaptrack + 读报告或 jmap + MAT 的流程就足以解决 90% 的问题。关键在于养成观察内存指标的习惯,在泄漏彻底拖垮服务之前尽早介入。