Java内存泄漏排查实战:从GC原理到VisualVM与MAT定位 1. 先搞清楚JVM 明明有 GC为什么还会内存泄漏做 Java 开发的朋友应该都不止一次在群里看到类似的问题我的服务跑了两三天接口响应越来越慢最后直接 OutOfMemoryError 挂掉了日志里全是堆栈谁能帮忙看看 我第一次遇到这种情况时也很慌第一反应是“我代码里有那么大的集合吗”结果排查了两三天最后发现导致 OOM 的对象只有几十 KB——问题根本不在大对象而在不该活着的东西一直死不掉。聊内存泄漏之前先把一个反直觉的概念讲清楚。很多人觉得 JVM 自带垃圾回收GC只要对象没用了就会被自动清掉怎么还会“泄漏”这是个天大的误会。GC 回收的判定标准是可达性——从 GC Roots线程栈、静态变量、JNI 引用等出发走不到的对象才会被回收。 也就是说只要你的业务代码里某个“根”还持有着一个对象的引用这个对象就会永远活着哪怕你心里早已不需要它。我见过太多这种情况缓存 Map 添了不删、监听器注册了不注销、ThreadLocal 用完了不 remove、IO 流 finally 里忘了 close。这些对象单独看都不大但架不住一天几百万次调用撑上一两天老年代就满了Full GC 逐渐从每分钟一次变成每十秒一次CPU 飙高、响应变慢、请求堆积最后整块内存爆掉。这种“对象活着但已经没有业务价值”的状态就是 Java 里的内存泄漏。这篇文章我会完整复盘一遍我从头到尾的内存泄漏排查过程并且把涉及的每个环节都拆开讲从用自己的例子复现泄漏到用 VisualVM 做宏观观察再到用 MAT 做微观定位最后归纳几类最常见的泄漏模式。里面用到的工具都是免费的案例工程也很简单可以整套照着做。2. 搭一个一次就“准”的泄漏复现环境排查内存泄漏最忌“靠猜”但更忌的是在线上环境里做实验。我见过有同事直接上生产环境开 heap dump把服务卡了几分钟被运维追杀。所以第一步我们先在一个可控的小工程里复现问题把你正在怀疑的泄漏模式变成可观察、可量化的现象。2.1 写一个典型的“缓存未清理”泄漏代码模拟一个服务端很常见的场景一个全局的静态 Map 充当业务缓存不断接收外部请求往里写数据但从某个分支开始里面的数据永远不会被取出、也不会被删除。我会用几个类来拼出这个复现场景分别为模拟业务对象、模拟数据访问层和一个凑数的Controller。代码结构如下public class LeakyCacheService { // 静态Map全局持有 private static final MapString, OrderInfo CACHE new HashMap(); public void handleRequest(String orderId) { // 模拟从数据库查询订单查询完并没有直接返回 OrderInfo order new OrderInfo(orderId, new byte[2 * 1024 * 1024]); // 放入缓存但业务上后续并不会读这个缓存 CACHE.put(orderId, order); // 模拟少量业务处理 process(orderId); } private void process(String orderId) { // 该方法内部不会读取 CACHE只是凑数 } }OrderInfo 对象里我放了一个byte[2 * 1024 * 1024]的成员也就是每个订单对象大约占 2MB 内存。这样做的好处是几万个请求就能把堆撑爆不需要等太久。2.2 启动参数决定了观测难度很多朋友问我为什么自己复现时堆曲线喜欢“反复横跳”看不出来是不是泄漏。多半是启动参数没给对。我测试时通常会这样启动java -Xms64m -Xmx64m -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/path/to/dumps \ -jar leak-demo.jar关键点是-Xms 和 -Xmx 必须相等。如果不相等JVM 会先扩大堆来掩盖问题堆曲线表现成一路爬坡难以判断是业务峰值还是真实泄漏。把堆固定在 64MB只要内存一点一点往上涨最后百之百逃不过 OOM。再加-XX:HeapDumpOnOutOfMemoryError让它崩溃前自动落盘一份 dump 文件后面 MAT 分析就不怕没证据了。2.3 压测节奏观察“有进无出”的堆行为代码起来之后我用一个简单的循环模拟高频请求public class StressThread extends Thread { Override public void run() { LeakyCacheService service new LeakyCacheService(); int index 0; while (true) { service.handleRequest(order_ index); index; if (index % 100 0) { System.out.println(已处理请求: index); } } } }跑起来以后每秒产生几十个 2MB 对象预计两三分钟就能看到 OOM。压测环境没条件做太复杂的反正目标只有一个——让泄漏稳定复现。注意复现问题的场景和真实线上是有距离的。我这里的“距离”是故意拉大的——线上一个请求不可能吃 2MB 内存但正因如此小内存下几分钟就能出效果适合快速练手。等你看懂了现象再回过去审视真实业务的代码思路是相通的。3. VisualVM 初诊没有数据支撑的猜测都是耍流氓复现成功之后堆上环境了但问题到底在哪我还不知道。这时候就该 VisualVM 出场了它的核心价值是从宏观运行视角发现内存异常的形态。3.1 连接本地 Java 进程VisualVM 是随 JDK 一起提供的工具一般在 JDK 的 bin 目录里就能找到jvisualvm。新版 JDK 如果要单独装去 VisualVM 官网下载然后双击打开即可。启动之后左侧“本地”窗口会自动列出现在运行的 Java 进程。我一眼就能看到正在跑的 leak-demo 进程双击进入在“监视”页签里能看到 Heap、Metaspace、CPU 和类的整体曲线。这里有个实操经验如果你是从 JVM 启动之后才打开 VisualVM可能只能看到“后半段”的数据堆起点不是 0前后对比不明显。所以最好的做法是先把 VisualVM 开好再启动业务进程从头录到尾曲线的完整形态一眼就能看穿。3.2 从堆曲线读懂 GC 的拼命挣扎打开“监视”页签后观察 Heap 曲线的形态你会发现典型的“泄漏三段式”上升期压测开始后堆空间使用率快速抬升GC 频繁介入。锯齿期新生代回收后堆会有小幅回落但落不到底老年代占用逐步增大。濒死期Full GC 越来越频繁曲线呈“梳子状”横向间距越来越窄整体平走后直接顶到 64MB 上顶然后 GC 彻底放弃OOM 出现。看到第三阶段基本可以判定是内存泄漏而不是单纯的内存峰值因为如果是业务峰值堆会在GC后回到一个稳定基线而不是缓慢抬高。为了进一步观察 GC 行为可以在 VisualVM 中安装 Visual GC 插件。装好后能看到 Eden、S0/S1、Old 三个区域占用情况。我在测试时看到的现象非常经典Eden 区不断“满而复空”S0/S1 不停交换而 Old 区只增不减。这说明大量本应在 Minor GC 后死掉的对象被晋升到了老年代而老年代回收不掉它们于是 Full GC 越来越密集。3.3 抓 Heap Dump 的时机很讲究Heap Dump堆转储是内存快照它包含某一瞬间所有存活对象的类型、数量、引用关系。用 VisualVM 抓 dump 很简单进入“监视”页签点“堆 Dump”按钮或者用 jmap 命令行抓取jmap -dump:live,fileleak.hprof pid但抓的时机非常关键我有过惨痛教训刚启动 JVM 就抓可能什么都看不出来因为泄漏对象还没来得及累积Full GC 后马上抓又可能把垃圾都回收掉只看到大量真正的“活”对象。正确的时机是在压测中后期、内存占用已经稳定偏高、Full GC 已经开始密集出现的时候抓。这时候 dump 里全是“老不死”的泄漏对象。如果不方便用 VisualVM也可以直接在代码里用jcmd触发 dumpjcmd pid GC.heap_dump /path/to/leak.hprof我当时就是这样做的压测跑到内存升到 55MB 左右没有再回落的迹象赶紧执行 jcmd 抓了一份快照随后服务继续跑不到半分钟就 OOM 了——这份快照几乎完美保留了全部“泄漏群像”。4. MAT 详查从海量对象中揪出“万恶之源”VisualVM 告诉我“有泄漏”但没告诉我“谁在泄漏”。要回答“谁”这个问题就得请出 MAT——Eclipse 基金会出品的 Memory Analyzer Tool。我用过不少 dump 分析工具MAT 的Leak Suspects 报告和Path to GC Roots功能是定位效率最高的入口。4.1 导入 Dump版本不匹配时的坑把 VisualVM 抓到的 .hprof 文件导入 MAT打开 MATFile - Open Heap Dump。这里有个老版本 MAT 和新版本 JDK 的兼容问题经常有人栽在这儿自带 JDK 里抓的 hprof 格式MAT 打不开提示版本不支持。解决办法有两类。一类是让 MAT 用新版例如64位环境选择匹配的解析器直接在启动脚本里加上-Dosgi.data.area/your/path并选用新版本JDK作为运行时通常就好了。另一类是用 jhat 的转换逻辑但速度慢不推荐。 我实践经验是优先使用最新的 MAT 版本用 JDK 11 启动读取 JDK 8/11/17 的 dump 都没有问题。如果你还在用很老的 MAT建议升级这比折腾格式转换省心得多。4.2 Leak Suspects 报告先看嫌疑再动手导入完成后MAT 会弹出一个 Overview 页面。不用着急看饼图直接点页面上的Leak Suspects或者 “疑点报告”。MAT 会自动按“保留堆大小”把这些潜在泄漏聚成几组嫌疑并用自然语言给出结论。比如我那次分析报告第一部分会写一个类型为java.util.HashMap的实例占据约 52MB 的内存被LeakyCacheService的静态字段CACHE所持有且无法被 GC 回收。看到这句话我心里马上有数了——就是这个静态 Map 在吸收所有订单对象。Leak Suspects 给方向但它不会精确到“哪一行代码 put 进去的”。要回答问题还是得往下钻。4.3 Dominator Tree 与 Histogram 的配合使用打开Dominator Tree支配树视图它按“保留堆大小”从大到小罗列了所有对象。所谓保留堆指的是“如果把这个对象干掉最终能从堆里释放出的内存大小”。我第一次看到这张表的时候恨不得直接点“清除引用”。排列在最顶端的通常是java.util.HashMap展开它的 value 数组能看到一长串的OrderInfo实例每个都背着一个byte[2MB]。接下来用Histogram直方图验证一下类的数量规模打开 Histogram过滤OrderInfo能看到它共有 20 多万个实例合计 41MB 左右——这是决定性证据一个不读不该有这么多实体的存活的业务对象而且全部在一个名为 CACHE 的容器里。4.4 Path to GC Roots定位“谁在引用”的关键一步这是我认为整个 MAT 流程中最核心、也最容易被忽略的一个功能。右键任意一个 OrderInfo 对象选择Path to GC Roots - exclude weak/soft referencesMAT 会画出一条从 GC Roots 到该对象的完整引用链。我当时看到的链大概是这样的Thread 0x…… [main] - Java Local ......... - LeakyCacheService (class) - static CACHE : HashMap - Node[8932] - OrderInfo答案瞬间就水落石出根上是线程栈通过局部变量指向中间是类的静态字段最终持有所有订单对象的是这个全局缓存。所以修复方案也很明确——要么别用静态 Map要么用带过期淘汰的策略要么业务逻辑上根本不应该放进去。经验缓冲Path to GC Roots 里的 soft/weak 引用一般默认排除因为软引用、弱引用在被 GC 回收时是“允许放弃”的。如果你用 WeakHashMap 或软引用做了缓存但里面对象依然大量堆积那就要打开 Include all 看看是不是软引用被 JVM 认为还不能回收——这种情况往往说明内存压力大也侧面印证了其它硬引用占用太多。5. 六种最常见的泄漏模式对号入座就能省半天时间排查思路理顺之后我把平时代码评审里反复出现的内存泄漏模式系统整理了一下。不是所有人都有机会像我这样用 MAT 从零排查很多问题是看一眼代码结构就能识别的。泄漏模式产生原因典型特征修复方向静态集合无界增长static Map/List持续 put无任何淘汰堆曲线持续上升Old 区只增不减改局部变量或用 Caffeine 缓存、定时清理ThreadLocal 未清理线程池复用线程ThreadLocal 值残留线程对象长期无法回收元空间异常在 finally 中remove()内部类隐式持有外部引用非静态内部类活得比外部类久外部类对象无法回收改成 static 嵌套类IO/连接资源未关闭Stream、Connection 构建后没有 close大量 buffer 对象堆积native 内存飙升try-with-resources 统一关闭监听器/回调未注销注册了 Listener 但忘记 remove观察者对象堆积在事件源里生命周期对称unregister类加载器泄漏自定义 ClassLoader 被静态引用类的元数据无法卸载Metaspace 持续膨胀避免全局持有 ClassLoader我挨个讲下实战中是怎么识别、怎么修的。5.1 静态集合无界增长缓存变“存尸间”这就是我在上面复现的那个问题。很多团队喜欢用 HashMap 当缓存图省事、不想引第三方依赖。但缓存一旦没有容量上限、没有过期策略就是一个变相的容器泄漏。修复时建议优先用 Caffeine我自己常用配置是maximumSize(10000)expireAfterWrite(5, TimeUnit.MINUTES)一劳永逸。5.2 ThreadLocal 未清理线程池里的隐形炸弹ThreadLocal 通常是每个线程持有一个变量副本本身没问题。但在线程池场景下核心线程是长期存活、反复执行任务的如果任务里没有调用remove()ThreadLocal 里的对象就会被线程对象一直引用着。我踩过的坑是用 ThreadLocal 存用户上下文任务跑完没清线程池几百个线程每个线程绑定一个几 MB 的上下文内存就被吃光了。正确姿势是在 finally 块里保证删除。ThreadLocalContext tl new ThreadLocal(); try { tl.set(context); // 业务逻辑 } finally { tl.remove(); }ThreadLocal.remove()会同时清掉 ThreadLocalMap 中对应的 entry避免 value 残留。5.3 非静态内部类隐式的“外部类捆绑”非静态内部类内部会保存一个指向外部类实例的引用。如果一个内部类对象被传递到别处外部类对象也会跟着无法回收。我常用一个比喻这就像你只借走了一本书里的一页纸但图书管理员说“你必须带着整本书回家”——所以你欠书库一整本内存的库存。解决方案很直接如果不访问外部类实例字段把内部类改成static嵌套类切断隐式引用。5.4 IO 资源未关闭系统资源与堆的双重消耗文件流、网络流、数据库连接这些资源在 Java 中虽然也借助了底层 buffer但如果不关闭除了堆内存会出现大量缓冲对象还会导致文件句柄、socket 被占满。现代 Java 已经非常推荐用 try-with-resources 语法我总是建议团队把这条写进代码规约。try (FileInputStream fis new FileInputStream(/tmp/data.dat)) { // 处理文件 }5.5 监听器/回调忘记注销GUI应用或事件驱动系统里广泛存在。对象把自己注册到某个全局事件源去监听但生命周期结束时没有注销。时间长了事件源里存了一堆早已不用的监听器间接引用了它们的宿主对象。检查思路所有“注册”操作必须成对出现“注销”操作。5.6 类加载器泄漏这个比较高级通常在热部署框架例如某些 OSGi 容器或 Web 容器插件里可见。每次重新部署都会新建一个 ClassLoader但如果旧的 ClassLoader 被静态字段引用它加载的所有类就不能卸载Metaspace 就会持续膨胀。排查时需要关注 Metaspace 使用率修复方向是确保每次部署的上下文不会被子旧对象持有。6. 修复后的验证从“没报错”到“真的没泄漏”很多同学修完代码就跑一下接口发现不 OOM 就觉得完事了。那真不行内存泄漏是“慢性病”修复后必须做周期内的验证确认堆曲线能稳定回到基线。没有这一关改对也可能是改“蒙对”的。6.1 修复后重跑同一份压测我用同一个 64MB 的 JVM 参数跑相同的压测代码观察堆曲线。修复前堆一路走高最后贴到边界修复后堆会稳定在一个水平线附近产生“锯齿状回落”的形态——每次 GC 后内存能回到底部说明大部分对象可被回收。对比方法很简单在 VisualVM 里看两个阶段的堆曲线截图关注“GC 后堆底部是否匀速抬高”。指标修复前修复后堆最小值GC后持续抬高稳定不变Full GC 频率越来越频繁间隔稳定每个请求平均保留内存2MB 级几乎为 0OOM 是否出现几分钟内必现持续运行数小时无 OOM6.2 用 MAT 复查 Dominator Tree修完以后再用 jcmd 抓一份 dump导入 MAT看 Histogram 里 OrderInfo 实例数和保留大小是否降下来了。如果数量已经非常低说明泄漏链路被切断。另一种验证方式是直接看 Path to GC Roots——如果再也找不到静态 Map 持有大量订单对象的链说明问题闭环。6.3 线上预防监控与代码规范在没有明显业务热点时就发现内存问题比等它出问题再修要省事得多。我对团队的建议是在每个服务里加堆使用率监控指标选 GC 后老年代使用量和 Full GC 次数。设置兜底 JVM 参数-XX:HeapDumpOnOutOfMemoryError-XX:HeapDumpPath至少崩溃后能留证据。集成压测环节发布前跑一个半小时的回归压力对比 GC 曲线。代码评审检查单中加入“集合是否有限界”“监听器是否注销”“ThreadLocal 是否 remove”三项。如果是线上的低峰期半夜突然 Full GC 次数激增通常意味着有个定时任务在批量塞数据且忘记清理。这时候不要急着重启服务先把 dump 抓到留给 MAT 分析重启虽然止损但丢了现场等于把案发现场清洗了一遍。最后想再叮嘱几句在内存泄漏排查这件事上我始终认为最有价值的投资是把工具练熟。VisualVM 帮你快速确认“有没有病”MAT 帮你找到“病灶在哪”二者配合基本能覆盖 80% 的常见泄漏场景。剩下的功夫在写代码时就要往里攒——少用静态集合、明确集合生命周期、所有资源成对释放。实际操作里有太多人喜欢拿代码逐行“读懂法”看得头晕脑胀也没结论。要反过来先让工具告诉你数据在哪里异常再结合代码逻辑做对应缩小。每次 OOM 处理完我都会建议队友把这次复盘沉淀成团队的排查手册——内存泄漏问题千奇百怪但套路是相通的确认现象、抓对时机、顺着 GC Roots 一直找到源头问题就能逐渐被从黑盒里拎出来。