资讯动态

Java内存增长排查实战:从OOM到MAT定位ResultSetImpl

发布时间:2026/9/7 22:33:37 来源:尧图企业网站定制
做Java后台这几年最怕的不是功能bug而是那种说不清道不明的内存增长。进程没挂GC曲线却一路走高CPU时不时飙一下最后在凌晨某个时刻OOM告警准时躺进群里。这种问题不像空指针那么好复现它藏在线程、连接、集合和各种底层驱动对象里不把堆内存翻个底朝天根本找不到它。最近我就完整走了一遍java内存增长排查的流程从OOM告警到dump文件再到MAT里层层下钻最后定位到com.mysql.cj.jdbc.result.ResultSetImpl——大部分Java开发天天在用、却很少在堆里真正审视过它的JDBC结果集实现类。这篇文章把这套排查思路、MAT实战关键步骤、以及ResultSetImpl吃内存的底层机制完整整理出来给正在跟内存问题搏斗的同仁一条可以直接照抄的排查路径。1. 线上告警的排山倒海内存问题是怎样一步步失控的1.1 那天的现象先说场景。某天下午监控面板上的堆内存曲线开始不对劲从早高峰之后老年代使用率一直没有回落到基线反而缓慢往上爬。到了下午四点老年代使用率冲上80%Full GC次数从过去几小时一次变成几分钟一次每次Full GC的停顿时间也从几百毫秒拉长到两三秒。紧接着夜间零点的批量任务一跑OOM告警准时弹出java.lang.OutOfMemoryError: Java heap space。这时候第一反应是去看GC日志。GC日志里最有价值的信息是内存分配速率和晋升速率。我翻出当天的GC日志发现年轻代回收后存活对象数量逐次增加一批对象在经历几次Minor GC之后被晋升到老年代而且晋升量是一次比一次大。这说明某个位置在持续产生大对象或者长生命周期对象并且数量级远超平时。这里要先做区分内存问题分两种。一种是突刺型比如一次性把大Excel读进内存、批量导出几百万行数据堆内存瞬间冲高再回落另一种是斜坡型像我们这次GC指标和堆占用缓慢爬升老年代一点点被填满直到某个业务动作成为压死骆驼的最后一根稻草。斜坡型问题最麻烦的地方在于触发条件往往不是某个瞬间的大动作而是持续产生垃圾 一部分垃圾被意外持有 晋升率超过回收率三个因素叠加出来的慢性病。1.2 为什么斜坡型内存增长最难查说实话这类问题比Full GC频繁更磨人原因在于几个特性叠加。第一触发条件隐蔽。你本地起个服务、跑个接口数据量就几百行根本复现不出来。线上数据量、并发数、连接池大小、任务调度时间窗口任何一个维度变化都可能让问题从偶发变成持续。第二责任类不一定是业务类本身。很多业务同学看到OOM第一反应是是不是我那个List没清但dump拉出来一看堆里几十个List都不是主体真正的大头可能是某个驱动对象、某个线程池的队列、某段缓存结构。这就导致排查方向很容易偏。第三难以用压测直接复现。压测场景通常会构造均匀分布的数据但线上数据是倾斜的比如某张表的某几行字段特别大某条SQL在特定日期范围扫出来的行数特别多。这些倾斜数据才是压测填不出来的坑。我自己有个习惯凡是堆内存持续增长的服务不管现在有没有OOM先按OOM预案走一遍流程。因为内存不会自己好让它多涨一天后面的排查成本只会更高。更好的是这个问题最终会在OOM之前把线索留在堆里而我们要做的就是拿到这个堆。提示监控上建议至少盯四个指标——堆内存使用率、老年代使用率、Full GC次数、Full GC平均停顿时间。任何一个指标出现连续上升且不回落的趋势就值得开始做dump分析而不是等告警。2. dump的获取与排查工具的选择为什么是MAT2.1 获取堆转储文件的正确姿势排查内存问题第一步永远是拿到案发时的堆快照。最常用的命令是jmap和jcmd以Linux下常见的方式为例# 方式一jmap dump这种方式默认只导出live对象不是加live才会触发Full GC再导出 jmap -dump:live,formatb,file/tmp/heap_live.hprof pid # 方式二jcmd dumpJDK 8推荐 jcmd pid GC.heap_dump /tmp/heap_live.hprofjmap -dump:live会先触发一次Full GC把可回收对象清掉再导出。这个行为在线上必须谨慎因为Full GC本身会带来停顿高并发服务在峰值期手动触发一次Full GC很可能直接把接口耗时打崩。如果不加live参数dump文件会大不少但它保留了更接近案发现场的内存状态对象布局更完整在分析大对象驻留问题时反而更有参考价值。另一个更重要的开关是提前给JVM加上OOM自动dump参数java -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/data/logs/heapdump/ \ -Xms4g -Xmx4g \ -jar yourapp.jarHeapDumpOnOutOfMemoryError会在OOM发生的瞬间把当时的堆完整落盘这是最能还原现场的证据。手动触发dump担心Full GC影响线上而自动dump是OOM那一刻JVM自己拍的快照不需要你干预唯一要做的是提前把HeapDumpPath目录准备好并且保证有足够的磁盘空间。一个4GB堆的服务dump文件可能在4到8GB之间目录不够大时会dump失败。2.2 大堆文件分析工具怎么选拿到几个GB的hprof文件之后就可以开始分析。分析工具我这些年基本把主流都试过优缺点比较明确。工具优点缺点适合场景jhatJDK自带零安装大文件加载极慢Web界面交互差小堆临时看一眼VisualVM图形化能看堆和线程打开几GB文件卡顿明显本地开发调试GCeasy在线分析报告友好偏GC日志分析对象引用追溯弱想看GC频率和停顿MAT对象级分析最强支持直方图、支配树、Leak Suspects、OQL学习曲线稍陡需要合理配置内存定位大对象、引用链、泄漏点结论很直接要做对象级定位、引用链追踪、怀疑某个底层对象持有大量堆内存MAT是当前最顺手的工具。它现在有独立版不需要装Eclipse IDE下载解压就能用。版本选择上用当前稳定版就行老版本对JDK 11的dump解析偶尔会出现兼容问题。2.3 用MAT打开大dump前的环境准备直接双击打开5GB的hprof文件大概率会卡到怀疑人生因为MAT解析本身需要大量内存。MAT安装目录下有一个MemoryAnalyzer.ini文件需要手动调整JVM参数-Xmx8192m -Xms1024m -XX:UseParallelGC-Xmx我建议设置到dump文件大小的1.5到2倍。比如dump有5GBMAT给8到10GB比较稳。如果分析机器内存只有16GB可以把系统里的其他服务停一停再分析。另外可以勾选MAT启动界面上的Keep unreachable objects排查内存泄漏通常不需要看不可达对象不勾选反而更清晰。3. 用MAT啃下dump文件从嫌疑报告到GC Roots的完整链路3.1 先看自动报告再动手验证MAT打开hprof之后第一件事不是直接进Histogram而是先点Leak Suspects Report。MAT会基于支配树自动计算哪些对象持有最大的Retained Heap生成一份嫌疑报告。我们当时那份报告里Top Suspects直接指出一个很大的Dominator Path核心类就是com.mysql.cj.jdbc.result.ResultSetImpl。报告会自动给出引用链的摘要展示它被谁持有、从哪个线程到达一路追到业务代码的调用栈。这个图景初步指向了一次批量查询导出接口。但这里要强调一点Leak Suspects是MAT自动计算的嫌疑不是最终结论。它的算法原理基于Retained Heap最大的支配树节点有时候报告会把一些本来正常的缓存误报成泄漏点。所以自动报告只能用来缩小范围真正定案必须回到Histogram、Dominator Tree和代码本身去验证。3.2 Histogram排序看实例数和Retained Heap点开Histogram按Retained Heap从大到小排序我们看到了非常典型的分布。com.mysql.cj.jdbc.result.ResultSetImpl的Retained Heap达到了2.3GB实例数不多只有17个。往下看com.mysql.cj.result.ByteArrayRow的Retained Heap约1.8GB实例数几十万个。再往下就是海量的byte[]和char[]。这里要搞清楚两个概念。Shallow Heap是对象自身占用的内存比如一个对象头引用字段的大小Retained Heap是当你把这个对象回收掉能连带释放的所有对象的总大小。排查内存增长Retained Heap才是关键指标。一个ResultSetImpl的Shallow可能只有几十字节但Retained高达一百多MB说明它背后挂着一整棵庞大的对象树——这棵树就是JDBC驱动从数据库拉回来的结果集数据。3.3 沿支配树和GC Roots还原调用现场在Histogram里选中Retained Heap最大的ResultSetImpl实例右键选择Merge Shortest Paths to GC Roots或者Path to GC Roots选择with all references就能看到一条从GC Root到该对象的完整强引用链。我们当时看到的链路大致是这样的http-nio-8080-exec-33 (线程) └── com.example.service.OrderExportService └── java.util.ArrayList └── com.mysql.cj.jdbc.result.ResultSetImpl这说明那个导出服务的某个方法里查询出来的ResultSet被业务层的一个ArrayList或者Map强引用着GC永远无法回收。结合链路再展开Dominator Tree能看得更细ResultSetImpl底下挂着com.mysql.cj.protocol.a.result.ResultsetRowsStatic再往下是ByteArrayRow[]数组每个ByteArrayRow内部还有一个byte[][]。这些字节数组就是数据库返回的每一行、每一列的原始数据。看到这里根因基本已经明确了这个ResultSet是一次性把数据库返回的所有行都缓存在了客户端堆里并且因为引用链的存在一直没被释放。注意看Path to GC Roots时一定要选exclude weak/soft references把弱引用、软引用排除只看强引用链。不排除的话分析结果会被那些基于WeakReference的缓存结构干扰出现好多条无关路径。3.4 用OQL验证实例分布为了确认不是个例我还在MAT的OQL查了一下ResultSetImpl在全堆里的分布情况。OQL语句很简单SELECT * FROM com.mysql.cj.jdbc.result.ResultSetImpl r可以看到每个实例的Retained Heap大小和持有者对象。如果多个实例的Retained都很大说明是多个业务请求同时触发了这种大查询而不是单次请求的偶发问题。我们再结合业务高峰期的时间线对了一下发现OOM发生前的几分钟导出接口的并发请求数和ResultSetImpl的Retained大小完全对得上这就不是偶然而是确定的业务路径问题。4. ResultSetImpl为什么会吃掉数百MB内存JDBC驱动结果集的内存机制4.1 MySQL Connector/J的默认一把梭加载行为要根治问题得先把JDBC驱动的结果集加载机制说透。MySQL Connector/J在默认配置下executeQuery()执行完的那一刻并不是一行一行往客户端送数据而是把查询结果的所有行一次性读入客户端内存存进ResultSetImpl内部。对应的堆内存对象结构大概是这样ResultSetImpl结果集的门面对象持有RowDataResultsetRowsStaticRowData接口的实现类持有所有查询行ByteArrayRow[]数组里每个元素对应一行ByteArrayRow内部是byte[][]存的是这一行每个字段的原始字节所以查询返回的行数越多、列越宽、字段内容越大堆里的byte[]就越庞大。而这只算了原始字节这一份。当你在while (rs.next())里调用rs.getString()的时候驱动还会再做一次字符解码生成新的String对象。也就是说一个字段在内存里至少有两份拷贝一份是驱动保存的原始字节一份是你解码出来的字符串对象。如果业务代码还在循环里把这些String转成业务对象、塞进List那就是第三份拷贝。三层叠下来内存占用会被放大好几倍。4.2 算一笔账什么样的查询能把堆直接打穿我们来算一笔账假设有个导出接口查了一张100万行的大表平均每行数据从数据库返回的原始字节是2000B20个列每列平均100B那么ByteArrayRow里的原始字节100万 × 2000B 2GBrs.getString()解码出的String对象假设每行解码后占2500B又是2.5GB业务代码里塞进List的业务对象每行再占500B又是500MB这还没算JDBC底层网络读取时的额外缓冲。也就是说一个百万行级别的查询走默认方式在堆里吃3到5GB内存是很容易的事情。4GB堆的服务被这样的查询打两三次OOM就是必然的。这也是为什么MAT里看到的ResultSetImpl会成为Leak Suspects第一名。它不是什么罕见的bug而是驱动默认行为的必然结果——只要业务SQL查了海量数据又没有用流式或者分页方式读取结果集在堆里的体积就会失控。4.3 哪些业务写法最容易养出大ResultSet从实际经验来看容易养出巨型ResultSet的代码形态就那么几种我每次排查基本都能对号入座。导出、报表类SQL不带limit条件又写得宽直接扫全表。循环里反复执行executeQuery()ResultSet没有及时关闭旧结果集被外层List或Map强引用越积越多。不关心字段内容一律SELECT *把大量TEXT/BLOB大字段一并拉回来。先让ResultSet把所有数据读进List再在内存里做过滤、排序、聚合等于把数据库的活搬到JVM里干。批量任务线程池里每个线程都持有自己的ResultSet任务处理慢了结果集就被线程一直占着。我们这次的线上问题就是导出接口大范围查询 先落List再写文件 SQL没控制范围这三个因素叠在一起。ResultSet把数据拉回客户端业务又把ResultSet的数据复制到List然后写CSV的时候又把List遍历一遍。中间任何一个环节都能省内存但代码写的时候全没省。4.4 fetchSize、游标与流式读取的正确用法有人会说那我把fetchSize调小一点不就行了这里有个很经典的坑MySQL驱动的fetchSize默认是0单纯调用statement.setFetchSize(1000)在默认连接参数下是不生效的数据仍然是一次性全部拉回。要让fetchSize真正起作用连接串上必须加上useCursorFetchtrue同时statement.setFetchSize(500)。这样驱动才会走服务端游标的逻辑每次只从MySQL服务端取一批行到客户端而不是把所有行都塞进内存。老版本Connector/J里还支持一种setFetchSize(Integer.MIN_VALUE)的流式读取模式让驱动逐行读取。但那种模式有个限制同一时间内一个连接只允许一个Statement处于流式读取状态否则会报错。日常新项目我建议直接用useCursorFetchtrue 合理的fetchSize语义更清晰连接池管理也更方便。不过游标模式也有代价游标读取期间连接必须保持打开直到结果集全部读完否则游标会失效。所以用游标读取时要注意两点一是不能让连接池在结果集没读完时把连接回收走二是读取完或者发生异常时必须及时关闭ResultSet和Statement把连接还给连接池。5. 修复与验证代码整改不是加个limit那么简单5.1 根据根因确定修复方案拿到根因之后修复方案不是简单加一个limit就完事得针对业务形态做调整。我们当时的导出接口改动比较大但核心思路就三条缩小单次查询的数据量、避免中间大对象堆积、显式管理资源生命周期。第一SQL加上范围条件采用主键分段的方式分批查询。比如按主键ID范围切段每批只查1万行。String sql SELECT id, order_no, amount FROM t_big_order WHERE id ? AND id ?; try (PreparedStatement ps conn.prepareStatement(sql)) { long lastId 0L; int batchSize 10000; while (true) { ps.setLong(1, lastId); ps.setLong(2, lastId batchSize); try (ResultSet rs ps.executeQuery()) { int count 0; while (rs.next()) { writeOneRowToFile(rs); // 直接写文件不经过list堆积 lastId rs.getLong(id); count; } if (count 0) { break; } } } }这段代码有几个关键点用try-with-resources管理Statement和ResultSet保证资源一定关闭边读边写文件不在内存里攒一个List再写按主键范围分批每批内存占用可控。第二查询字段从SELECT *改成明确的字段列表不把大字段拉出来。当时发现t_big_order表里有一个备注字段是TEXT类型平时业务根本不用但SELECT *把每行的TEXT内容全部读出来了。这种字段对内存的消耗非常可观直接去掉就能省一大块。第三给导出接口加上最大行数保护。无论是通过参数限制还是条件限制超过阈值直接拒绝或者强制降级为分批导出不能让接口无限量地查下去。5.2 上线后的验证步骤改完代码不是测试一把就完事验证要分三步走。第一步看内存曲线。上线之后持续观察老年代使用率和Full GC频率确认曲线不再爬升回落到正常基线。我们当时的Full GC从几分钟一次降回几小时一次老年代使用率稳定在50%以下基本可以确认修复有效。第二步再打一份dump对比。在同样压测流量下用jmap导出一份新dump放进MAT里看直方图。修复前ResultSetImpl的Retained Heap是2.3GB修复后同场景下只剩几十MB实例数从17个下降到个位数这个对比数据非常直观。第三步做一次针对性压测。压测的时候除了看吞吐和RT一定要盯内存曲线是否在压测结束后回落。能回落的说明对象可以被回收不回落的说明还有东西被一直引用着那就要继续查了。5.3 几个日常预防手段经历过这次之后我把预防手段也拉了起来。第一条是代码评审阶段盯着几类高风险写法大集合、循环查询、不关闭资源、SELECT *、先查后滤。这些只要在评审的时候被点出来后面能少很多线上事故。第二条是压测不能只看性能指标还要看内存曲线是否有升有降。有升有降说明对象能被GC回收只升不降就要警惕。第三条是定期打dump做健康检查不一定非得等OOM。一个月拉一次堆快照用MAT看一眼最大的几个对象是谁心里有数。提示线上服务的JVM参数里-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath一定要配很多团队等到OOM发生后才追着运维要dump结果发现参数没加最关键的现场直接丢了。最后再分享一个心态上的体会。遇到内存增长问题最忌讳的就是猜。今天觉得是缓存没清明天觉得是List太大方向错了时间全搭进去。正确路线是先抓现场dump出来让对象自己说话顺着引用链回到代码让代码告诉你它做了什么。绝大多数的根因不是最初猜的那个而是一个平时根本不会留意的驱动底层对象——就像这次站在大名单前面的是com.mysql.cj.jdbc.result.ResultSetImpl。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价