ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JVM调优实录:从线上频繁OOM到平稳运行一年的排查复盘

JVM调优实录:从线上频繁OOM到平稳运行一年的排查复盘 做 Java 后端这几年JVM 调优和 OOM 排查是我最常被问到的硬骨头。去年我接手了一个已经线上跑了快三年的 RPC 服务高峰期每天处理上千万次请求但每隔一段时间就会报 OOM重启后好一阵子然后又复发。当时大家第一反应就是“加内存”可 8G 加到 16G 还是照崩不误明显不是堆开小了这么简单。折腾了将近两周把堆内存、GC 日志、线程栈全翻了个底朝天最后靠一套组合拳把问题压住系统稳定运行超过一年没再出过 OOM。这篇就把我当时的排查思路、五个实战案例和工具用法完整记录下来算是一个阶段性的复盘。这篇文章适合谁看如果你正准备 JVM 面试或者正在被线上 OOM、Full GC 频繁、接口偶发超时这类问题折磨可以参考我的定位方法。我不会堆一堆理论名词而是按真实排查顺序走一遍先看现象再抓现场再分析堆和日志最后落到代码和参数上。看完之后你至少能建立起一套自己的 JVM 问题诊断路径。1. 这次调优的背景和目标1.1 系统为什么非调不可我接手的这个服务是订单中心的核心 RPC 节点部署了 6 个实例每个实例 8C16G 的配置堆内存设定为 6G。业务上每天有订单创建、查询、对账、推送等多个动作高峰期 QPS 能到 800 左右。按理说这个体量不算离谱但问题是每两三天就有实例触发 OOM监控面板上 Full GC 次数常年居高不下接口 P99 延迟经常冲到 500ms 以上。最棘手的是 OOM 发生的时间点完全没有规律白天忙的时候崩半夜流量低谷也崩。重启以后能撑一阵但治标不治本。而且因为是主链路节点每次崩溃都会引发调用方重试风暴上游服务也跟着抖整个调用链的数据反馈很难看。这种状态下别说做性能优化连稳定交付都谈不上。所以当时定的目标很明确第一彻底解决频繁 OOM至少保证半年内不再出现第二把 Full GC 的次数和耗时压下来让接口延迟恢复正常第三沉淀一套可复用的排查流程后面团队其他人再遇到类似问题不至于抓瞎。1.2 用到的工具链和前置准备工欲善其事必先利其器。排查 JVM 问题工具选对了能省一大半时间。我这次用的工具基本都是 JDK 自带的命令行工具外加一个在线 GC 日志分析平台。具体如下jps查看 Java 进程获得进程 PID。jstat -gcutil实时观察 GC 情况和堆使用率定位 Full GC 频率。jmap -heap查看堆配置和分代使用情况。jmap -histo/jmap -dump统计对象分布必要时导出堆 dump。jstack抓取线程快照排查死锁、线程阻塞和调用来源。jcmdJDK 8 以后非常实用可以替代很多 jmap 的活儿比如jcmd PID GC.heap_dump。GCeasy在线工具上传 GC 日志就能自动生成报告直观看出停顿来源和参数建议。我在动手前还做了一件事确认所有实例都开了 JVM 参数-XX:HeapDumpOnOutOfMemoryError -XX:HeapDumpPath/app/logs/dump.hprof。这个习惯非常重要没有堆转储文件OOM 的排查就只能靠猜效率低到不想说话。如果你现在的线上服务没开这个参数建议先补上这是花一分钟能省一天时间的投入。1.3 需要先想清楚的一个问题调优之前我反复问自己一个问题这个 OOM 到底是“内存不够”还是“内存泄漏”这两个方向的解法完全不同。内存不够的典型特征是堆使用率有规律地波动高峰期逼近阈值过后回落改参数、改分配策略就有效内存泄漏则是某个对象只增不减最终必然触发 OOM光靠调参数只是把死亡时间往后推真正要做的是找到谁在堆积对象。这个判断方法很简单连续采集几天的jstat数据看老年代使用率曲线。如果是锯齿状但整体平稳那大概率是分配问题如果是一条持续向上的斜线那基本是泄漏。我这次遇到的三个 OOM 案例里两个属于泄漏一个属于瞬时分配过大原因不同处理路径也完全不同。2. 三个 OOM 案例的完整排查过程2.1 案例一堆内存 OOM定位到大对象分配风暴某天下午线上告警弹出java.lang.OutOfMemoryError: Java heap space节点直接挂掉上游重试导致同一时间其他节点压力骤增我赶紧登到测试机复现现场。首先用jps -l找到进程号然后用jstat -gcutil pid 1000观察 GC 情况发现老年代使用率几分钟内从 40% 冲到了 95%紧接着就是 Full GC然后 OOM。因为开了HeapDumpOnOutOfMemoryError我拿到了一份 4.2G 的堆转储文件。用jmap -histo:live pid先做了一次快速冒烟测试发现byte[]和char[]实例数量异常庞大。老实说光看 histogram 还不够我直接在 dump 里用 MAT 分析了 dominator tree找到最粗的那条引用链一个批量查询接口把一个月的数据全查了出来然后循环里对每个订单又发起了二次查询加起来一次请求就能产生几十万订单对象。问题的根子是 SQL 没有分页一个列表接口设计时支撑不了这个数据量。我当时在代码里做了一个快速修复把查询改成游标分页每批 500 条循环处理完一批就释放引用同时给方法入口加了参数校验超过最大查询范围直接拒绝。堆内存这块其实没改参数问题就解决了一大半。注意如果是从 dump 里定位大对象建议不要只盯着占比最大的类看,C还刑要往下钻引用链,看清楚是谁创建、谁持有的。很多时候 byte[] 只是表象,真正的锅在持有它的业务对象。2.2 案例二元空间 OOM类加载器泄漏第二个案例更隐蔽报错信息是java.lang.OutOfMemoryError: Metaspace。当时我第一反应很奇怪系统里没有大量的动态代理或者反射生成类怎么会把元空间打爆看 GC 日志发现元空间使用率一路从 80M 涨到 800M然后触顶崩溃。用jstat -gc pid重点观察 M 区和 MC 区之后我怀疑是类加载器泄漏。排查路径是这样的用jcmd pid GC.class_histogram看类数量发现有几万个GeneratedClass类再用jstack抓线程栈定位到一个定时任务线程在循环执行某些操作每次都会调用一个第三方工具库的表达式编译功能那货会创建新的 ClassLoader 来加载临时类但任务结束后 ClassLoader 没有释放。当时我直接写了一个小的复现脚本跑几万次表达式编译然后用jmap -clstats查看类加载器统计确认自定义加载器数量线性增长。修复方案分两步第一步把定时任务里那个工具库的实例改成单例复用避免每次都生成新 ClassLoader第二步升级第三方库版本新版提供了显式的清理方法。完全修好后元空间稳定在 350M 左右。这个案例提醒了我一件事不是只有热部署才会类加载器泄漏任何频繁创建 ClassLoader 的代码都可能踩坑特别是表达式引擎、动态代理、SPI 扫描这类场景。2.3 案例三直接内存 OOMNIO 的隐形杀手第三个案例出现在某个网关实例上错误是java.lang.OutOfMemoryError: Direct buffer memory。这个报错不能靠堆转储来分析因为它根本不是堆内存的问题而是 JVM 堆外的直接内存被耗尽了。当时第一反应是检查-XX:MaxDirectMemorySize默认值是堆大小6G 堆就会有 6G 的直接内存上限按理说不小但还是被干爆了。排查思路从 GC 日志转向了线程栈和业务代码。我用jstack抓了线程快照发现大量 IO 线程阻塞在从ByteBuffer读取数据的环节。继续追代码发现一个文件导出功能使用FileChannel读取文件并写入SocketChannel但是只调用了read没有在 finally 块中释放DirectByteBuffer。NIO 操作如果不显式调用((DirectBuffer) buffer).cleaner().clean()或通过BufferPoolMXBean监控GC 又无法及时回收堆外内存最终就会导致直接内存泄漏。修复就是把所有 NIO 操作包进一个统一的工具类确保ByteBuffer在 doFinally 中被正确清理。Netty 项目中对 DirectBuffer 的分配和释放做得好很多因为它有内存池管理但手写 NIO 的代码就得自己多操这份心。实操心得排查 DirectBuffer 泄漏时可以用jcmd pid VM.native_memory看内存占用分布也可以写一个小代码通过BufferPoolMXBean定时打印count和memoryUsed两个指标观察曲线就能定位到是哪个系统在吃内存。3. GC 优化实战从 ParNewCMS 到 G13.1 案例四Full GC 频繁导致接口超时OOM 问题解决后系统稳了不少但监控面板上的 Full GC 次数还是很扎眼。这个服务用的是 JDK 8老一代配置是 ParNewCMS 组合堆参数是-Xms6G -Xmx6G -XX:NewRatio2老年代 4G新生代 2G。Full GC 频繁的直接原因其实上一轮已经埋下缓存类对象没有被正确清理老年代被一些无效的会话对象占住无法回收的空间越来越多。排查时我用jstat -gcutil pid 1000 60连续观察了一分钟发现每次 Young GC 后都有对象晋升到老年代但老年代的使用率只升不降。再用jmap -histo:live看存活对象发现SessionInfo和LockObject类各有几十万实例。进一步查业务代码后发现某个批量处理接口在请求完成后没有把本地缓存 map clear 掉等于每处理一批数据就泄漏一批对象。修复代码后我又重新审视了 GC 参数做了两个非常关键的决定。第一-Xms和-Xmx保持一致避免 JVM 在运行中动态扩容时触发 Stop The World第二把 CMS 的参数-XX:CMSInitiatingOccupancyFraction从默认值调低到 60让 CMS 在老年代使用率达到 60% 时就开始并发回收而不是等到 92% 才姗姗来迟。这两个改动叠加代码修复Full GC 次数从每小时 10 次降到了每天 2-3 次。3.2 案例五升级到 G1把停顿压下去CMS 虽然 Full GC 少了但每次 Full GC 停顿都在 2-3 秒高峰期还是很要命。于是我决定把这个核心服务迁移到 G1 垃圾回收器这也是当时团队里争论比较大的一个决策。老实说不是所有服务都适合无脑上 G1但这个服务的特点是堆比较大6G、并发高、可停顿目标明确G1 的优势能发挥出来。迁移时我给 G1 定的参数组合是这样的-Xms6G -Xmx6G -XX:UseG1GC -XX:MaxGCPauseMillis100 -XX:G1HeapRegionSize4M -XX:InitiatingHeapOccupancyPercent45 -XX:G1ReservePercent10 -XX:ParallelRefProcEnabled -XX:HeapDumpOnOutOfMemoryErrorMaxGCPauseMillis100是目标停顿G1 会尽量把 STW 控制在 100ms 以内RegionSize4M是把堆切成 1500 个左右的小区域这个搭配对 6G 堆来说比较均衡InitiatingHeapOccupancyPercent45让 G1 在老年代用到 45% 时就开始混合回收避免等到堆快满了才启动ParallelRefProcEnabled把引用处理改成多线程对大堆很有帮助。上线后观察一周G1 的表现确实优于 CMSMixed GC 代替了大部分 Full GC停顿 P99 从 800ms 降到了 150ms 左右P95 基本在 50ms 以内。G1 也不是万能的有几个参数需要结合业务特点反复调后面我会在避坑部分细说。3.3 GC 日志到底该怎么看排查 GC 问题不会看日志等于瞎折腾。我当时为了保证日志完备在启动参数里加了这些-Xloggc:/app/logs/gc-%t.log -XX:PrintGCDetails -XX:PrintGCDateStamps -XX:PrintHeapAtGC -XX:UseGCLogFileRotation -XX:NumberOfGCLogFiles5 -XX:GCLogFileSize20MJDK 8 下用这套参数会生成滚动 GC 日志默认 20M 一个文件保留 5 份。看日志时我习惯抓三个关键指标Young GC 的平均停顿和频率、Old GC/CMS 并发阶段的耗时、Full GC 的触发点和堆使用率。最好用的方法是把滚动日志直接拖进 GCeasy它能自动算出来吞吐量、停顿占比还会给出参数建议。我后来很多参数调整都是拿它的建议做起点再结合业务验证。注意GC 日志本身也会占磁盘一定要配滚动和轮转不然故障没排完磁盘先被日志打满了。我见过不止一次生产环境因为 gc.log 无限增长导致磁盘满的案例。4. 常见问题排查与避坑技巧实录4.1 排查命令速查表这套命令我每次排查 JVM 问题都会用直接照着敲就行场景命令操作要点找到进程号jps -l比 ps -ef观察 GC 频率jstat -gcutil pid 1000 60每秒打印一次连续一分钟看 YGC/FGC 曲线看堆配置jmap -heap pid确认当前各分代当前值和最大值、各参数是否生效看对象分布jmap -histo:live pid | head -50触发一次 Full GC 后再统计剩下来的基本是活着的大对象导堆转储jcmd pid GC.heap_dump /tmp/dump.hprof比 jmap -dump 更推荐有些版本的 jmap 在压力下会卡顿抓线程栈jstack pid /tmp/thread.log多抓几次间隔 5 秒对比看线程状态变化看类加载器jmap -clstats pid排查元空间泄漏时必用看 loader 数量和占用空间4.2 必须避开的几个参数坑第一个坑是-Xms和-Xmx设置不一致。很多团队喜欢-Xms1G -Xmx6G理由是省启动内存但代价是运行中 JVM 要反复扩展/收缩堆内存这个过程伴随着 STW高峰期搞一次能让你直接看到接口超时报警。强烈建议生产环境-Xms和-Xmx设置成一样。第二个坑是无脑调大-XX:MaxMetaspaceSize。元空间 OOM 的根本原因通常是类加载器泄漏把上限调大只会让问题变得更隐蔽最后可能变成操作系统内存不够。正确姿势是先查 loader 泄漏再考虑调参数。第三个坑是 G1 的MaxGCPauseMillis设得越小越好。这个参数是软性目标G1 为了达到它可能会激进地增加混合回收的频率结果 CPU 消耗上升、吞吐量下降。建议从 100ms 起步观察后逐档调不要直接写到 10ms。第四个坑是把-XX:PrintGCDetails和-XX:PrintGCDateStamps当成永久参数开着。生产环境日志很重要但必须配合UseGCLogFileRotation否则日志文件会失控。4.3 我踩过的几个真实的大坑调优过程中我犯过几次错写下来帮大家绕路。第一次是在没有对全链路流量做回归压测的情况下直接改了 G1 的并发线程数-XX:ConcGCThreads结果高峰期 GC 线程和业务线程抢 CPU接口延迟反而更差。后来我在压测环境完整跑了两轮全链路性能测试才把并发线程数调到配置。第二次是很蠢的错误我在线上看jmap -histo的时候因为文件太大直接把输出重定向到了/tmp/结果磁盘被写满了引发了连锁告警。排查 JVM 问题时的副产品也要注意清理别没解决老问题又搞出新事故。第三次是误判了问题归属。有个实例频繁出现 OOM我在堆里查来查去查不到明显的业务对象堆积后来发现是某第三方 SDK 内部的连接池没有设置最大连接数连接对象一直堆积。第三方的坑往往最隐蔽排查时不要先入为主觉得自己的代码没问题每一层调用链都可能埋雷。还有一个经验就是调优前后做对比要保存基线数据。我在这次优化中坚持每日记录堆使用率、GC 次数、FullGC 耗时、P99 延迟四个指标后面复盘时才能清楚知道每一步改动到底是正收益还是负收益。不然今天改一个、明天改一个出了效果都不知道是哪个参数起了作用。5. 这套方案上线后的运行复盘与个人体会5.1 一年运行下来的关键数据从调优完成到现在这个订单中心服务已经稳定运行超过一年。有据可查的几个指标变化如下指标优化前优化后OOM 次数每 2-3 天一次0 次Full GC 次数每天 100约每周 1-2 次Mixed GC 停顿 P99800ms200ms 以内接口 P99 延迟500ms120ms 左右CPU 使用率高峰期 85%高峰期 65%这个结果不是我一个人的功劳代码修复和团队配合缺一不可但至少证明了这套排查路径是有效的先看现象快速抓取现场然后借助 jstat/jmap/jstack 缩小范围最后落到代码细节或参数配置上修复。5.2 调 JVM 参数之前先把代码问题扫干净很多人一上来就调参数觉得换个垃圾回收器、调大堆内存就完事了。但我的经验恰恰相反如果业务代码存在问题参数只能暂时掩盖症状。这一点在案例一里体现得很明显分页查询改完以后堆内存参数其实没怎么动OOM 却彻底消失了。调参只是手段代码健康才是根本。给新人的建议是遇到 JVM 问题先从代码侧排查三件事是不是一次性加载了不该加载的数据是不是某个集合只增不减是不是第三方组件或线程池没正确释放资源代码层没问题再来谈垃圾回收器选型和参数优化。5.3 后续还能怎么扩展这套方法这套排查思路不只适用于这个订单中心服务我在团队里做成了标准化文档后面还推广到了几个核心网关和支付服务上。每个服务的基本盘都做同样的事开堆转储、开 GC 日志、建监控看板、定期复盘 GC 数据。再配合压测环境提前演练 OOM 场景比出了问题再加班排查不知道高到哪里去了。如果你正在维护的系统也存在类似问题建议从今天开始做两件小事第一确认所有生产 JVM 都开了HeapDumpOnOutOfMemoryError第二手动执行一次jcmd pid GC.heap_dump确认你有权限、有磁盘空间、有分析工具能打开 dump。这两步走完你就已经比 90% 遇到 OOM 只能重启的人强了。我在实际排查中最大的体会是JVM 调优不是玄学而是一套基于现场证据的推理过程。OOM 和 GC 问题再复杂只要能留下现场按照固定的命令和步骤一步步缩窄范围最终都能落到具体代码或参数上。这套方法让我从原本面对 OOM 只会重启的状态变成了能冷静分析、快速定位的人。希望这篇记录对你也有同样的帮助。
返回列表