VisualVM内存排查实战:分页方案优化解决OOM
IDEA VisualVM 实战内存排查 分页方案全总结踩坑整理如果你写Java后端迟早会遇到这么一天接口响应越来越慢GC日志里Full GC的频率从几分钟一次变成几十秒一次再往后直接OutOfMemoryError服务原地去世。我之前在某项目里就赶上了这么一出一开始以为是大对象没释放查了半天线程池、缓存、静态集合最后发现根子竟然埋在一句分页SQL里——更准确地说是“分页方案的内存使用方式”出了大问题。排查过程用的是两件套IDEA自带的调试、JVM参数配合再加上VisualVM做堆转储和抽样分析。这套组合不花一分钱也没有复杂的Agent接入却能非常直观地把“到底谁占着内存不放”这个问题变成一张张清清楚楚的报表。这篇文章把我这次完整的排查思路、操作过程、以及最后的分页方案对比都整理出来。里面包括VisualVM连接本地/远程JVM的配置细节堆转储之后怎么看Class和实例怎么顺着GC根引用找到真正的持有者以及我用键集分页替代传统OFFSET分页之后内存和响应时间的实测变化。内容适合正在处理内存溢出、频繁Full GC、接口卡顿的Java开发者也适合那些听说过VisualVM但是一直没真正用来排查过问题的朋友。我会尽量把每一步都写清楚碰到容易踩的坑也会专门标注让它可以直接照着操作。1. 故障现场先搞清楚“内存不够”到底是谁的不够先说现象。某次线上监控报警一个平时很稳定的报表查询接口P99延迟从800ms飙到接近6秒同时老年代内存曲线持续走高。重启之后能缓解一两个小时然后又慢慢涨上去。再过一阵子直接出现类似这样的报错java.lang.OutOfMemoryError: Java heap space我当时的直觉是一定有哪个集合在无界增长。比如往某个static Map里塞数据没清理或者线程池里的任务队列积压或者用了什么缓存框架没设过期时间。于是先看代码翻了一遍缓存、异步任务、批处理逻辑眼都快花了也没发现明显问题。最后决定不用猜的直接上工具看。这里要先纠正一个常见误区。很多人一看到OOM就急着调大堆内存-Xmx从2G改成4G改成4G不行就8G。这种做法只能延迟爆炸不能解决爆炸。真正要做的是弄清楚两点第一什么样的对象在占空间第二这个对象的生命周期为什么这么长。VisualVM要解决的就是这两个问题。VisualVM是什么简单说它是JDK自带的一个监控和排障工具安装目录的bin/jvisualvm.exe老版本或bin/jvisualvm新版本可以直接启动。它不仅能看堆内存使用曲线、GC活动、CPU占用还能做堆转储Heap Dump把某一时刻JVM堆里所有对象的快照导出来。配合IDEA里的JVM参数设置可以在OOM发生时自动导出堆快照之后再用VisualVM慢慢分析。这套流程是我个人觉得最适合日常开发同学的排障路径不依赖Arthas、不依赖MAT装好JDK就有一大半工具可用。下面我会按这次排查的实际顺序来写先在IDEA里把JVM参数配好让服务带监控参数启动再用VisualVM连上JVM观察曲线发现问题集中在某类对象后导出堆转储做深挖最后根据根因重构了分页方案并实测对比了内存表现。2. 用VisualVM把内存问题“看”清楚2.1 在IDEA里配置JVM参数并做好Heap Dump自动导出VisualVM要能看到JVM的内存情况其实不需要额外配置只要Java进程跑着它就能通过JMX或者Attach机制连接。但为了排查OOM建议给服务加上几个关键的JVM参数。我平时会在IDEA的Run Configuration里找到VM options填入下面这组参数-Xms1g -Xmx1g -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/path/to/logs/heapdump.hprof解释一下-Xms1g和-Xmx1g把初始堆和最大堆设为一样避免运行时堆扩容带来额外开销。排查内存问题时把堆设成偏小反而更容易暴露问题1G对于模拟线上场景比较合适。-XX:HeapDumpOnOutOfMemoryError是关键。它让JVM在抛出OOM之前自动把堆快照写到磁盘。没有这个参数OOM一发生进程挂掉现场就没了。-XX:HeapDumpPath指定快照保存路径建议放到一个独立目录因为.hprof文件可能很大几个G都正常。IDEA里的配置位置很简单Run-Edit Configurations- 选择你的Application/Spring Boot配置 -VM options粘贴上述内容。注意HeapDumpPath目录要提前创建好否则导出会失败。另外如果项目用了Spring Boot的DevTools热重启之类的机制不会影响VM参数但要注意同一端口别起多实例否则两个进程都往同一个路径写快照会互相覆盖。启动后用VisualVM连接当前进程的方式有两种。如果VisualVM和IDEA在同一台机器直接双击左侧Local列表里对应的PID即可。如果是远程服务器上的Java进程需要额外配置JMX这个放到后面小节单独讲。连接上之后你会看到概览页签里有一个实时更新的Heap曲线图。正常情况下它是一条锯齿波每次Young GC后下降一截总体平稳。如果曲线呈“爬楼梯”式整体上行且每次GC后降不下去说明老年代在持续堆积对象这就是下一步要追的线索。2.2 连接远程JVM的JMX配置这次事故最终需要看线上进程所以远程连接这一步也得讲。远程连接靠的是JMX也就是Java管理扩展。需要在远程服务的启动参数里加上这样一串-Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port9999 -Dcom.sun.management.jmxremote.authenticatefalse -Dcom.sun.management.jmxremote.sslfalse -Djava.rmi.server.hostnamexxx.xxx.xxx.xxxport9999是JMX对外暴露的端口可以自己定一个不容易冲突的。authenticatefalse和sslfalse都只是为了排查方便生产环境不建议长期这么开最好配合认证或内网隔离。最后那个java.rmi.server.hostname一定要设成服务器的实际IP否则VisualVM以RMI方式连接时返回的地址可能是内网地址或者127.0.0.1怎么连都连不上。这也是远程连接最常见的坑。配置好之后在VisualVM里右键Local或Remote节点选择Add JMX Connection填hostname:9999即可。连接成功后可以看到远程堆曲线也可以做堆转储——但要注意远程连接下堆转储会先把文件生成在服务器端再下载到本地打开大堆场景耗时比较明显。2.3 先看趋势再动手重点观察什么拿到VisualVM连接后我通常不会立刻就点Heap Dump而是先观察一段时间让堆曲线有足够的采样数据。重点看三个东西Heap曲线整体走势是否持续上涨GC后能否回落。Metaspace曲线如果持续上涨多半是类加载器泄漏比如热部署、动态生成类。Garbage Collections页签这里能看每次GC的耗时、回收前后堆大小。如果Full GC频繁且回收后使用率依然很高说明老年代基本被不可回收对象占满了。在我这次的项目里曲线是这样的频率每两到三次Young GC后紧接着一次Full GCFull GC之后老年代曲线只掉一点点然后又继续爬。这说明有对象能苟过多次Young GC晋升到老年代却一直没人能清理它。光看这个现象还不能定位到具体类所以下一步堆转储是必须的。3. 堆转储定位“内存大户”从快照里找到真凶3.1 手动导出堆快照的时机选择在VisualVM面板里点击Heap Dump按钮即可导出当前堆快照。但要注意时机最好在Full GC之后、堆使用率处于相对低位时导出。因为堆使用率最高点时导出的大多是“临死前”的垃圾对象反而干扰分析。你可以先在VisualVM的GC按钮上点一下强制执行一次Full GC等曲线降下来再导出。这样导出的快照里剩下的就是“确实存活、确实占用空间”的对象分析价值最高。3.2 打开快照后怎么一步步定位导出后VisualVM会自动打开快照页面。第一步看Summary页签里的Heap Dump大小和Class总数对整体有个印象。第二步切到Classes页签按Retained Heap从大到小排序。这个Retained Heap可以理解为“如果把这个类的所有实例全部干掉能被释放的内存量”比Size更接近真实内存占用。在我这次的堆快照里排名第一的是一种类型byte[]Retained Heap占了将近500MB紧随其后的是char[]然后是java.lang.String和java.util.ArrayList。看到这个组合基本可以猜出一个“大量字符串被装进了一个很大的列表”的结构。第三步是看实例数量byte[]数量并不多但单个体积大说明不是碎片化的小数组堆积而是少数几个大数组。进一步定位到持有这些大数组的对象方法是选中这个Class点击右键选择Instance View会列出所有实例。从实例里能看到它们分别被谁引用。我选了一个占用最大的实例顺着“引用链”往上追。VisualVM的实例视图里会展示这个对象到GC Root之间的引用路径。一路点上去看到了熟悉的包名某个报表查询ServiceImpl的查询结果列表字段——一个ArrayList里面装了大量封装查询结果的对象每个对象内部又有若干个String和byte[]。这就把嫌疑锁定到了这个报表查询方法上。3.3 为什么“找到了大对象”不等于“找到了根因”找到大数组和大列表只是第一步真正的根因要回答另一个问题这些数据为什么会被同时加载到内存里我把代码翻出来看了一眼核心逻辑很简单public PageResultReportRow queryReportData(QueryParam param) { // 第一版实现 int pageNo param.getPageNo(); int pageSize param.getPageSize(); ListReportRow allRows reportMapper.selectLargeRange(param); // 内存中进行过滤、排序、分组 ListReportRow pageList allRows.stream() .filter(...) .sorted(...) .collect(Collectors.toList()); int total pageList.size(); // 手动截取当前页 ListReportRow result new ArrayList(pageList.subList( (pageNo - 1) * pageSize, Math.min(pageNo * pageSize, total) )); return new PageResult(total, result); }一眼看过去就明白问题出在哪里了这个接口为了做复杂过滤和排序一次性把一个大时间范围内的所有数据全部查进来在应用内存里做处理然后再手工截取当前页。数据量小的时候这套逻辑没什么问题但当某个客户选择了大时间范围或者不加过滤条件时需要加载的行数轻松超过几十万。几十万行的ReportRow每个字段都是字符串、日期、状态枚举、数值展开成对象图之后内存消耗呈数量级上升而且这些对象全部要存活到请求结束后才能被回收。QPS一高多个请求并发过来堆内存立刻被塞满。所以这里的根因不是“泄漏”而是“加载了远超所需的数据”。这类问题写代码时非常隐蔽因为它不会在测试阶段暴露通常要等某天的数据量触发临界点。3.4 定位到根因之后别忘了看一眼GC Roots有同学可能会问既然这些对象是方法局部变量方法一结束不就回收了吗为什么老年代GC了也收不掉这就涉及到“对象的可见性和存活判定”。在VisualVM的实例视图里你可以看到每个实例的引用链是否直接或间接连接到了某个GC Root——比如当前线程的栈帧、静态变量、JNI引用等。我这次追踪到的引用链最后落到了一个业务线程的栈帧上线程正在执行报表查询方法局部变量引住了这个巨大的列表所以即使老年代空间不足触发Full GC这部分对象同样被GC判定为“可达”无法回收。这解释了为什么Full GC之后老年代使用率几乎不减。等到请求执行完线程把栈帧弹出去这批内存才可以被回收。但由于下一个请求马上又来了藤蔓不断生长堆根本消化不了。所以不要把这类问题简单归结为“缓存没清”本质是单个请求工作集过大。想要治它除了换更大的堆不推荐更关键的是让单个请求不要一次性把全量数据捞进内存。4. 分页方案的三种实现内存消耗差距有多大既然根因在分页查询上接下来就是怎么改。这里我把三种典型分页方案放在一起做了对比实测最终选型也必须从这次堆转储的经历出发把“内存占用”放到和“查询速度”同样的权重来考量。4.1 传统OFFSET分页翻页越深越吃力第一种方案是大家在MyBatis/MyBatis-Plus里最常用的SELECT * FROM report_table WHERE create_time BETWEEN #{start} AND #{end} ORDER BY id LIMIT #{offset}, #{pageSize}这种写法在数据量小几千、几万行的时候毫无问题数据库和内存表现都很均衡。但它有一个天生的短板数据库需要先把offset pageSize行全部扫描出来再从第offset行开始返回pageSize行。越到后面的页偏移量越大丢掉的已扫描行数越多慢SQL不可避免。更重要的是它并不解决我们刚才的问题如果查询条件本身会命中几十万行即使加了LIMIT数据库端扫描的行数依然很大。虽然最终只返回一页数据但数据库的CPU和IO已经为所有命中的行付出了代价同时如果你在应用层还做了filter和sorted那LIMIT在数据库端根本限制不了应用内存里的总行数——因为先查全量再在内存里过滤LIMIT作用的是过滤前的结果。用在我这个项目里高级过滤条件一多SQL里的WHERE根本没把最终结果限制住开了LIMIT也是白搭几十万行照样从数据库拖进堆内存。这是OFFSET分页最容易被误解的一点你以为分页了其实只是把结果集截断了中间处理的数据量一点没少。4.2 键集分页记住上次位置而不是跳过N行第二种方案是键集分页也叫Seek Method思路非常简单翻下一页时不告诉数据库“跳过10000行”而是告诉它“从上一次拿到的那条记录往后继续找”。SQL长这样SELECT * FROM report_table WHERE create_time BETWEEN #{start} AND #{end} AND id #{lastId} ORDER BY id LIMIT #{pageSize}第一次查询时lastId传0拿到第一页并记录这一页最大id。翻下一页时把上一页最大id传进来数据库直接从那条记录之后开始取。没有OFFSET的跳过式扫描因此翻到十万页和翻到第二页性能差别不大。但它要求排序字段必须唯一且稳定通常就是主键id。如果排序需求比较复杂比如按create_time排序且同一时刻可能出现多个相同值就需要把id作为次级排序键并配合组合条件的写法WHERE (create_time #{lastCreateTime}) OR (create_time #{lastCreateTime} AND id #{lastId}) ORDER BY create_time, id LIMIT #{pageSize}这种写法稍微绕一点但胜在稳定。我在项目里最终采用了“默认按id排序 键集分页”的组合因为报表系统的排序需求大多可以归一化到主键顺序再在应用层做展示字段的排序不要求全局排序。4.3 覆盖索引分页在索引上完成定位第三种方案是覆盖索引分页。它的核心是先把SELECT的字段缩小为主键列表用主键和索引完成过滤、排序、分页最后用这批主键回表取完整数据。-- 第一步只查主键 SELECT id FROM report_table WHERE create_time BETWEEN #{start} AND #{end} ORDER BY id LIMIT #{offset}, #{pageSize}; -- 第二步用主键批量回表取完整数据 SELECT * FROM report_table WHERE id IN (...)这种方案的好处是第一步的扫描只需要遍历索引页不必把整行数据尤其是有很多大字段的行从数据页里读出来。内存和IO都大幅降低。但它依然有OFFSET的深翻页问题——如果翻到很靠后的页第一步还是要扫描很多索引项。所以比较适合“每页数据行宽大、偏移量不大”的场景比如详情表有几十个字段、单行好几KB的情况。4.4 三种方案的实测数据对比为了不拍脑袋我基于一份大约40万行的报表数据在同样的环境里对三种方案做了压测对比。环境是8C16G的云服务器JVM堆1.5G接口返回每页20条统计的是翻到第500页时的表现。方案单次接口内存增量约P99耗时约数据库扫描行数传统OFFSET分页只查一页120MB4.8s约10万行传统OFFSET分页先全量查再内存截取500MB6.2s约40万行覆盖索引分页18MB1.2s约1万行键集分页15MB0.3s21行可以看到键集分页的单次内存增量非常小。原因很简单它从机制上避免了把所有命中数据拖进堆。那个“先全量查再内存截取”的版本其实就是线上事故版本的模拟内存增量500MB直接就把堆撑爆了。需要说明的是覆盖索引分页在翻到第500页时依然出现明显耗时因为OFFSET还是让它扫描了1万行索引。它的优点在单行很宽的场景会更突出我这里的表单行宽度中等所以键集分页优势最明显。如果你的系统里不允许改SQL、不能轻易加WHERE id ?条件覆盖索引分页可以作为折中方案。5. 重构落地从SQL到代码的顺序调整5.1 关键改动让数据库做它该做的事确定改用键集分页后我对查询链路的改造分了三个步骤。第一步改写Mapper SQL。原先是全量查询现在加入键集条件。以MyBatis为例select idselectReportPage resultTypeReportRow SELECT id, report_no, biz_date, amount, status, note FROM report_table where if teststart ! null AND biz_date gt; #{start} /if if testend ! null AND biz_date lt; #{end} /if if testlastId ! null and lastId ! 0 AND id gt; #{lastId} /if /where ORDER BY id LIMIT #{pageSize} /select注意这里lastId默认用0这样第一页查询条件不会带上id 0因为所有主键都大于0带不带都一样但为了规范性还是建议在代码层判断一下。第二步调整Service层的“查总数”逻辑。原来total是通过pageList.size()得到的改完分页后不能这么干得额外用COUNT(*)查询总数。这里有个小技巧COUNT(*)只查行数不加载行数据内存占用很小没必要为了省这一次查询而牺牲正确性。如果确实对性能有极致要求也可以做“总数缓存定时刷新”但要注意数据一致性报表类场景通常可以接受分钟级延迟。第三步把过滤和排序挪到SQL里。原先在内存里的.filter(...)和.sorted(...)能下推的尽量下推到数据库。字段过滤写进WHERE排序写进ORDER BY。只有特别复杂的、数据库函数难以表达的业务过滤才保留在应用层。这一步做完应用层内存里只有当前页的数据不再出现“几千页数据都在堆里排队”的情况。5.2 保留键集分页游标如何记住“上一页的最后一行”键集分页和前端交互时有两种常见方式。第一种URL带参。上一页请求返回的lastId拼在链接上点击下一页时带上。适合服务端渲染的页面。第二种前端持有游标。REST接口的返回体里除了数据列表还额外返回nextCursor字段前端翻页时把它作为请求参数传回。适合前后端分离的接口。我在这次改造中选择了第二种改造后的返回结构类似{ data: [ ... ], total: 38765, nextCursor: 892314, hasNext: true }nextCursor就是当前页最后一条记录的主键id。前端点下一页时带上这个值。如果用户跳页怎么办键集分页不适合随机跳页因为页与页之间的游标是连续的。所以产品交互上需要做一次调整把“跳到第N页”改成“加载更多”。这个细节一定在开发前和产品对齐不然后端改完了前端交互没跟上联调时很容易返工。5.3 改造完成后的内存表现改造完成上线后我用VisualVM重新连上服务观察了半小时的堆曲线。最直观的变化是Full GC的频率从之前的几分钟一次降低到接近零堆使用率稳定在一个低位。接口P99耗时从几秒降到300ms以内。之前那些大byte[]、大ArrayList实例消失堆快照里排名靠前的变成了正常的业务常驻对象。这里可以分享一个判断标准当你的堆快照里byte[]、char[]、String大量堆积而且引用链最终指向某个业务接口的局部变量时先不要怀疑“内存泄漏”九成的情况是“单次请求数据量过大”。这是两种完全不同的诊断方向前者要查生命周期和缓存后者要查SQL和集合使用方式。方向错了折腾一整天可能都找不到真凶。6. 基于这次排障沉淀的通用排查清单6.1 排障工具链的搭配建议在IDEA里做日常调试加上VisualVM做堆分析是最适合普通开发者的组合。如果再搭配上JDK自带的jcmd和jstat基本可以覆盖大多数场景jcmd pid GC.heap_info随时看各代内存使用量。jstat -gcutil pid 1000每秒打印一次GC统计看Full GC频率。jmap -dump:live,formatb,fileheap.hprof pid只导出生还对象生成堆转储适合线上无法重启的情况。IDEA里自带的一个小功能也很实用断点打到业务方法上通过Evaluate Expression输入new Throwable()或查看局部变量的大小能快速确认某个集合到底装了多少数据。不需要等到OOM只要觉得接口慢就可以在可疑方法上加断点看一眼list.size()往往几秒钟就能发现异常大集合。6.2 写分页代码时需要强制自己回答的三个问题每次写分页接口我建议开发同学先回答三个问题这条分页SQL在数据库端需要扫描多少行才能返回当前页在应用层这个分页接口一次请求最多会把多少数据加载进堆内存如果数据量翻10倍这个接口会不会挂第一个问题指向OFFSET深翻页问题第二个问题指向“先全量查再截取”这种伪分页问题第三个问题是压死骆驼的最后一根稻草帮你在编码阶段就预判风险。我这次踩的坑本质上就是第二个问题没回答好。所有过滤条件都堆在应用层导致LIMIT形同虚设数据照样全量进堆。6.3 在线紧急处理的优先级建议如果线上已经出OOM了处理顺序建议是这样的先加-XX:HeapDumpOnOutOfMemoryError参数并重启确保下次OOM能留下现场同时用jmap强制导出一份快照来定位当前存活大对象再临时调大堆内存如果机器物理内存允许争取恢复时间最后才是静心分析代码做根治。切忌上来就改代码猜原因没有现场数据的排查就像闭着眼拆弹效率极低。我见过不少团队OOM之后第一反应是把堆调大、把超时时间延长结果问题只是被掩盖了几天。等数据量再涨一波二次爆发时堆已经调无可调才被迫回头找根因。早一点花半小时做堆转储分析远比多次线上事故的代价低太多。7. 收个尾这次踩坑留给我的经验整轮排查做下来最有价值的不是找到了一个具体的分页问题而是建立了一套“内存问题不靠猜、直接看堆”的路径。VisualVM的操作在熟练之后非常快从看到内存曲线异常到定位到具体的类和引用链通常不会超过半小时。真正花时间的是理解业务代码为什么会让数据全量驻留内存以及做出分页方案上的结构调整。如果你现在就在处理类似内存持续增长的问题我建议按照这个顺序操作先配HeapDumpOnOutOfMemoryError再连VisualVM看曲线GC后导出快照按Retained Heap排序看大对象顺着引用链找持有者。这个过程做完你大概率会自己找到问题所在。至于分页方案别等OOM了再改。如果你维护的报表接口目前还是“先把全量数据查出来再内存截取”的写法哪怕数据量今日看起来不大也建议尽早换掉——等它变成线上事故的时候修改的代价已经不是改几行代码那么简单了。有人可能觉得换键集分页后少了很多“跳页”的灵活性但在我看来报表类场景里用户真正高频使用的动作就是“下一页”和“加载更多”跳页的诉求极少。用一点点交互上的收敛换取内存稳定和接口稳定这笔交易很划算。