真实的 JVM 内存泄漏排查实战案例,包含 Memory Dump 抓取、MAT 工具分析、诊断步骤及最终解决方案。

发布时间:2026/8/13 10:52:17
真实的 JVM 内存泄漏排查实战案例,包含 Memory Dump 抓取、MAT 工具分析、诊断步骤及最终解决方案。 文章目录 1. 事故现场与线上告警指标️ 2. Memory Dump 抓取与现场保护2.1 生产 JVM 参数防打底配置2.2 线上主动导出 Heap Dump 3. MAT (Memory Analyzer Tool) 诊断实战3.1 第一步查看 Leak Suspects 概览3.2 第二步分析 Dominator Tree (支配树)3.3 第三步路径溯源 (Path to GC Roots) 4. 根因代码定位与问题分析泄漏机制推导✅ 5. 最终解决方案与生产落地验证5.1 代码修复严格采用 try-finally 结构与 AutoCloseable 范式5.2 生产压测验证对比5.3 线上防御治理规范 1. 事故现场与线上告警指标去年 Q3 促销期间核心结算服务Settlement Service运行于 OpenJDK 17 容器分配-Xms8g -Xmx8g在连续运行 48 小时后爆发频繁告警。Prometheus 监控显示Heap 堆内存使用率呈现典型的“锯齿状上升但基线不断抬高”趋势最终老年代使用率锁定在 98% 上下触发频繁的 Full GC但单次 Full GC 仅能回收不足 50MB 空间。[Prometheus Alert] Service: settlement-service-prod-7f989 Metric: jvm_memory_used_bytes{areaheap} 95% Duration: 15m GC Pause Time: 12.4s (Full GC count: 38 in last 10 mins)容器日志打印出致命报错2026-07-18T10:14:22.5110800[http-nio-8080-exec-114]ERROR c.m.s.c.GlobalExceptionHandler - Exception caught java.lang.OutOfMemoryError: Java heap space at com.merchant.settlement.context.UserSessionContext.initBuffer(UserSessionContext.java:42)at com.merchant.settlement.service.OrderBatchService.lambda$process$0(OrderBatchService.java:88)at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1136)at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:635)at java.base/java.lang.Thread.run(Thread.java:833)直接重启节点仅能短暂维持运行约 6 小时后内存会再次吃紧典型的缓慢型内存泄漏。️ 2. Memory Dump 抓取与现场保护在生产环境中出现 OOM 趋势时必须先隔离节点摘除 API 网关流量保留现场并生成 Dump 镜像。2.1 生产 JVM 参数防打底配置线上服务必须预先配置自动 Dump 参数确保在抛出 OOM 的瞬间自动保留第一现场-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath/data/logs/dumps/heapdump_%p_%t.hprof-XX:ExitOnOutOfMemoryError# 发生 OOM 后立即终止容器触发 K8s Pod 重新拉起以恢复服务2.2 线上主动导出 Heap Dump如果服务尚未崩溃但内存居高不下利用jcmd工具手动导出内存快照相比jmap -dumpjcmd性能开销更低且推荐用于 JDK 11/17# 1. 查询目标 Java 进程 PIDjcmd# 2. 导出 Heap Dump 到临时挂载路径jcmd1GC.heap_dump /data/logs/dumps/settlement_leak_manual.hprof# 控制台输出# 1:# Heap dump file created 3. MAT (Memory Analyzer Tool) 诊断实战将导出的 8GB.hprof文件下载到本地启动 Eclipse MAT 工具调大MemoryAnalyzer.ini中的-Xmx内存至 16GB 以上导入分析。3.1 第一步查看 Leak Suspects 概览MAT 自动生成的Leak Suspects Report饼图直观地暴露了最大嫌疑人Problem Suspect 1 The thread java.lang.Thread 0x70018a3b8 http-nio-8080-exec-42 keeps local variables with total size of 6,812,410,232 (81.43%) bytes. The memory is accumulated in one instance of java.lang.ThreadLocal$ThreadLocalMap$Entry[] loaded by system class loader.系统超过 81% 的堆内存被单一线程池中的线程对象以及其绑定的ThreadLocalMap所占据。------------------------------------------------------------------------------- | MAT Heap Dump Overview (8.0 GB) | | | | [...........] | | Problem Suspect 1: ThreadLocalMap$Entry[] (81.43% - 6.5 GB) | | Other Objects: (18.57% - 1.5 GB) | -------------------------------------------------------------------------------3.2 第二步分析 Dominator Tree (支配树)点击切换至Dominator Tree视图按照Retained Heap保留堆该对象被回收后能直接和间接释放的内存总量进行降序排列Class NameShallow Heap (Bytes)Retained Heap (Bytes)Percentagejava.lang.Thread 0x70018a3b8 http-nio-8080-exec-421,2481,362,491,02416.29%java.lang.Thread 0x70018a990 http-nio-8080-exec-181,2481,321,102,11215.80%java.lang.Thread 0x70018b108 http-nio-8080-exec-051,2481,280,004,80015.30%java.lang.Thread 0x70018b880 http-nio-8080-exec-211,2481,195,430,40014.29%展开Thread节点的引用路径java.lang.Thread 0x70018a3b8 http-nio-8080-exec-42 -- threadLocals java.lang.ThreadLocal$ThreadLocalMap -- table java.lang.ThreadLocal$ThreadLocalMap$Entry[256] -- [12] java.lang.ThreadLocal$ThreadLocalMap$Entry -- value com.merchant.settlement.context.UserSessionContext -- cacheMap java.util.HashMap -- table java.util.HashMap$Node[1024] -- [45] com.merchant.settlement.model.OrderCacheBuffer (32MB byte[])3.3 第三步路径溯源 (Path to GC Roots)右键点击内存占比最大的UserSessionContext实例选择Path to GC Roots-exclude all phantom/weak/soft references排除弱引用与虚引用。GC Root: java.lang.Thread (System Class / JVM Internal) └─ threadLocals (java.lang.ThreadLocal$ThreadLocalMap) └─ table[12] (java.lang.ThreadLocal$ThreadLocalMap$Entry) ──[Strong Reference]── Value └─ UserSessionContext └─ HashMap (cacheMap)关键诊断突破点ThreadLocalMap$Entry对Key即ThreadLocal变量本身是弱引用WeakReference但对Value即UserSessionContext是强引用StrongReference。由于 Tomcat 的 HTTP 线程池http-nio-8080-exec-*中的线程是被反复复用的常驻线程即使外部的ThreadLocal对象被销毁Entry.value依然被Thread对象的threadLocals属性强引用导致UserSessionContext以及内部绑定的 32MB 缓存无法被 GC 回收 4. 根因代码定位与问题分析结合 MAT 提供的线程栈跟踪信息Thread Details定位到涉及代码段packagecom.merchant.settlement.context;importjava.util.HashMap;importjava.util.Map;publicclassUserSessionContext{privatestaticfinalThreadLocalUserSessionContextCONTEXT_HOLDERThreadLocal.withInitial(UserSessionContext::new);privatefinalMapString,ObjectcacheMapnewHashMap();publicstaticUserSessionContextget(){returnCONTEXT_HOLDER.get();}publicvoidputCache(Stringkey,Objectvalue){this.cacheMap.put(key,value);}publicstaticvoidclear(){CONTEXT_HOLDER.remove();// --- 提供了清除方法}}审阅业务调用逻辑OrderBatchService.java// 存在 Bug 的业务逻辑源码publicvoidprocessBatchOrders(ListOrderDTOorders){UserSessionContextcontextUserSessionContext.get();// 模拟为当前请求分配大内存做计算缓存context.putCache(BATCH_BUFFER,newbyte[32*1024*1024]);if(CollectionUtils.isEmpty(orders)){return;// 漏洞点 1早期分支直接 return未执行 clear()}try{for(OrderDTOorder:orders){doSettlement(order);}}catch(Exceptione){log.error(Batch settlement failed,e);thrownewBusinessException(SETTLE_ERROR,e.getMessage());// 漏洞点 2抛异常后未在 finally 中 clear()}UserSessionContext.clear();// 仅正常流程结尾调用非常脆弱}泄漏机制推导Tomcat 工作线程处理 HTTP 请求调用UserSessionContext.get()向线程绑定了32MB缓存。当遇到非空校验拦截或者业务内部抛出异常时代码跳过了位于结尾处的UserSessionContext.clear()。工作线程执行完毕归还至 Tomcat 线程池核心线程数 200。随着高并发请求不断涌入200 个 Tomcat 线程全部污染上废弃的UserSessionContext强引用单线程持有多个脏缓存直接积压堆内存高达200 × 32 MB ≈ 6.4 GB 200 \times 32\text{MB} \approx 6.4\text{GB}200×32MB≈6.4GB导致老年代瞬间暴涨引发连续 Full GC 与 OOM。✅ 5. 最终解决方案与生产落地验证5.1 代码修复严格采用try-finally结构与 AutoCloseable 范式重构上下文管理将清理逻辑强制置于finally块中同时引入AutoCloseable接口结合try-with-resources彻底避免漏写remove()packagecom.merchant.settlement.context;publicclassUserSessionContextimplementsAutoCloseable{privatestaticfinalThreadLocalUserSessionContextCONTEXT_HOLDERThreadLocal.withInitial(UserSessionContext::new);privatefinalMapString,ObjectcacheMapnewHashMap();publicstaticUserSessionContextopen(){UserSessionContextctxCONTEXT_HOLDER.get();ctx.cacheMap.clear();// 避免复用旧线程残留的数据returnctx;}publicvoidputCache(Stringkey,Objectvalue){this.cacheMap.put(key,value);}Overridepublicvoidclose(){this.cacheMap.clear();CONTEXT_HOLDER.remove();// 强制清理清理 ThreadLocal 避免内存泄漏}}业务侧改写为安全的链式调用publicvoidprocessBatchOrders(ListOrderDTOorders){// 配合 try-with-resources无论发生异常还是提前 return离开作用域必定触发 close()try(UserSessionContextcontextUserSessionContext.open()){if(CollectionUtils.isEmpty(orders)){return;}context.putCache(BATCH_BUFFER,newbyte[32*1024*1024]);for(OrderDTOorder:orders){doSettlement(order);}}}5.2 生产压测验证对比完成修复后在压测环境环境配置与生产完全一致-Xms8g -Xmx8g200 并发持续压测 4 小时对比 GC 情况关键指标修复前 (Bug 逻辑)修复后 (try-finally重构)OldGen 内存使用峰值7.8 GB 7.8\text{GB}7.8GB(97.5%频繁 Full GC)2.1 GB 2.1\text{GB}2.1GB(稳定处于 25%~30%)Young GC 次数/平均耗时1,420 次 / 32ms2,100 次 / 18msFull GC 次数/总耗时38 次 / 142.8 秒 (OOM 前夕)0 次 / 0 秒接口 P99 延迟12,400ms (卡顿严重)14ms (平滑稳定)5.3 线上防御治理规范静态代码检查规约在 SonarQube 中加入自定义规则禁止在类成员变量中私有定义ThreadLocal却在方法内部缺乏finally { ...remove(); }逻辑在 CI 阶段拦截。容量边界预警对于需要在线程上下文传递的大对象一律限制容量上限如最大字节数禁止直接将大容量数据映射放入ThreadLocal中。