后端服务内存泄漏怎么排查
排查后端服务内存泄漏的标准路径是三步:先用 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」,得到的就是强引用链。
具体四步:
- 打开 hprof,等索引建完(8G 文件大概需要 10-20 分钟,MAT 的
MemoryAnalyzer.ini里把-Xmx调到文件大小的 1.5 倍,否则打不开)。 - 看 Dominator Tree(支配树),按 Retained Heap(被该对象独占持有的内存)倒序排。Retained Heap 高才是真凶,Shallow Heap 高只是表层。
- 右键最大节点 →
Path to GC Roots→ 勾选exclude all phantom/weak/soft etc. references,过滤掉软引用、弱引用这些不阻止回收的引用。 - 看整条路径最上面那个业务类。典型结果长这样:
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 引用链定位代码**。三步跑完,剩下的都是照着引用链改代码。
原文链接:https://www.gj0.com/thread-560.html
转载请注明出处并保留本声明;内容仅代表作者观点,与本站立场无关。若本文涉嫌侵权,请联系本站处理。