ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

JVM内存居高不下?可能是GC不及时而非内存泄漏——Excel导出场景排查实录

JVM内存居高不下?可能是GC不及时而非内存泄漏——Excel导出场景排查实录 做后台服务这几年处理过不少诡异的内存问题最让我印象深刻的是这一个明明代码里已经调用了workbook.close()对象也置空了可JVM堆内存就是迟迟不降GC日志里Full GC半天才来一次内存曲线像一条横线。很多同学第一反应是“完蛋内存泄漏了” jmap、jvisualvm各种工具轮番上折腾好几天也没查出个所以然。后来等我把“内存到底谁占着、什么时候才肯归还”这件事彻底想明白才发现这压根不是泄漏而是GC不及时。今天就把这段排查经历、原理分析和最终的解决方案完整梳理一遍特别是做Excel大数据量导入导出、报表服务、数据中台这类的同学这篇文章大概率能帮你省好几天的排查时间。大数据表格场景里内存问题的核心矛盾在于Excel的写入需要把大量结构化数据组装成XML片段这个过程产生的对象大部分会直接进入老年代。老年代除非触发Major GC或Full GC否则不会主动回收空间。你虽然把引用置空了对象却还在老年代里躺着只能等“大扫除”轮到自己。于是现象就是——内存占用居高不下看起来和泄漏一模一样实际上只是GC的回收时机没到。1. 从“内存居高不下”说起问题现象与排查定位1.1 典型的表象内存曲线成了一条直线先说当时现场的表现。系统用的是一台4C8G的容器JVM堆内存设置了-Xmx2g里面承载一个报表导出功能。业务要求一次性把几十万行明细数据写成Excel文件供用户下载每天有几十个定时任务在跑。某天运维反馈容器内存告警RSS居高不下触发swap后接口响应开始变慢重启才恢复。我上去看的时候JVM堆内存使用率稳定在90%左右GC日志显示Minor GC一直在动但每次回收后堆内存只是轻微下降很快又涨回去。用jmap -heap看了一眼老年代老年代空间几乎占满但FGC却迟迟没有发生或者频率极低整体曲线就是“平台期”。换了jvisualvm连上去也一样存活对象里一堆byte[]、String、xml格式相关的内部类而且数量很多、个头很大。最让人误判的是启动参数里确实加了-XX:HeapDumpOnOutOfMemoryError但业务并没有OOM。大家第一反应就是“对象没释放泄漏了”然后开始到处找Workbook没关闭、流没关闭、静态变量缓存等问题翻遍了代码也没找到明显的漏洞。后来才想到一个点如果这些对象老早就不可达、只是没被回收那和“泄漏”在内存图上长得几乎一样但处理方式完全不同。1.2 先用一次强制GC验证回收不掉才是泄漏排查内存问题我习惯先做一个快速验证用jcmd或者JMX触发一次System.gc()然后观察堆内存变化。注意这是服务于排查的临时操作不是生产环境常规手段但它能很快地把“内存泄漏”和“GC不及时”区分开。当时我在测试环境复现了导出任务导出完成后等了两分钟先记录堆内存占用然后执行jcmd GC.run再看堆内存。结果非常关键一次FGC之后堆内存从1.6GB直降到400MB以下老年代的使用率也大幅回落。这说明对象本身是可以被回收的只是GC没有及时触发。从这一刻起问题的性质就变了。如果FGC后内存纹丝不动那大概率是真泄漏要从强引用链、静态集合、ThreadLocal这些方向去追。而FGC后内存大幅下降说明只是“垃圾存放时间过长”要解决的其实是老年代什么时候回收、怎么让JVM更早地触发大扫除以及怎么在设计上减少大对象进入老年代的比重。2. 为什么销毁了对象内存还在JVM内存模型与GC机制拆解2.1 对象不是“销毁”就立刻消失的很多同学对JVM内存回收的理解是“对象null了过一会儿就应该被回收”但实际JVM处理垃圾回收遵循的是可达性分析算法从GC Roots出发沿着引用链往下走能走到对象就算“活着的”走不到才算“垃圾”。GC Roots包括栈帧中的局部变量、静态变量、JNI引用、活跃线程等。当你执行workbook null时这个对象确实失去了来自局部变量的强引用理论上已经“不可达”。但问题在于GC不保证立刻回收它。垃圾对象需要等下一次垃圾收集动作发生而且更关键的是——它所在的内存区域决定了下一次GC什么时候来。如果它待在年轻代Minor GC频繁很快就会被清理如果它在老年代那只有Major GC或Full GC才会去扫描。老爷子们干活频率低你一个对象躺在老年代里自然“看起来”就是不消失。大数据量Excel导出时Workbook内部维护了整个Sheet结构的DOM树每个单元格样式、字体、合并区域、公式都要持久化在内存里这个对象集群体积非常庞大。JVM有个空间分配规则叫“大对象直接进入老年代”阈值由-XX:PretenureSizeThreshold控制但即使没有配置这个参数一个持续增长的Workbook经过多次Minor GC后依然会因为年龄增长进入老年代。等导出结束、引用置空后这个大对象集群就安静地躺在老年代里等待一个可能很久之后才到来的FGC。2.2 Minor GC、Major GC、Full GC分别管哪块地聊到GC就得把几个名词掰扯清楚不然排查时会绕晕。Minor GC也叫Young GC只回收新生代把存活对象晋升到老年代或者Survivor区它发生得最频繁耗时短是JVM最主要的垃圾回收动作。Major GC一般指清理老年代的GC在很多GC实现里会连带Young区一起叫做Full GC。Full GC是重量级动作STW时间最长所以JVM会尽量推迟它。问题就出在这个“尽量推迟”上。老年代只有空间不足时才会触发Major GC或者Full GC。你导出完一个报表老年代可能占了1.2GB、总共1.5GB还剩300MB但这300MB并不会触发FGC因为还没有达到触发阈值。于是对象就一直在那躺着内存占用维持高位。直到下一次大对象分配老年代空间不够了JVM才会“被迫”来做一次Full GC。这就是“GC不及时”的核心机制垃圾对象本身无需存活但回收动作发生的条件是“空间不够”而不是“存在垃圾”。只要堆还有一点剩余空间JVM就倾向于不干重活。于是你看到的现象就是“占着茅坑不拉屎”——内存占用高、响应慢、但程序没有报错也没有OOM一切看着都不健康但又勉强能跑。2.3 JVM不会主动向操作系统归还堆内存还有一个容易忽略的底层事实即使发生了Full GCJVM堆内存被清出来的空间大多数情况下也不会立刻归还给操作系统。JVM为了性能倾向于把堆空间握在自己手里后续再用。你在任务管理器或容器监控里看到的内存占用是RSS物理层面多长期被JVM持有。所以哪怕GC已经把老年代从1.5GB降到了200MB你通过free或top看到的进程RSS也未必下降。换句话说就算你搞定了GC触发时机问题也有可能看到“堆内降了但物理内存没怎么降”的现象这会让人以为方案无效。实际操作中我们既要确保堆内垃圾及时被GC又要在监控层面区分开堆内占用和物理RSS前者看jstat、jconsole后者看top、/proc/ /status里的VmRSS。理解了这两者的关系排查时才不会被“物理内存居高不下”带偏。3. 大数据表格写入期为何容易触发“假泄漏”3.1 XSSFWorkbook的内存占用曲线与老年代晋升做Java生态的同学大都用过Apache POI导出Excel的老牌方案是HSSFWorkbook.xls和XSSFWorkbook.xlsx。XSSFWorkbook走的是DOM模型整个工作簿加载进内存。几十万行数据意味着几十万个Row对象、Cell对象、样式对象、字符串驻留对象全部堆叠在一起体量轻轻松松到几百MB甚至上GB。这种对象有一个特点生命周期短暂但体积巨大。随着写入循环推进这些对象产生、引用、更替但Workbook整体引用始终存在导致整个对象树不会在导出过程中被回收。JVM在年轻代分配这些对象时会频繁触发Minor GC而Minor GC之后幸存下来的对象会逐步晋升最终涌入老年代。由于对象体积大晋升速度快老年代的使用率会快速飙升。导出结束、调用workbook.close()、把workbook置null之后老年代里的这一大块区域成了“无主之地”。但因为没有新的老年代分配需求FGC迟迟不来。于是你会发现导出一个500MB的表格后JVM内存一直维持在高水位而这些内存里住着的全是已经“死掉”的对象。3.2 手动置null为什么不是百分百有效有同学会问我在循环里每处理完一行就置null甚至每处理完一个Sheet就主动调了System.gc()为什么内存还是降不下来这里有几个原因。第一一个对象的“可达性”是从GC Roots出发遍历判断的局部变量置null确实能让当前方法栈帧里的引用断开但如果业务代码里还有别的强引用路径——比如把Workbook放进了ThreadLocal、放进了ServletContext、或者被某个异步任务持有——那它依然是存活的你做再多局部置null也没有用。第二System.gc()只是“建议”JVM执行GC而不是“命令”。JVM完全可以通过-XX:DisableExplicitGC忽略你的建议在服务端通常还会配合-XX:ExplicitGCInvokesConcurrent来降低显式GC的停顿但这样回收效果并不彻底尤其是对老年代的老对象。第三也是很多人不知道的JIT编译器会做逃逸分析和死代码消除如果你的对象在代码后续没有任何使用在某些情况下它甚至不会被插入GC根枚举的显著位置但这并不等于普通置null就一定能立刻触发回收——它只是去掉了强引用回收动作本身还是被GC机制推迟。真实项目中置null做得对不对还得靠工具验证。我会用jstack拿到线程栈确认在导出方法返回之后栈帧确实已经弹出了再结合堆转储看对象是否还有GC Roots路径。如果确认不可达但内存还是居高不下那问题基本就锁定在“回收不及时”这个维度了。3.3 Full GC触发条件的误判“还没满所以不回收”再深挖一下触发条件。老年代的触发阈值通常可以用-XX:CMSInitiatingOccupancyFractionCMS或-XX:InitiatingHeapOccupancyPercentG1来调整。默认情况下G1的IHOP是45%意思就是堆使用率达到45%就可能启动并发标记但这只是“并发标记”的阈值不是“FGC”的阈值。真正的FGC触发点是在并发标记失败、或者发生转移失败的时候。在表格导出这种亚健康场景里最典型的发生顺序是老年代或整个堆占用升到了90%以上但还没有到完全写满的状态于是JVM仍然自我感觉良好。等到下一次有大对象需要分配或者某次Young GC晋升失败时才触发一次重量级FGC。这期间你的服务对外表现就是“内存占用飙高、GC频繁但回收不掉、接口变慢”跟泄漏几乎一个模子刻出来的。理解了触发条件解决方案就有了方向要么主动调整触发阈值让JVM在内存高到一定程度时提前开始回收要么业务线程在导出结束后主动“请一次GC”。但我要提前给个提醒直接无脑调低触发阈值后Full GC次数可能显著上升STW时间变长对延迟敏感的服务反而不利。要用巧劲不能蛮干。4. 终极解决方案显式置null System.gc() 响应式回收策略4.1 根治思路让老年代尽早清场而不是等全满解决这个问题的核心逻辑就是两句话让大对象在生命周期的终点尽早被标记为垃圾在关键节点主动推动老年代回收。其实最理想的方案是从源头减少老年代大对象堆积比如换流式写入API这我后面单独讲。但如果你暂时不能改框架就必须在“回收时机”上下功夫。我这里推荐一套组合拳导出方法里使用局部变量避免Workbook被外部类持有在finally块里依次关闭FileOutputStream、Workbook并显式置null在导出响应返回之前通过一个可控开关触发一次System.gc()结合JVM参数调整保证显式GC不会被忽略且能充分回收老年代。这套组合拳的目的不是说每次导出都要做FGC而是针对“高内存尖刺后陡降”的场景把本来要等很久的那次Full GC提前到“请求结束后低峰期”来完成避免内存长期占住高位。4.2 代码层实现关闭后置空、安全触发GC下面是当时我实际用过的代码骨架基于Spring Boot的Controller异步导出场景写法比较简单但关键步骤都有注释// 导出任务线程 public void exportBigExcel(OutputStream responseStream, ExportQuery query) { XSSFWorkbook workbook null; FileOutputStream fos null; try { workbook new XSSFWorkbook(); // 构建Sheet、写入数据这里省略业务封装 fillWorkbook(workbook, query); fos new FileOutputStream(tempFile); workbook.write(fos); fos.flush(); } catch (Exception e) { log.error(导出失败, e); } finally { // 第一步关闭流释放文件句柄 IOUtils.closeQuietly(fos); // 第二步关闭Workbook释放内部数据结构 IOUtils.closeQuietly(workbook); // 第三步显式置空切断强引用 workbook null; // 第四步按开关触发GC避免生产环境每次都FGC if (gcSwitch.get()) { System.gc(); } } }这里有两个细节值得注意。一是IOUtils.closeQuietly(workbook)这一步POI的Workbook接口继承自Closeableclose()会释放一部分内部资源比如临时文件、缓冲流等。二是显式置null之后建议再用一个局部变量赋值来帮助JIT识别有些JVM版本中对“最后使用点之后的引用归零”做得很激进显式置null可以帮助优化器更早切断引用。关于System.gc()生产环境直接裸调要小心。JDK 8以后OracleJDK默认把-XX:DisableExplicitGC配在了一些基础组件里比如RMI、JMX相关的启动配置。如果你的应用确实被加上这个参数你要么去掉要么换另一种方式。另一个选择是使用jdk.internal.misc.Unsafe或者通过JMX的GC MXBean触发GC但更通用、更可控的做法是调低触发阈值让Full GC更早发生。4.3 JVM参数组合调整触发阈值与显式GC行为要让这套方案在生产环境落地JVM参数不能少。当时我采用的关键参数如下-Xms2048m -Xmx2048m -XX:UseG1GC -XX:InitiatingHeapOccupancyPercent35 -XX:ExplicitGCInvokesConcurrent -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/dump逐条解释一下。-XX:InitiatingHeapOccupancyPercent35表示堆使用率达到35%时G1开始并发标记周期。这样老年代不会被放任到90%以上才开始干活内存饱和度更快被识别和清理。-XX:ExplicitGCInvokesConcurrent把System.gc()从Full GC降级为并发周期STW更短减少对业务线程的冲击这个参数配合主动GC非常合适。但要注意IHOP调太低会导致G1频繁做并发标记消耗额外CPU很大概率会让吞吐量轻微下降。对批量任务型服务来说可接受对高并发低延迟业务就要谨慎。我当时选择35%是做了压测的导出一次500MB的Excel后堆内存峰值控制在1GB以下FGC次数从每小时2次增加到4次但STW平均时长从180ms降到80ms整体响应反而变好了。原因是提前回收避免了大对象堆积后的“紧急FGC”把耗时分散到了多个低峰期。4.4 编程范式升级能走流式就别用DOM模型参数和System.gc()是“善后”更高级的做法是从源头减少大对象进入老年代的体量。POI官方提供的事件模式或者流式模式中SXSSFWorkbook是一个非常好的选择。SXSSFWorkbook内部维护一个滑动窗口只保留当前可见的行数据在内存中之前的数据会被写出到临时文件这样单次内存中最多只存在几百行对象几十万行的表格写下来内存占用也能控制在很小的范围内。代价是某些功能受限比如不能随机访问已经写出的行但用于导出报表完全足够。除了SXSSFWorkbook阿里开源的EasyExcel也是主流方案之一。EasyExcel基于SAX模式解析和写入内存占用非常低几乎成了当前大数据量Excel导出的标准选择。换用EasyExcel之后我甚至发现连System.gc()都不太需要主动调用了因为峰值内存锐减老年代根本堆不出那么大的“垃圾山”。导出方案内存模型大数据量表现推荐度HSSFWorkbook全量DOM极差几十万行基本OOM不推荐XSSFWorkbook全量DOM内存占用高有GC不及时风险少量数据SXSSFWorkbook滑动窗口临时文件内存可控写入快推荐EasyExcelSAX流式解析内存极低社区活跃强烈推荐4.5 监控运维层给GC装上“仪表盘”最后光有代码和参数还不够必须有监控手段配合。我在实际运维中给这个导出服务做了三件事使用Micrometer暴露JVM内存和GC指标以PrometheusGrafana构建看板重点关注jvm_memory_used_bytes和jvm_gc_pause_seconds这两个核心指标在导出结束的日志里主动打印堆内存快照包含总内存、已用内存和最近一次的GC耗时方便追踪每次导出的内存水位配置GC日志输出并定时归档用GCeasy或GCEasy分析停顿趋势一旦发现某次导出后老年代持续走高能迅速定位是GC参数问题还是业务侧真的产生了额外引用。这一步相当重要。没有监控你永远是在事情发生之后才去抠日志有了监控你可以设定阈值老年代占比超过70%就触发告警在内存问题恶化成接口超时之前就介入处理。我后面排查其他项目时靠的就是这套监控体系快速锁定了类似问题省了不少力气。5. 实操验证与避坑指南从参数验证到问题速查5.1 一组实测数据GC前后对比与响应时间变化为了验证这套方案真的有效我在测试环境做了对比实验。环境为JDK 8、G1回收器、-Xmx2g数据量是40万行×20列的Excel导出跑12次取平均值。先看不做任何处理的情况XSSFWorkbook导出完成后老年代占用1.45GBFGC间隔约90秒请求完成时间4.8秒。此时再发起一个普通查询接口RT从30ms飙到800ms表现非常明显。接着用System.gc()IHOP35的组合导出后老年代占用降到320MB请求完成时间4.6秒后续查询接口RT稳定在32ms。差异最大的地方在“导出结束后的内存水位”上差了两倍以上。而换成SXSSFWorkbook之后整个导出期堆内存峰值只有420MB老年代高峰也只有210MBGC完全不紧张响应时间全程稳定。这就很说明问题了有条件的项目升级技术方案比调参数更干净利落。5.2 常见问题排查速查表现象可能原因排查方法解决方案导出后内存居高不下GC日志FGC少老年代有大量不可达对象未触发FGC触发一次System.gc()观察内存是否下降调低IHOP或主动触发GC或换流式APISystem.gc()没效果JVM配置了DisableExplicitGC检查启动参数是否包含DisableExplicitGC去掉参数或用JMX GC MXBean触发FGC后物理内存RSS没有明显下降JVM未归还堆内存给操作系统对比jstat堆用量和top RSS属于正常现象关注堆内指标为主可考虑容器内存限额预留余量Workbook关闭后仍有byte[]占内存存在其他强引用路径用jmap dumpMAT分析GC Roots检查ThreadLocal、缓存、异步任务持有引用调低IHOP后FGC次数暴增触发阈值设得过低导致频繁并发标记逐步调参压测观察STW平衡内存水位与回收频次找到最优区间导出几十万行就OOM使用了DOM模型内存峰值过高看OOM时的堆栈指向哪个类换用SXSSFWorkbook或EasyExcel5.3 关于“内存泄漏”和“GC不及时”的最后判断方法如果你也遇到类似问题我建议按这个步骤走先用jstat -gcutil观察老年代使用率和FGC次数记录时间点主动触发一次GCjcmd GC.run或JMX观察内存变化如果内存大幅下降说明垃圾对象本身没问题问题在回收时机如果内存几乎不动再用jmap -dump:formatb,filexxx.hprof 导出堆转储用MAT分析GC Roots找真实泄漏点。这个方法我在多个项目里验证过准确率很高能把排查范围缩小到一个很集中的选择上要么改GC参数、主动GC要么查强引用链。千万别一上来就怀疑框架有Bug、JVM有毛病很多时候问题就出在“回收机制”和“使用方式”的错配上。最后再分享一个小心得生产环境的JVM参数不是抄一段配置就完事的。每一个营销号都在推的“万能JVM调优参数”离开业务场景就是空谈。你需要理解它背后的触发逻辑在测试环境压测到足够大的数据量和并发量再决定是调IHOP还是加ExplicitGCInvokesConcurrent、是换SXSSFWorkbook还是彻底换成EasyExcel。搞明白了“对象去哪了、什么时候被回收、什么条件触发回收”这三个问题你就能从“到处试参数”进化到“一眼看到底”。这个思维比任何所谓的终极方案都值钱。
返回列表