后端服务内存泄漏怎么排查

chinaz
chinaz 初级会员超兽战士
发布于 2026-10-07 21:24 ·1 浏览 ·0 回复

排查后端服务内存泄漏的标准路径是三步:先用 GC 日志确认「Full GC 后老年代基线是否持续抬升」,再用 heap dump(堆快照,某一时刻全部对象的快照文件)找出增长最快的对象类型,最后从 GC Roots 反向追引用链定位到具体代码。绝大多数泄漏不是「内存没释放」,而是某个集合、缓存或监听器只增不减。

怎么判断是真内存泄漏,而不是正常占用?

结论:判断依据是老年代(Old Gen)在每次 Full GC 后的基线值,连续 3 次以上单调上升,才叫泄漏。正常服务在 Full GC 后会回落到同一水位;如果回落后一次比一次高,就是泄漏。

Java 服务用 jstat -gcutil <pid> 1000,每秒打印一行,重点看 O 列(老年代使用率)和 FGC 列。例如 30 分钟内 FGC 从 12 次涨到 87 次、O 列从 41% 回落到 63%、70%、78%,这就是明确泄漏。

更省事的做法是加 JVM 参数:-Xlog:gc*:file=/data/logs/gc.log:time,uptime:filecount=10,filesize=50M(JDK 9+),或 JDK 8 的 -XX:+PrintGCDetails -XX:+PrintGCDateStamps。上生产前再加两个保命参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/,这样 OOM(内存溢出)时自动落盘,不用事后抓。

抓到泄漏对象的第一步命令是什么?

结论:先 jmap -histo:live <pid> | head -30 看实例数排名,同一个类在两次采样间实例数翻倍,基本就是嫌疑人。

命令是 jmap -histo:live 12345 | head -30。注意 :live 会触发一次 Full GC,线上会 STW(Stop The World,暂停所有业务线程),大堆可能停 1 秒到数秒,低峰期执行,别连续猛敲。

如果看到某个自定义类比如 com.xxx.CacheEntry 有 480 万个实例,而业务侧活跃用户只有 5 万,差值就是泄漏量。

要做深度分析就导快照:jmap -dump:live,format=b,file=/tmp/heap.hprof 12345。注意两点:文件大小约等于堆大小,8G 堆会生成 8G 左右文件,先确认磁盘够;容器里要 docker cp 出来分析,别在容器内开 MAT。非不得已别加 -F,它会在进程无响应时强行 dump,有把进程搞挂的风险。

拿到 heap dump 后怎么定位到具体代码行?

结论:用 MAT(Eclipse Memory Analyzer)打开 hprof,先看 Leak Suspects 报告,再对最大对象执行「Path to GC Roots → exclude weak/soft references」,得到的就是强引用链。

具体四步:

  1. 打开 hprof,等索引建完(8G 文件大概需要 10-20 分钟,MAT 的 MemoryAnalyzer.ini 里把 -Xmx 调到文件大小的 1.5 倍,否则打不开)。
  2. 看 Dominator Tree(支配树),按 Retained Heap(被该对象独占持有的内存)倒序排。Retained Heap 高才是真凶,Shallow Heap 高只是表层。
  3. 右键最大节点 → Path to GC Roots → 勾选 exclude all phantom/weak/soft etc. references,过滤掉软引用、弱引用这些不阻止回收的引用。
  4. 看整条路径最上面那个业务类。典型结果长这样:Thread → ThreadLocalMap → Entry → HashMap → 400 万个 OrderDTO,答案就是 ThreadLocal 用完没 remove()。

Go 服务怎么排查内存泄漏?

结论:Go 服务用 net/http/pprof,看 inuse_space 而不是 alloc_space,前者才是当前占用的内存。

引入 import _ "net/http/pprof" 并起一个 http.ListenAndServe(":6060", nil) 后:

  • go tool pprof -inuse_space http://10.0.0.5:6060/debug/pprof/heap,进交互后 top20 看占用最高的函数,list 函数名 直接看到哪一行代码分配。
  • 判断是否泄漏,隔 10 分钟抓两次做 diff:go tool pprof -base heap1 heap2,看哪些函数在持续增长。
  • goroutine 泄漏是 Go 最高频的泄漏源,用 curl http://ip:6060/debug/pprof/goroutine?debug=2 打印全部栈,同一个函数栈出现上千次就是它。常见成因是 channel 读写阻塞、http.Client 没设 Timeout、context 没取消。

最常见的泄漏原因有哪几类?

结论:按出现频率排,前四类是集合/缓存无上限、ThreadLocal 未清理、监听器与回调未反注册、资源未关闭。

  • 静态 static Map 当缓存:改成 Caffeine 或 Guava Cache,设 maximumSize(100_000) 和 expireAfterWrite(10, TimeUnit.MINUTES),别裸用 HashMap。
  • ThreadLocal:必须在 finally 块里 remove(),线程池场景下线程是复用的,不清理就是永久驻留。
  • 监听器、事件总线、addListener 类接口:注册和反注册必须成对出现,对象生命周期长的注册中心尤其危险。
  • 连接、流、ByteArrayOutputStream 缓存:全部放 try-with-resources。
  • 频繁 new Thread 或 newFixedThreadPool:线程池要作为单例复用,每次请求新建等于每次泄漏一个线程对象及其栈。

生产排查要注意什么?

结论:dump 前先评估 STW 影响,优先在低峰期做,并且一定要先保留现场再重启。

按优先级:先看 GC 日志确认趋势 → 导出 heap dump 存档 → 分析定位 → 灰度发布修复补丁 → 观察 24 小时老年代基线是否走平。若服务已被容器 OOMKilled 反复重启,把 dump 路径挂到持久卷,-XX:+HeapDumpOnOutOfMemoryError 抓到的快照就是最直接的证据,比重启后复现快得多。

整套流程的核心就一句话:**用 Full GC 后的老年代基线定性,用 heap dump 中的 Retained Heap 定位对象,用 GC Roots 引用链定位代码**。三步跑完,剩下的都是照着引用链改代码。

版权声明:本文来自 GJ站长论坛《后端服务内存泄漏怎么排查》
原文链接:https://www.gj0.com/thread-560.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。

全部回复 0

还没有回复,来抢沙发~