人人都会AI编程

22.2 内存泄漏排查:增长趋势判断、堆内存分析、泄漏定位

更新时间:2026-07-12

内存泄漏是指程序分配了堆内存,但在不再需要时未能正确释放,导致内存消耗持续增长。严重的泄漏会让进程最终耗尽系统内存,触发 OOM Killer 被杀死,或在容器环境中引发重启。排查内存泄漏需要从宏观趋势到微观定位,逐层逼近问题的根源。

22.2.1 增长趋势判断

首先需要确认进程的内存是否真的在持续增长,而不是正常的业务波动或缓存占用。

1. 使用 tophtop 观察实时内存
topRES%MEM 列作为起点:

top -p <PID>

关注 RES(resident memory,物理内存占用)是否随时间单调递增,且在工作负载稳定后仍不回落。需要注意的是,VIRT 包含映射的文件和尚未分配的虚拟空间,对泄漏判断价值有限,重点看 RESSHR

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 追踪 mallocfree 调用,统计未释放的分配:

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% 的问题。关键在于养成观察内存指标的习惯,在泄漏彻底拖垮服务之前尽早介入。