多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

JVM内存溢出与GC频繁排查实战:从堆到堆外的完整路径

JVM内存溢出与GC频繁排查实战:从堆到堆外的完整路径 几个月前一个线上服务连续两次在凌晨被监控报警先是接口大面积超时紧接着JVM进程直接挂掉异常日志就一行java.lang.OutOfMemoryError: Java heap space。我接手的时候第一反应也是把-Xmx拉大然后重启但直觉告诉我不能这么干——因为这是当月第三次OOM了前两次都是这么处理的每次加完内存都只能稳几天。后来老老实实打开GC日志、捞堆快照才发现问题根本不在堆大小上而是一个定时任务用IOUtils.toString把整个几十MB的报表文件一次性读成了字符串赶上业务量翻倍堆直接被打爆。今天想把这类“内存溢出、GC频繁”问题的定位思路整理出来给自己做个复盘也给同样被JVM调优折腾的人一条可复用的排查路径。这篇文章不打算讲太多高深理论重点放在真实排查场景里你会遇到的工具、命令、报错和决策逻辑上适合后端开发、运维和刚开始研究JVM调优的新手。1. 排查前的底子JVM内存模型、GC机制和几个关键决策点1.1 堆、元空间、线程栈、直接内存谁是谁的“责任田”很多同学一上来就调参数但连JVM内存分几块都说不清楚这就像不看地图就开车迟早撞墙。按我自己的习惯排查任何内存问题之前脑子里先过一遍JVM的内存布局四块区域必须门儿清堆Heap对象实例的主战场日常说的-Xms、-Xmx都是堆的上限。堆里面又分新生代和老年代新生代再拆成 Eden 和两个 SurvivorS0、S1。绝大多数Java heap space的OOM问题都出在这块。元空间MetaspaceJDK8 之后替代了永久代存放类元数据、方法信息。它不在堆内默认受本地内存限制这也是很多人容易漏掉的“隐形炸弹”下文会专门说。线程栈VM Stack每个线程一个栈往里压的是栈帧局部变量、方法调用大小由-Xss控制默认1MB左右。线程一多这块内存同样不容小觑。直接内存Direct MemoryNIO 的ByteBuffer.allocateDirect分配的内存不走堆默认受MaxDirectMemorySize控制该值默认等于Xmx。这区域在jmap -heap里完全看不到但top里能看到进程占的内存远大于堆上限。我习惯把这四块用一个比喻记堆是主仓库栈是每个员工的手推车元空间是仓库外面的图纸柜子直接内存是仓库门口临时堆货的月台。排查时四个区域都要看只看堆很容易漏掉真凶。1.2 JRE和JVM到底什么关系为什么和调优有关这个问题其实很基础但我在排查现场发现很多工作了好几年的开发也说不清。一句话总结JDK 是工具箱JRE 是运行环境JVM 是运行环境里真正干活的“虚拟机”。你执行java命令本质是启动一个 JVM 进程JRE 提供java.lang、java.util这些核心类库类加载器把类加载进元空间JVM 再解释执行你编译出来的字节码。调优时动的-Xmx、-XX:UseG1GC全部作用在 JVM 进程上但真正消耗内存的是你代码里new出来的对象和 JRE 加载进来的类。所以调优的第一原则永远是“先看代码再看参数”——如果连内存是代码吃掉的还是配置不合理吃掉的都没分清调参就是碰运气。1.3 GC是怎么工作的新生代、老年代、回收算法与触发条件GC 机制不展开写成论文只说排查必须知道的部分。对象先出生在 Eden 区Eden 满了触发 Minor GC活下来的对象进 Survivor 区熬过若干次 Minor GC 后晋升到老年代。当老年代快满或者晋升失败时就会触发 Full GC。衡量 GC 问题通常只看两个指标频率和单次停顿时长。频率高说明对象产生速度快或者堆太小装不下单次停顿长说明堆太大、活着对象太多或者回收器选得不合适。常用参数我在下面这张表里列清楚排查时能快速对照参数作用备注-Xms/-Xmx初始/最大堆大小生产环境建议相等避免动态伸缩-XX:NewRatio老年代:新生代比例默认2即老年代占2份新生代占1份-XX:SurvivorRatioEden:Survivor比例默认8-XX:MaxMetaspaceSize元空间上限不设可能吃满本地内存-Xss线程栈大小默认1MB线程多时可适当调小-XX:UseG1GC使用G1回收器低停顿场景常用-XX:MaxGCPauseMillisG1期望最大停顿设太低会导致GC过于频繁-XX:MaxDirectMemorySize直接内存上限默认等于Xmx需按实际调整看完这张表再回头看线上OOM很多人第一反应是“Xmx不够大”。但实际排查时我首先看的恰恰相反堆设得合不合理只是其一更重要的是里面的对象是谁创建的、为什么没释放。2. 先分清两种病内存溢出和GC频繁的诊断路径完全不一样2.1 从报错信息判断是哪个区域在报警遇到OOM别急着重启先看异常信息。不同报错指向的区域完全不同对应的排查方向也完全不同报错信息对应区域优先排查方向Java heap space堆大对象、内存泄漏、堆设置过小Metaspace元空间动态生成类、反射、代理库unable to create native thread系统线程/栈线程数超限、文件描述符耗尽Direct buffer memory直接内存NIO buffer未释放、直接内存上限过小GC overhead limit exceeded堆对象疯狂产生GC已濒临崩溃GC overhead limit exceeded一定要单独说它本质上是 GC 频繁到极端时的保护机制JVM 发现 GC 一直在跑但回收效果极差干脆抛OOM。遇到这个报错加-Xmx大概率没用因为问题不是堆小而是代码里在疯狂制造无法回收的对象。2.2 用jstat判断GC是否“频繁”“GC频繁”不能靠感觉要用数据说话。我在现场的第一步永远是jstatjps -l # 找到目标进程PID之后每隔1秒输出一次GC统计 jstat -gcutil pid 1000输出里E是 Eden 使用率O是老年代使用率M是元空间使用率YGC是 Minor GC 次数FGC是 Full GC 次数FGCT是 Full GC 总耗时。一个健康服务 FGC 一天几次甚至没有如果一分钟几十次 FGC每次几百毫秒那接口不卡才怪。我判断 GC 是否异常通常看三个信号FGC 频率突然暴涨说明老年代一直快满回收完又涨满典型的对象泄漏或晋升异常。每次 FGC 耗时极长说明堆里存活对象太多或者堆太大导致回收时需要扫描大量对象。YGC 每秒好几次说明短命对象产生太猛比如在循环里重复查库、重复创建大集合。这三个信号对应的调优动作完全不一样所以我说“先分清两种病”——一种是堆不够吃一种是吃法不对。2.3 现场排查的标准动作从jmap到jstack确认 GC 异常后接下来按顺序做现场取证。我总结了一套标准动作基本不会漏jmap -heap pid看堆配置和当前各区占用确认是不是真的有对象把老年代塞满了。jmap -dump:live,formatb,fileheap.hprof pid抓堆快照。注意大堆慎用会暂停应用生产环境要评估影响或选择业务低峰期。jstack pid看所有线程栈排查是不是某个线程卡死、死循环在疯狂 new 对象。同时去看服务日志里最近10分钟的异常特别是有没有 SQL 超时、连接池打满之类的记录。这里有个救命参数我已经写进所有服务的默认启动脚本里-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heap.hprof如果服务已经 OOM 而且进程退出了dump 根本来不及抓。有了这个参数JVM 会在 OOM 时自动把堆快照留在磁盘上后面慢慢用 MAT 或 JProfiler 分析就行。这个参数救过我好几次可以说是性价比最高的一个调优前置配置。2.4 排查链路要跳出JVM本身很多 GC 问题表面在 JVM根因却在数据库和外部依赖。我处理过一个服务FGC 忽然飙升追下去发现是 MySQL 一条慢查询把连接池打满大量请求在排队等连接每个线程都持着一批历史对象不放手老年代压力暴增。那段时间搜“mysql性能调优”经常能搜到 GC 调优文章不是没道理的——数据层和内存层经常是连在一起出问题的。所以我的排查链路不只是 JVM 内部OOM 发生时间、GC 日志、业务日志、数据库慢查询、连接池状态、外部接口耗时全部拉到一个时间线里看。比如某次凌晨2点 OOMGC 日志显示1:50开始 FGC 频繁业务日志里1:45有一批定时任务启动——真相往往就藏在这个时间交叉点上。3. 案例一大文件读取成字符串流把堆打爆3.1 一行代码引发的内存翻倍回到开头那个线上事故。罪魁祸首是一行再常见不过的代码String content IOUtils.toString(new FileInputStream(file), UTF-8);这个file是每天凌晨自动生成的报表平时也就 20MB 左右。那天业务量涨了文件到了 180MB于是 JVM 撑不住了。为什么 180MB 的文件会把 Xmx1GB 的堆打爆因为IOUtils.toString内部用ByteArrayOutputStream把整个文件先读成byte[]再按 UTF-8 解码成String。这两个对象在转换瞬间同时存活一个 180MB 的byte[]还有一个解码后的String。如果文件主要是中文UTF-8 下一个汉字占 3 字节解码后String内部的char[]每个字符占 2 字节算下来String本身也要 120MB 左右再加上解码器临时缓冲区瞬间峰值超过 400MB——业务正常运行时堆里还有几百 MB 占用1GB 的堆直接爆掉。这就是典型的“代码用法不当导致内存翻倍”不是堆不够是同一份数据你复制了好几份。3.2 正确姿势流式处理与分块读取正确的做法很简单逐行或分块读取让内存里始终只保留一小部分数据。BufferedReader reader Files.newBufferedReader(Paths.get(filePath), StandardCharsets.UTF_8); String line; while ((line reader.readLine()) ! null) { // 处理一行处理完这行的引用自然可以被回收 }如果是复杂格式CSV、日志用流式解析器或者按批次读取每处理完一批就清掉引用。确实需要全文做统计的场景可以用MappedByteBuffer映射文件借助操作系统读写文件不把文件整体加载进堆。你经常搜到的“java将文件读取成字符串流时内存溢出”基本就是这么来的——一次性读成字符串这个操作本身就是要让整份文件在内存里多待几份数据一大必炸。这一点怎么强调都不为过文件、网络流、数据库结果集凡是能流式处理的就别整段加载。3.3 从案例看“批量调优”的思维这个案例引申出一个我很喜欢的概念——批量调优。它不是某一个参数而是处理大批量数据时的一整套思路能分批就分批能流式就流式别把几千条、几万条数据一次性全捞进内存。数据库分页、文件分块、消息批量消费本质都是同一个道理控制单位时间内活在内存里的对象数量。我在实际项目里见过太多“调参党”一遇到内存溢出就加-Xmx从2G加到4G再到8G最后发现把代码改成流式处理之后1G的堆都绰绰有余。代码改对了GC 自动就安静了参数反而不是重点。4. 案例二百万行Excel导出XSSFWorkbook直接内存溢出4.1 POI的三种APIXSSF不是万能的做过 Java 导出 Excel 的肯定对XSSFWorkbook不陌生。Apache POI 的XSSFWorkbook对应.xlsx格式但它有个致命弱点所有行、单元格对象都保留在内存里。导出几十万行、几百列时堆直接爆炸。网上搜“xssfworkbook内存溢出”能搜出一大堆求助帖症状基本一致小数据量没事一导出全量数据就 OOM。POI 其实提供了三种 APIHSSF对应老式.xls最多 65536 行太老不鼓励新项目用。XSSF对应.xlsx内存占用高因为所有行都驻留在内存中。SXSSFXSSF 的流式版本通过滑动窗口机制只保留最近 N 行在内存里其他行写入临时文件。N 默认 100可以自己设。所以导出大量数据的正确做法是用SXSSFWorkbook(窗口大小)而不是new XSSFWorkbook()。4.2 SXSSF的坑与正确写法一个标准写法SXSSFWorkbook wb new SXSSFWorkbook(100); wb.setCompressTempFiles(true); Sheet sheet wb.createSheet(批量数据); for (int i 0; i 1000000; i) { Row row sheet.createRow(i); for (int j 0; j 50; j) { row.createCell(j).setCellValue(test- i - j); } } try (FileOutputStream fos new FileOutputStream(out.xlsx)) { wb.write(fos); } finally { wb.dispose(); // 清理临时文件 }这里有几个坑必须强调wb.dispose()一定要调用否则临时文件残留这也是“导出一次内存涨一次”的隐性原因。setCompressTempFiles(true)会压缩临时文件减少磁盘占用代价是 CPU 升高看机器情况取舍。窗口大小要根据列数和行大小调整。窗口越大内存越高窗口太小频繁刷临时文件也会慢100 是一个比较保守的起步值压测后再调。另外SXSSF 因为只保留窗口内行不适合读取已有大文件做随机修改。如果你要解析大.xlsx请用XSSFReader配合 SAX 事件模型或者直接用 EasyExcel 这种封装好的库。把读取和导出的 API 选对内存问题已经解决了一半。4.3 导出场景的“批量调优”三层控制我把导出大文件的内存控制总结成三层每一层都不能省DB层查询分页或者设置 JDBC 的fetchSize别让驱动一次性把百万结果集全拉进内存。转换层复用 DTO、批量 flush避免在循环里创建大量中间对象。写盘层用 SXSSF 窗口 压缩临时文件同时写文件用缓冲流。这三层都控制住百万行导出完全不是问题。我自己做过一个导入导出平台20万行表格导出时堆内存波动也就几百 MBGC 完全没有任何压力靠的就是这三层控制。反过来说如果确实堆不够把 Xmx 从2G加到4G能解决一部分问题但治标不治本。堆越大Full GC 停顿越长用户实际感受到的卡顿越明显。所以我的原则是先让代码“省着吃”再让 JVM“吃合适”。5. 堆外内存那点事从VMMap、jmap到NMT别漏了直接内存和元空间5.1 堆外内存的来源为什么jmap看不出来问题有一种 OOM 最让人头疼jmap -heap看堆还剩不少空间但操作系统里进程的 RES 内存一直在涨最后要么被 OOM Killer 干掉要么报Direct buffer memory。这就是堆外内存问题。堆外内存的主要来源有DirectBufferNIO 的ByteBuffer.allocateDirect比如 Netty 的池化内存。线程栈线程越多栈内存越多这部分在进程内存里占的是实打实的空间。Metaspace类元数据不受Xmx约束。JIT编译产物、GC内部结构、JNI这些零碎内存加起来也不小。所以进程总内存 堆 元空间 线程栈 直接内存 JVM自身开销。如果你只盯着Xmx堆外内存就是你永远找不到的那部分“消失的内存”。5.2 在Windows上用VMMap查看进程内存构成如果你在 Windows 环境排查 Java 进程内存异常微软 Sysinternals 套件里的VMMap是很好的工具。很多人搜“vmmap怎么查看内存溢出”我这里说下我自己的用法以管理员身份运行 VMMap选择目标 Java 进程。界面会按类型列出 Image映像、Private私有、Heap堆、Mapped File映射文件等内存块的占比。重点看 Heap 和 Private 的持续增长趋势。注意这里的 Heap 是原生内存意义上的堆不是 JVM 堆。如果它一路往上走而jmap -heap看到 JVM 堆并不高就要怀疑是 JIT、DirectBuffer、JNI 这些堆外消耗。可以双击某个大内存块查看它对应的具体地址和大小配合后续的 Native Memory Tracking 进一步定位。Linux 环境的对应工具是pmap -x pid和cat /proc/pid/status里的 VmRSS思路完全一样先把内存大头按类型分出来再往下钻。5.3 Metaspace溢出动态代理和反射的副作用Metaspace 溢出值得单独说。JDK8 以后类元数据放 Metaspace默认不设MaxMetaspaceSize就只受本地内存限制。一个典型场景是不断用反射或者 CGLIB 生成代理类或者用 Groovy 动态编译脚本跑一段时间后 Metaspace 满了报java.lang.OutOfMemoryError: Metaspace。这种问题加-Xmx完全没用得检查代码里是不是有类加载器泄漏、动态生成类的缓存没有清理。这里又回到我们开头说的 JRE/JVM 关系类元数据属于 JVM 进程自己管理但类的来源是 JRE 核心类库、应用依赖和动态代理。你调的-Xmx只是堆Metaspace 是另一块地用错参数等于给错了区域浇水。5.4 用NMT把堆外内存拆开看排查堆外内存建议在启动参数加-XX:NativeMemoryTrackingsummary然后建立基线并查看jcmd pid VM.native_memory baseline jcmd pid VM.native_memory summary.diffNMT 能看到 Java Heap、Class元空间、Thread、GC、Compiler、Internal、Direct Buffer 等类别的内存占用比 jmap 的视角全得多。注意 NMT 本身有 5%-10% 左右的开销生产环境建议只开 summary不要开 detail。再一次强调堆内堆外要分开记账只看一半账本永远算不清内存去哪了。6. 参数怎么调才不翻车从HMCL这类启动器里的JVM参数说起6.1 “调参三板斧”为什么危险网上流传的“JVM调优三板斧”-Xms设成和-Xmx一样、-XX:UseG1GC、-XX:MaxGCPauseMillis50。这三板斧在某些场景是对的但直接照抄很容易翻车。MaxGCPauseMillis设太低G1 为了满足停顿目标会频繁发起 Young GC 和并发标记吞吐量反而下降XmsXmx对长期运行的服务有帮助但对短生命周期的批处理任务意义不大G1 也不是万能的在小堆1-2G上 G1 优势不明显ParallelGC 反而更简单直接。调参的思路应该是先明确你的约束是“延迟”还是“吞吐量”再选择回收器和堆布局。延迟敏感的服务牺牲一点吞吐量换停顿降低吞吐量敏感的任务允许偶尔较长停顿换更高的处理效率。6.2 按场景选参数的参考表下面这张表是我自己的默认选择供参考场景推荐配置理由在线接口服务延迟敏感XmsXmxG1MaxGCPauseMillis100~200避免堆伸缩控制停顿离线批量导出吞吐量敏感ParallelGC适当调大新生代减少GC次数允许偶尔较长停顿客户端工具/游戏启动器按实际内存分配XmxG1或默认GC减少卡顿且不挤占其他程序容器服务注意容器内存上限Xmx留出堆外余量防止OOM Killer误杀具体的数字比如机器 16GB 内存跑在线接口服务Xmx建议设 12G 左右留 2-3G 给 Metaspace、DirectBuffer、线程栈和操作系统。很多人上来-Xmx14G结果堆外一涨整个容器就跪了。6.3 客户端启动器里的JVM参数以HMCL为例像 HMCLHello Minecraft! Launcher这类启动器设置 JVM 参数的位置一般在“版本设置”或“全局游戏设置”里有一个“Java虚拟机参数”文本框可以填-Xmx4G、-XX:UseG1GC这类内容。很多玩整合包的朋友喜欢直接把网上抄来的参数填进去什么-Xmx8G、-XX:UseConcMarkSweepGC都往上堆。这里我给几条实在建议先看你机器物理内存有多大装了哪些 Mod 和光影再去定Xmx。8G 内存的电脑开-Xmx8G操作系统和浏览器直接没内存可用GC 也跟着卡。客户端场景内存给太多反而会卡因为 GC 停顿变长游戏帧数会抖动。我实测下来Mod 量中等的情况下-Xmx4G比-Xmx8G帧数更稳定。某些整合包或服务器管理工具里会出现类似builder: {gc: {defaultkeepstorage: 20gb, enabled: true}}的配置段看起来很高端但本质只是把一组 GC 相关参数比如堆预留、G1区域大小封装成了开关。看到这种配置先搞懂它翻译成的是什么 JVM 参数再确认你的机器内存够不够。在一台 8GB 的机器上开一个defaultkeepstorage20gb那不是调优是给自己挖坑。客户端场景和目标服务端不一样它更看重 GC 停顿对流畅度的影响。分配合理的堆大小、关掉不必要的附加功能比盲目堆参数有效得多。6.4 改完参数之后如何验证改完参数之后千万不要“感觉好了”就完事我吃过大亏——改了一个参数之后应用确实稳定了但后来发现其实是流量自然下降导致的跟参数没关系。现在我建立了一个固定验证闭环改之前用默认参数跑一轮压测或真实流量记录 FGC 次数、FGCT、YGC 次数和吞吐量。一次只改一个参数再跑一轮对比差异。同时改多个参数出了 Bug 你根本不知道是哪一项配置引起的。用 GC 日志分析工具GCeasy、GCViewer解析 GC 日志看暂停时间分布和吞吐量百分比。连续观察几天的运行曲线确认稳定后再动下一个参数。这也是为什么我一直强调启动参数里必须带 GC 日志-Xlog:gc*:gc.log -XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/data/logs/heap.hprofJDK11 之前的老版本用-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:gc.log。没有 GC 日志就没有对比的依据调优就成了玄学。这套闭环看起来繁琐但它是唯一能让你确定“这次优化真的有效”的方法。最后分享一个我自己的经验JVM 调优没那么玄大部分内存溢出和 GC 频繁问题的根因都在代码和数据量上真正需要调参数的情况反而是少数。我遇到内存问题时固定顺序是打开 GC 日志看现场再用 jstat、jmap 确认堆状态接着拉业务日志和数据库状态检查大对象、大集合、流没关、连接泄漏最后才动 JVM 参数。这套顺序帮我解决过不下十次线上事故。如果你现在正被某个内存问题折磨不妨先把-Xmx放一边打开 GC 日志看看是哪个区域在报警把那行代码揪出来。很多时候你会发现答案早就写在日志里了。
返回列表