
前段时间线上一个Java服务频繁OOM每次重启后能撑两三天然后又挂。看了下监控曲线内存像台阶一样往上爬典型的泄漏节奏。原本以为是什么高并发下的复杂bug结果定位到最后发现是个非常简单的小坑但排查过程确实绕了不少路。这篇把整个排查和分析过程完整记录下来包括用了哪些命令、怎么读GC日志、怎么用MAT看dump文件以及最后那个“小坑”到底是什么。如果你是做Java后端或者维护线上服务的对jstat、jmap、MAT这些工具不陌生但没系统排过内存问题这篇可以作为一份实战参考。即使是新手按照里边的思路和命令走一遍也能建立一套自己的排查框架。1. 现象描述与初步判断1.1 服务表现隔几天就OOM一次这个服务是一个普通的Spring Boot应用部署在两台4C8G的云主机上JVM堆设成4G用的G1垃圾收集器。平时接口响应都正常也没有特别大的流量波动但每到凌晨业务低峰期偶尔会有一个实例突然“消失”K8s里显示OOMKilled容器被重启。刚开始以为是夜间批处理任务造成的查了定时任务日志发现确实有几个凌晨跑的数据汇总Job但任务执行时间都不长内存峰值也就几百MB不至于把4G堆打满。后来又怀疑是连接池泄漏检查了数据库连接、Redis连接、HTTP客户端连接池全都没问题。直到有一次在OOM之前抓到了当时的现场日志看到这样的内容java.lang.OutOfMemoryError: Java heap space Exception in thread http-nio-8080-exec-233 java.lang.OutOfMemoryError: Java heap space这一下就确定了是堆内存的问题而且不是栈溢出也不是Metaspace或者直接内存溢出。接下来就好办了直接围绕堆内存排查。1.2 这里先搞清楚一个基础概念内存泄漏和内存溢出的区别很多新手容易把这两个词混在一起。内存泄漏Memory Leak是对象已经不再使用但GC无法回收导致内存被白占内存溢出OutOfMemoryError是堆内存真的不够用了连新对象都分配不出来。泄漏是原因之一溢出是结果。服务反复OOM十有八九就是有对象一直堆积把堆熬干了。用生活里的例子说漏水的水池一边进水一边漏水如果漏水的速度小于进水的速度水池迟早会满。内存泄漏就是这个“永不关闭的水龙头”对象只创建不回收堆空间被一点点蚕食。判断是不是泄漏不能光看OOM日志得看内存趋势。下面这个台阶状的内存增长曲线就是泄漏最典型的特征每次GC之后内存能降一点但降不到初始水位整体重心不断抬高直到触发Full GC也收不回来最终OOM。2. 排查过程从GC日志到堆dump2.1 第一板斧jstat看GC趋势登录到出问题的机器上在服务刚重启后不久先用jstat观察GC情况jstat -gcutil pid 1000 30输出里重点看这几列YGC/YGCT年轻代GC次数和耗时FGC/FGCTFull GC次数和耗时S0/S1/E幸存区和Eden区占用百分比O老年代占用百分比MMetaspace占用百分比我当时拿到的情况是Eden区和Survivor区都正常每次Minor GC之后都能清干净但是老年代O那一路从20%慢慢爬到50%、70%Full GC之后也只能回落几个百分点然后继续涨。这说明老年代里有大量对象没法被回收而且持续在产生。顺便说一个容易踩的坑jstat输出里FGC如果一直增加不要直接断定就是Full GC太频繁。要看FGC发生的时间间隔和每次回收后的老年代占用变化。有的服务FGC数量不多但每次回收效果极差这才更符合泄漏的特征。2.2 第二板斧jmap确认对象分布GC趋势有了初步判断后先用jmap -heap看堆的整体配置和当前分区使用情况jmap -heap pid这一步主要确认堆参数有没有生效比如G1的MaxHeapSize、InitialHeapSize还有当前各区域的实际占用。紧接着用jmap -histo看一眼对象实例数的分布jmap -histo:live pid | head -50这能粗略看出哪一种对象特别多。不过这里要提醒一下-histo:live会先触发一次Full GC线上环境如果堆很大、GC停顿时间长要小心一点最好在低峰期操作。我当时看到的结果是byte[]和char[]数量很大但这在Java服务里太常见了没法直接定位到问题只能说明内存里确实存了大量数据。2.3 第三板斧jmap dump堆快照histo不够用就得抓堆快照。这个操作也要小心dump过程中服务会暂停因为要冻结堆状态才能生成一致性快照。线上大堆环境先评估一下停顿时间能不能接受尽量在流量低峰执行。jmap -dump:live,formatb,file/data/dump/heap_20240115.hprof pid如果有条件强烈建议在启动参数里加上自动dump这样OOM发生时JVM会自动把当时的堆快照落盘省得事后抓不到现场-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/dump/这条参数看起来平平无奇关键时候能救命。没有现场dump全靠事后复现排查难度会翻好几倍。dump文件拿到手之后我先把文件大小看了一眼4G的堆配置dump文件竟然有3.8G说明堆里边确实塞满了东西。3. 用MAT分析dump文件定位可疑对象3.1 MAT的基本使用思路dump文件拿到了接下来用MATMemory Analyzer Tool分析。MAT是Eclipse家的开源工具专门做堆dump分析官网直接下载解压就能用Mac和Windows都有对应版本。文件太大打不开的话在MAT的MemoryAnalyzer.ini里把-Xmx调大比如-Xmx4g -Xmx6g我这边3.8G的dump文件本地16G内存的机器给MAT分配6G堆就很稳。打开dump文件之后MAT会自动计算并生成一个报告但我们真正要看的不是那个自动报告而是几个关键视图Dominator Tree支配树看哪些对象占用的堆最大Leak Suspects泄漏嫌疑MAT自动分析出的嫌疑对象Histogram类直方图按类维度看实例数和占用大小3.2 支配树上的大鱼一个3GB的HashMap当时直接打开Dominator Tree一眼就看到一个大块头一个java.util.HashMap实例Retained Size占了3GB多。这个HashMap挂在某个静态字段下面包名一看就是业务代码里的一个工具类。点进这个HashMap的内部结构看到它里面有大量Entry每个Entry的key是一个业务ID字符串value是一个自定义的对象。整个Map里存了上百万个条目这就是老年代持续增长的元凶。再看引用链这个Map是static修饰的属于类级别的全局容器。只要类加载器不被回收这个Map和它里面的所有对象就永远不会被GC盯上即使业务上早就不需要那些数据了它们依然稳稳地挂在堆里。3.3 为什么不是普通的“忘记移除”追溯value来源找到Map本身还不够得搞明白这些对象是怎么进去的、为什么没有被移除。顺着代码一追发现这个Map设计本意是做一个“短暂缓存”接口里每来一个请求就往里放一条数据但“短暂”两个字完全没有实现——既没有设置过期时间也没有在接口结束之后移除更没有做容量上限控制。时间一长每天几十万请求每个请求都往Map里塞一条几个月下来堆里就有上百万条垃圾数据。这其实就是最原始形态的内存泄漏程序逻辑上忘记释放不再使用的引用。这里有一个MC比较经典的误区值得说一下有人觉得Java有GC所以“不用管释放”也有人觉得缓存不清理没关系反正有大不了重启。这两种想法在生产环境都要付出代价。GC只处理不可达对象static容器里的对象永远“可达”GC拿它们一点办法都没有。4. 根因定位与代码层面的“小坑”4.1 问题代码还原绕了一大圈最后定位到的代码逻辑大概是这样一个路子已脱敏改写过public class BizDataCache { private static final MapString, BizData CACHE new HashMap(); public BizData getOrCreate(String bizId) { BizData data CACHE.get(bizId); if (data null) { data buildFromRemote(bizId); CACHE.put(bizId, data); } return data; } }乍一眼看过去有缓存、有判断、有复用还挺合理。但致命的问题就在那个static的HashMap上key的集合理论上是有限的业务ID但实际线上数据里携带着随机追踪参数每个请求都是新值Map只增不减没有任何淘汰策略buildFromRemote返回的value对象内部还挂着一个大列表进一步放大内存开销这种问题为什么叫“小坑”呢因为代码逻辑一眼看过去“没毛病”不涉及高深的并发问题也不涉及框架使用错误就是一个数据结构的选择和生命周期管理问题。4.2 更隐蔽的同类坑ThreadLocal和线程池的组合排查过程中还有一个意外收获虽然这次不是主因但值得提醒一句另一个服务里出现过类似症状最后定位到是ThreadLocal配合线程池使用导致线程复用后ThreadLocalMap里的对象一直不释放。典型的错误写法是private static final ThreadLocalSimpleDateFormat DATE_FORMAT new ThreadLocal(); // 在业务方法里设置值但是 finally 中忘了 remove()线程池里的线程是长期存活的ThreadLocalMap是线程的一个属性如果不主动remove里边的对象会跟随线程活到天荒地老。这个问题在netty的异步线程、定时任务线程池里特别常见。排查方向正确的情况下MAT里的支配树一眼就能看到ThreadLocalMap占据大量内存顺着引用链往上追很快就能找到遗忘了remove()的那行代码。5. 修复方案与预防措施5.1 修复引入带过期机制的缓存组件定位到根因后修复反而很简单。不再手写静态HashMap换成支持过期和容量限制的本地缓存组件。我们用了Caffeine完全可以替代手写一段“简陋缓存”的写法public class BizDataCache { private static final CacheString, BizData CACHE Caffeine.newBuilder() .maximumSize(10_000) .expireAfterWrite(Duration.ofMinutes(30)) .build(); public BizData getOrCreate(String bizId) { return CACHE.get(bizId, id - buildFromRemote(id)); } }maximumSize限制总量expireAfterWrite保证数据只会存活30分钟两把锁把之前Map无限增长的漏洞完全堵死。就算业务上出现了极端情况缓存最坏也只是被淘汰数据不会拖垮堆内存。这里补充一下选择Caffeine而不是Guava Cache是因为Caffeine的淘汰策略和并发性能在多数场景下更优。如果项目里不想引入新依赖用ConcurrentHashMap加定时清理也可以但要注意“清理”必须真的实现不能只写注释“定期清理”而不落代码。5.2 JVM参数层面的预防修复线上代码之后还要把预防措施补上。这次踩坑有几个参数特别值得拆开讲。首先是启动参数里一定要有自动dump和OOM发生时执行额外动作的配置-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/dump/ -XX:OnOutOfMemoryErrorkill -9 %pHeapDumpOnOutOfMemoryError这一条前面说过了OnOutOfMemoryError的作用是OOM发生后立刻杀掉进程避免一个半死不活的服务继续接收流量方便K8s快速拉起新实例。然后是G1的参数调优我这里踩过一个小坑默认的G1在堆快满的时候才做Full GC如果线上服务存在慢速泄漏表现出来就是GC停顿越来越长但内存已经逼近极限。可以在启动参数里限制一下G1的停顿时间目标-XX:MaxGCPauseMillis200这个参数告诉G1尽量把GC停顿控制在200ms内它会动态调整年轻代大小和回收策略。虽然不能解决泄漏本身但能把GC表现调得更可控给排查争取时间窗口。5.3 监控告警不能只盯CPU这次还有一个体会监控这块只盯CPU是不够的。很多团队对CPU告警特别敏感CPU一高马上响应但内存缓慢增长这种“温水煮青蛙”型的问题不盯JVM内存曲线的话根本看不出来。建议至少监控三样东西老年代内存占用率持续上升不回落就是泄漏信号Full GC次数和耗时频率增加或者单次耗时明显变大要警惕堆内存整体使用率配合业务的流量曲线做对比确认是否异常把这些指标接到PrometheusGrafana里配置阈值告警慢速泄漏最长也不会拖到OOM才发现。6. 常见问题与排查技巧实录排查内存问题这种事多做几次就有手感了。把这次过程中遇到的典型问题和一些实用经验整理一下直接当速查表用。6.1 现象与怀疑方向速查现象可能原因优先排查方向内存台阶式增长GC后不回落对象泄漏堆里有容器只增不减jmap -histo、MAT支配树刚启动正常运行一周后慢慢变卡静态Map、缓存失效策略缺失业务代码中static字段、缓存组件配置Metaspace持续增长动态生成类过多、类加载器泄漏jstat -gcutil的M列、导出Metaspace统计内存正常但频繁Full GC堆太小或者大对象过多-Xmx配置、G1 region设置、对象大小分布OOM日志是Direct buffer memory堆外内存泄漏NIO、Netty的ByteBuf使用情况偶发OOMdump却很小栈溢出或者创建线程过多线程数、-Xss参数、系统文件描述符6.2 排查过程中的几个经验和坑第一抓dump千万别在高峰期操作。jmap -dump在4G堆上往往会有几秒到十几秒的停顿线上用户能明显感知到接口超时。我习惯的做法是早高峰之前或者问题实例已从负载均衡摘除之后再抓抓完第一时间看Dominator Tree。第二histo和dump要用live参数时要先想清楚。jmap -histo:live和jmap -dump:live都会触发Full GC目的是只保留存活对象。对于排查泄漏来说用live参数反而更方便因为死对象本来就不需要关心。但如果Full GC之后对象都被清掉了嫌疑对象也一起没了反而不好定位。所以我的建议是先抓一次不带live的dump再看情况决定要不要带live抓第二次。第三MAT的Leak Suspects报告别全信。它给出的结论是基于启发式规则的经常会把一些正常的大对象池误判成嫌疑。真正可靠的是Dominator Tree和引用链分析自己顺着路径追到业务代码才敢确认根因。第四OOM日志一定要保留。容器被K8s重启之后原来的stdout日志可能就丢了如果log文件没有持久化现场彻底没了。建议把JVM的日志输出到文件里单独保留别混在业务日志里滚动清理。-Xlog:gc:/data/logs/gc.log:time,level,tags:filecount10,filesize50m这条命令把GC日志写到固定文件里保留10个滚动文件每个最大50MB。排查的时候能精确看到每次GC前后内存的变化细节比监控曲线更精准。6.3 修复后的验证方法改完代码上了线怎么确认问题真的解决了不能光靠“观察几天没OOM”来验证。我在这次排查里的做法是修复上线后把旧的dump文件和新的dump文件各抓一次用MAT对比两个文件里嫌疑对象的总量和引用链确认大量对象已经不在堆里。同时盯紧GC日志里的老年代占用曲线修复前后对比看到“台阶”消失曲线平稳才算真正闭环。另外压测的时候可以故意造一些极端数据比如把缓存key的随机性加大、增加并发量让缓存命中率显著下降观察内存曲线是否依然平稳。只有在这种“恶意输入”下不出问题才能确认修复是扎实的。7. 这次排查给我留下的几个小教训整个过程走下来最大的体会是线上内存问题大多数时候都不是什么高深的东西反而是最朴素的“代码写完了忘了清理”的问题。排查工具再强大也得一步步从现象推结论从GC日志到堆快照再到业务代码急不得。还有一点静态字段和全局容器是最容易藏内存问题的两个位置。写代码的时候凡是涉及到static、Spring单例Bean里的成员变量、缓存组件都要多问一句这个东西的生命周期是谁在管有没有容量上限要不要过期每个问题都问一遍能挡下一大半的泄漏隐患。最后分享一个个人习惯新服务上线第一周我会每天固定看一眼老年代占用曲线和Full GC频率连续看一周。大部分慢速泄漏一周左右就会露出马脚。真等到OOM告警才去排查虽然也能定位但那种“半夜被叫起来处理线上事故”的滋味体会过一次就够了。