Java内存泄露排查实战:从监控曲线到MAT与Arthas定位

发布时间:2026/10/2 15:29:15
Java内存泄露排查实战:从监控曲线到MAT与Arthas定位 从监控图上看到堆内存一天比一天高每次Full GC后只能回落到一个更高起点时那种感觉我太熟悉了——先是怀疑配置再是怀疑代码最后彻夜翻dump文件。这篇文章想聊的就是我排查内存泄露时积累下来的一些真实感受和可复用方法没有高深理论全是实操里反复验证过的路径。无论你是刚接触Java服务排查的新手还是被线上问题折磨过的老手这篇内容应该都能帮你在乱麻中找到线头。1. 先把“内存泄露”这顶帽子戴对了再说1.1 别把所有内存上涨都叫内存泄露很多人一看到监控曲线往上走就喊“内存泄露”其实这个判断本身就可能出问题。我见过太多案例最后定位到的根本不是内存泄露而是流量上涨带来的正常水位抬高或者是JVM参数不合理导致的频繁晋升。判断是不是内存泄露我有一个三板斧第一看趋势是否单调递增。正常业务波动带来的内存变化高峰过后总会有回落。如果每次回落都落不回原来的低点而是逐级抬升这才有“泄露”的嫌疑。第二看时间跨度和版本变更。内存持续上涨3天以上不回落或者刚发版本后出现异常这两个场景的排查路径完全不同。第三看堆与非堆的关系。有些时候堆内存没涨但Metaspace一直涨这就是另一条路的问题不能用堆内存的排查思路来套。先把定性做对后面才不会白忙活。我自己的习惯是监控图上连续多个GC周期至少5个以上都呈现“底部抬升”形态才开始进入真正的排查流程否则只是虚惊一场。1.2 排查前必须先搞定的三个前置条件有同学一上来就抓dump文件结果分析了半天发现hook不上代码最后白干一场。在动手之前有三件事必须确认清楚其一确认你对代码有足够的掌控力。手上的代码是不是当前线上版本Git分支对不对有的环境部署时用了旧包分析出来的栈信息跟代码对不上那真是欲哭无泪。其二确认你能复现或至少能触发疑似泄露的场景。如果问题偶发那运气成本很大建议先压测复现否则等你慢悠悠分析完dump问题又自己消失了你连验证的机会都没有。其三确认监控面板里的GC日志被完整保留。没有GC日志做佐证后面的堆转储分析就是空中楼阁。把这三步走完我通常才会考虑动手。1.3 我来用一个具体场景描述“正常”和“异常”的差异举个常见的例子。一个订单查询服务刚启动时堆内存约1.2GB运行一上午后稳定在2.8GBFull GC回落到1.5GB下午又升到2.9GB再回落到1.5GB。这种情况其实是正常的——对象在被频繁创建年轻代不够放部分对象晋升到老年代GC后又清理掉一部分水位动态波动但基线稳定。异常的情况是什么呢上午的回落点从1.5GB变成1.8GB下午变成2.1GB第二天开盘直接3.2GB起步。每次GC的确能清掉一部分内存但永远清不到昨天的水平。这时候我才会严肃地对自己说坏了这真可能是内存泄露。2. 排查前三板斧先用最粗的粒度圈定范围2.1 监控曲线怎么看才不算白看监控面板上信息很多但内存排查时我真正关注的指标其实非常集中——堆内存使用量、GC频率与耗时、老年代占用趋势。其他繁杂指标可以放到后一步先盯死这三个。堆内存使用量要看“趋势”而不是“瞬时值”。GC后的堆内存最小值曲线才是泄露排查最可靠的朋友。我习惯把Full GC后的堆大小单独拉出来画一条线这条线的斜率就是泄露速度的直观体现。GC频率和耗时也有讲究。如果Full GC频繁到每几分钟就一次但每次耗时又不长说明堆里面存活对象多且复制成本低。如果Full GC单次耗时在1秒以上且持续走高那就是老年代碎片化加对象增长的组合拳。老年代占用趋势则要和晋升速率对比着看。我踩过一个坑——老年代涨得厉害但年轻代也很活跃一度让我怀疑是对象创建太猛后来才发现是ThreadLocal持有对象导致的老年代缓慢膨胀。光看一条曲线是不够的要把几组曲线并排放。2.2 内存快照对比法dump前后对比才是王道很多教程会告诉你“分析dump文件时看大对象”这个说法并不完整。真正的排查套路是在怀疑泄露的时间段内连续抓两份、甚至三份dump文件互相之间做对比。具体怎么操作我常用的做法是观察到老年代持续上涨后在某个时间点T1抓一份dump然后正常跑业务6到8个小时再在T2抓一份dump。注意两份dump之间一定要间隔足够久否则差异太小分析不出有价值的信息。抓完两份dump后用MAT打开它们对Histogram页面做对比重点关注“Retained Size增量最大”的类。这一步是最能给出方向性结论的能直接从几万个类里筛出真正增长的嫌疑对象。我有一次排查缓存泄露就是因为对比后发现com.example.CacheEntry这个类在8个小时里Retained Size翻了4倍一下子就定位到问题类。2.3 Linux下几条实战命令帮你快速摸高不废话直接上命令。如果服务还在跑我不建议一上来就重启或停服抓dump先用系统层面命令摸个底避免打草惊蛇也避免误伤正常业务。看JVM堆内使用jmap -heap [pid]这条命令能快速看堆参数配置和当前各区域使用率。抓堆转储jmap -dump:live,formatb,fileheap.hprof [pid]live参数很关键只dump存活对象文件更小分析更快。看线程状态jstack [pid]如果内存泄露的同时伴随CPU飙高大概率有并发问题在疯狂创建对象。查看GC日志jstat -gcutil [pid] 1000 301秒钟打一次连续打30次观察老年代变化趋势。看系统进程状态top -Hp [pid]确认内存是不是真的被JVM吃掉还是被堆外内存悄悄消耗了。这些命令不复杂但组合起来就能形成判断链路。我尤其喜欢jstat -gcutil这个组合因为它能在不影响服务的情况下秒级反馈GC表现比jmap惊动服务要好用得多。2.4 这里有一个新手最容易忽略的非堆内存区域堆内存排查了半天找不到问题结果Full GC后内存还是持续上涨这时候要立刻换个思路看看非堆内存。MetaspaceJDK8后替代PermGen是一个经常被忽略的重灾区。ClassLoader加载了大量的类或者反射生成大量代理类Metaspace就会默默膨胀。这个问题有一个特征在监控面板上表现为“非堆内存”曲线趋势上涨且每次Full GC后没有任何回落迹象。排查Metaspace泄露的时候jmap -clstats命令可以查看类加载器统计信息。如果发现某个自定义ClassLoader的类数量只增不减那基本就是它对引用的对象有错误的持有。我以前处理过一个动态生成类的场景就是因为一个静态Map持有所有反射生成类的Class对象导致Metaspace炸了。还有一个非堆区域是直接内存Direct Memory它不归JVM堆管容易被误判为系统内存泄露。排查思路是看堆外内存占用与Direct Buffer池的关系并且检查代码里ByteBuffer.allocateDirect()是否被正确回收。3. 工具链实战拆解MAT和Arthas才是排查双雄3.1 MAT分析Histogram、Dominator Tree和Leak Suspects的正确姿势MAT是Eclipse Memory Analyzer的简称名字带Eclipse但跟IDE耦合度很低可以独立分析dump文件。拿到dump文件后我固定按三个顺序来看不跳步第一步看Overview页签的“Leak Suspects”。这个功能会自动把堆内存按照“可能存在泄露嫌疑”的对象组分类。注意它是按存活对象累计大小来找可疑对象的并不代表就真有泄露。它给出的结论需要人工二次验证。第二步看Histogram按Shallow Heap排序。Shallow Heap是对象自身占用不包含引用对象适合迅速找到“大头”。但单纯说某类对象Shallow Heap大还不能下结论因为可能是大数组或大集合也可能是正常的缓存必须去看它的Retained Heap。第三步看Dominator Tree支配树。支配树这个功能能直接指出“如果我要回收某个对象理论上一共能释放多少内存”换句话说就是谁是真正的内存持有者。在Dominator Tree里顺藤摸瓜找到持有大量对象的根部节点顺着引用链一直往上往往能发现问题的源头——比如某个被遗忘的静态集合。3.2 Arthas排查运行时内存的独门技巧如果线上不能随便抓dump文件或者想在不打断业务的情况下看运行时状态Arthas就是一个很好的辅助工具。它不需要对代码做任何改动attach到目标进程就能用。我用得最多的是dashboard和memory命令。dashboard可以秒级展示当前内存分区的占用情况memory命令则更细致地列出heap、non-heap、direct buffer等各项数值。这两条命令组合起来在问题定位的早期能避免盲猜。更有意思的是“trace”和“watch”组合。当通过MAT锁定了某个类有嫌疑后可以用Arthas的trace命令跟踪这个类核心方法的调用路径和执行耗时再用watch命令观察特定方法入参和返回值的对象大小。典型的场景是某个方法每次都把大批量查询结果塞进一个静态Map肉眼看不到但用watch一测返回的List大小再乘上调用频率就能估出每小时的内存增速。需要提醒一下Arthas虽然好用但在生产环境长时间挂载会有一定的性能影响建议在需要排查的窗口期使用完成定位后及时卸载或退出。3.3 dump文件抓取时机抓错了等于白抓说到抓dump文件时机太重要了。我见过有人在高并发时期直接jmap抓堆抓完发现文件大到服务器磁盘被撑爆业务直接挂掉那真是雪上加霜。比较稳妥的做法是这样的先在业务低峰期抓第一份触发一次手动Full GC可以用jcmd GC.run命令等GC完成后再抓第二份。这个顺序的意义在于第一份dump反映的是“跑了一段时间后的稳态”第二份dump反映的是“GC清理后的状态”。如果第二份dump里面仍然有一大堆本该被回收的对象说明这些对象是被某处引用牢牢挂住的这就是经典的内存泄露特征。另一种常见方案是“间隔对比抓取”如上文提到的T1和T2各抓一次。两份dump都尽量选在低峰期避免业务流量对内存形态造成干扰。抓完后直接做Diff操作MAT用“Compare Base”功能就能对两份dump对比效率高很多。3.4 绕不开的GC日志分析从CMS到G1的参数差异GC日志是整个排查链条里最诚实的数据来源因为它记录的是JVM当时的真实行为不依赖任何二次加工。JDK8默认使用CMS或并行GCJDK11及以后G1成了主流两者的日志格式和分析侧重差别很大。CMS时代我习惯看“ParNew”和“CMS”这两个关键阶段。如果年轻代GC后晋升到老年代的对象数量显著增多Old区的占用就会持续抬升。这里有一个细节——CMS的Initial Mark阶段会Stop The World如果这个停顿频繁出现且越来越长说明堆内碎片和存活对象都在积累。G1时代重点观察“Evacuation Pause”和“Humongous Allocation”。G1的日志会直接打印每个Region的回收情况。如果一个Region里都是大对象Humongous而这些大对象又不被回收泄露的线索就藏在这里。JVM参数上CMS和G1的堆初始比例、晋升阈值都不相同建议不要盲目套用。比如CMS的-XX:MaxTenuringThreshold默认是15但频繁晋升时可以把阈值调低让对象更早进老年代减少年轻代复制开销而G1的-XX:G1NewSizePercent等参数则需要根据业务对象生命周期单独调。这些参数调优不是一蹴而就的必须在GC日志的辅助下反复验证。3.5 为什么说ThreadLocal是内存泄露的头号种子选手排查过不少案例后我越来越觉得ThreadLocal是内存泄露的头号种子选手。名字带Local但实际上它的根是一个ThreadLocalMap而ThreadLocalMap是Thread对象内部的字段。线程池的核心线程通常长期存活这意味着Thread对象也长期存活。如果ThreadLocalMap里的Entry持有value值却不清理那这个value就会顺着“Thread对象 - ThreadLocalMap - Entry.value”的引用链一直被拽住JVM根本不敢回收。我踩过一个典型场景一个定时任务线程池里每次请求都往ThreadLocal里塞一个用户上下文对象代码里也调用了remove()。但有一个分支路径漏了remove导致用户上下文对象一直挂在核心线程上。在某些极端情况下这个对象还引用了一个大List内存占用呈线性增长。排查ThreadLocal泄露时MAT的Dominator Tree里找Thread对象然后展开它的threadLocals字段就能看到哪些Entry是应该清理但没有清理的。这个方向几乎一瞄一个准比在业务代码里逐行翻要高效得多。4. 真实案例复盘一次压测引发的Full GC风暴4.1 问题现象与第一时间误判那个案例非常典型。服务在压测环境跑了一个小时左右突然出现频繁Full GC平均每5分钟一次每次GC耗时接近3秒。压测流量并没有明显增加但老年代占用率从30%一路飙到90%。第一时间的误判就是怀疑JVM堆参数配置不合理——因为压测环境经常有人动配置团队同学一度认为-XX:MaxHeapSize在某个配置文件里被覆盖成了小值。排查了一圈配置没毛病于是开始翻代码。后来通过jmap确认老年代涨到90%后先用jmap -dump:live抓了一份dump文件。这份dump对初期的方向判断帮助很大但并没有立刻定位到具体代码原因是没有足够的对比基准。这也是我为什么后来强调“连续dump对比”的原因。4.2 破案关键一份对比dump揪出的静态Map第二份dump是在第一份基础上隔了4个小时抓的期间服务继续跑着压测流量。MAT打开后直接做Histogram对比最醒目的变化是一个叫SessionContext的类Retained Size从40MB涨到210MB增长率大约5倍。点开这个类的Dominator Tree顺着引用链往下看发现它被一个静态Map持有。这个Map在单机模式下没什么问题但压测环境里模拟了大量并发用户SessionContext对象创建速度非常快而静态Map只进不出所以内存被不断累积。代码里写的是“sessionContexts.put(userId, context)”这行代码本身没有任何同步逻辑也没有考虑过期清理。在真实业务场景下写入量不大时根本不会暴露问题但压测流量一上来累积效应立刻显现。修复方案也很简单改用带过期策略的Cache例如Caffeine或Guava Cache或者使用WeakReference包装value让GC能按需收集不再活跃的会话对象。修复后重新压测Full GC频率降到了半小时一次老年代占用稳定在50%上下。4.3 从案例中提取出通用定位公式把这个案例抽象一下我提炼出一条适用于大多数内存泄露排查的通用定位公式先看趋势确认泄露再抓双份dump做对比然后顺着Retained Size增量最大的类找Dominator Tree根节点沿着引用链路定位到具体持有者最后回到代码看这个持有者是否缺少清理或过期逻辑。这条公式几乎覆盖了我在各类业务场景中遇到的80%的内存泄露问题。剩下20%是那些更隐蔽的情况比如Metaspace泄露、Direct Buffer泄露、JNI本地引用等但这些情况在核心排查思路上也没有跳出框架只是要在非堆区域多留个心眼。4.4 修复验证阶段的三个必须动作修复写完后有同学改了几行代码就宣布搞定这种做法我不太认可。内存泄露修复的验证必须做完整否则线上还是会出问题。第一个动作是验证基线水位。修复后让服务冷启动把堆内存最小值记录下来连续运行24小时再对比Full GC后的回落点。如果新基线比修复前明显下降好说明问题方向是对的。第二个动作是验证极端场景。在压测环境把流量打到峰值的1.5倍观察老年代是否会反弹到高水位。如果反弹速度显著变慢说明累积效应得到控制。第三个动作是验证泄漏点已断根。再次抓dump用MAT检查原来那个静态Map的大小确认它不再随运行时间线性增长。这个动作最直接也最有说服力。5. 那些教材里不讲、但排查中真实管用的感受5.1 直觉的培养从堆的“呼吸感”看出问题排查久了我对堆内存的“呼吸感”非常敏感。正常运行的内存曲线应该是有规律的高低起伏——业务高峰时冲高低谷时回落整体像呼吸一样有节奏。但真正泄露的曲线是没有呼吸感的它只有吐气没吸气一路向下走毫无回旋余地。这种直觉不是天赋而是长期看监控练出来的。我建议做服务端开发的同学养成每周至少打开一次监控面板看看内存曲线图的习惯。即使没有故障也要观察高峰、低谷、GC的节奏。当正常形态深深印在脑子里异常出现时你才能一眼察觉。另一个直觉是看GC日志里某个细节的变化趋势。比如老年代回收占比、晋升耗时、Young GC后存活对象数量这些长期记录下来的数据会在你脑海里形成一种“今天的数字不对劲”的条件反射。5.2 借助线程转储发现内存背后的并发矛盾内存泄露往往不是孤立的。很多时候堆内存的异常增长伴随着线程状态的诡异变化。我在一次排查中就注意到线程转储里大量线程卡在Object.wait()上而这些线程对应的业务对象每个都持有几十MB的数据。线程不干活但对象不释放这就是并发和内存搅在一起了。所以我现在习惯把jstack和jmap组合使用。先jstack查看线程状态有没有大面积BLOCKED或WAITING再jmap看堆里有没有对应的大量对象。如果两个信号都对上了那基本是一套组合拳——锁未释放导致线程挂起线程持有的对象也无法回收。这种场景下只调堆或只调并发都是不够的要两边一起治。5.3 无法复现的线上内存问题怎么用最小化手段试探没有压测环境或者问题只出现在特定机器上怎么办这种情况我也遇到过很多次。最朴素的思路就是在生产环境搞“最小侵入式验证”。具体做法是选定一台低流量实例用jmap做连续快照对比。抓第一份后用jcmd触发一次Full GC再紧接着抓第二份对比两份dump。如果Full GC后内存仍然大比例被占用且这些对象的类型集中在业务代码类而非框架内部类那基本可以锁定业务层存在强引用。这种方法不需要停服不需要改代码只需要在低峰期操作几分钟。而且由于是“对比”而非“单点分析”误判概率大幅下降。需要注意的是jmap命令本身会占用一定CPU和内存资源切勿在高负载实例上频繁操作建议最多一天一次。5.4 与运维、DBA配合排查时容易忽略的协作细节内存泄露排查在大型系统里很少是纯JVM层面的事。我经历过一个案例JVM堆内存看起来一切正常但整机内存却在缓慢增长。后来才发现是数据库连接池的配置出现问题返回给业务代码的ResultSet没有被正确释放大量底层对象通过连接池线程持有到堆外。从那以后我就特别重视与运维和DBA的配合。与运维沟通时要明确要操作系统层面的内存趋势数据而不仅是Docker容器或K8s Pod的内存指标。与DBA沟通时要确认连接池的闲置超时配置、慢查询日志和ResultSet的释放规范。这些跨角色信息往往能把问题从“JVM内部”带回到“系统整体”定位效率提升一个量级。和运维对齐监控口径也很重要。很多团队用容器内存来判定内存泄露但容器内看到的内存包含页缓存、堆外内存等不能直接等同于堆内存增长。两边口径不一致会出现一个人说泄露另一个人说没事的尴尬局面。5.5 一个“不起眼的配置”让排查方向跑偏的真实教训最后分享一个教训。有一次线上服务频繁Full GC堆持续增长我按照标准流程走了两轮排查最后发现罪魁祸首竟然是JVM参数-XX:MaxDirectMemorySize没有显式设置。不设置时默认值等于-Xmx的值也就是说堆外直接内存的可用上限被放得很大。业务代码里用了Netty框架Netty的Direct Buffer池会按需分配堆外内存但池回收依赖线程的退出。在长连接场景下线程长期存活Direct Buffer池里的内存只增不减最终把堆外内存打满。这个问题的定位绕了一大圈因为它不在堆内常规MAT分析根本不管用。这个案例让我养成了一个新习惯拿到JVM配置的第一时间就检查-XX:MaxDirectMemorySize和-XX:MaxMetaspaceSize这两个参数是否被显式设置。别看它们不起眼一旦出问题排查成本是成倍的。6. 排查工具箱的选型经验和扩展思路6.1 工欲善其事不同场景该选谁排查工具不一定要用最复杂的关键是匹配场景。我把自己用过的工具按场景分了个类供参考快速看GC趋势与内存分区首选jstat命令行轻量秒级输出。静态分析dump首选MATHistogram和Dominator Tree两个视图足够强大。线上动态观测首选Arthasattach机制不需要重启服务支持实时查看对象状态。全链路监控如果公司有APM平台优先用平台自带的内存火焰图或GC看板因为它能拉长时间线看趋势。底层系统分析必要时可以上perf或pmap但这类工具对能力要求较高平时用不上不建议从这类工具入门。很多人问我VisualVM能不能用我的答案是可以用单机开发调试没问题但在生产环境attach图形界面比较别扭不如Arthas灵活。VisualVM更适合本地复现时用。6.2 建立薄弱环节的日常巡检机制内存泄露不是一天形成的问题在前兆阶段就已经有信号。所以我推荐建立日常巡检机制把“事后排查”变成“事前发现”。最简单的方式是在监控面板上配置“Full GC后堆内存最低值”的环比告警。如果连续多个周期该值上涨超过5%就触发告警。这样即使不人工盯监控也能第一时间感知到异常趋势。更进一步的巡检机制是每周自动抓取一次核心服务的dump文件用脚本跑一遍Mat的Headless模式做初步分析输出增量最大的前20个类。这个机制能筛掉大量“看似正常但积累很多”的隐性泄露等真出问题的时候手上已经有了丰富的历史数据。6.3 五分钟速览适合新手抄作业的排查清单如果你刚接触这块担心自己记不住完整流程我整理了一份可以直接照着走的快速清单确认堆内存基线是否随时间抬升看Full GC后的最小使用量。连续抓两份间隔数小时的dump文件。用MAT对比Histogram锁定Retained Size增量最大的类。在Dominator Tree里找引用根路径定位到具体持有者。回代码检查持有者是否缺少清理机制或过期策略。修复后跑24小时对比GC基线是否回落并稳定。顺手检查Metaspace和Direct Memory两个非堆区域。这套清单不是万能药但能覆盖大多数常见内存泄露场景。做多了之后你会更快识别出哪些类天生容易泄露也会对引用传递有更强的敏感度。6.4 扩展思路从单一JVM到容器与集群视角大部分排查工作都聚焦在单个JVM实例上但系统一旦容器化还得把视角扩到容器与集群层面。内存泄露有时候不是实例的问题而是某个Pod反复重启后负载转移导致邻近Pod内存被压出问题。排查这类问题时要联合看容器编排平台的事件记录和实例调度日志。单个实例的JVM堆可能看起来正常但多次重启后底层宿主机的内存碎片化会对新增实例造成隐性影响。扩展思路带来的收获是不要把所有排查动作都局限在JVM内部。从监控数据入手先在系统层面定位到“究竟是哪一台机器的内存异常”再下钻到JVM和代码层效率会高很多。我个人在实际操作中的体会是内存泄露排查最难的往往不是技术而是耐心和方法论。只要对“趋势”保持敏感把工具用对顺序再难的问题也能一步步逼近真相。