JVM GC调优实战:从Full GC频发到性能平稳的完整复盘 做Java开发这些年我每一次认真研究垃圾回收调优几乎都是被线上Full GC和飙升的接口延迟逼出来的。上周又遇到一次典型的JVM性能瓶颈一个订单服务从P99 80ms一路涨到2.3秒老年代曲线像坐了火箭Full GC从半小时一次变成两分钟一次。这次我没有急着加机器而是打开GC日志按照一套固定流程定位问题、调整参数、验证效果最后让服务恢复平稳。这篇文章就是那套流程的完整复盘从JVM内存模型和垃圾回收底层原理讲起再到调优参数设计和真实线上排查案例适合正在被高延迟、Full GC折磨的Java工程师也适合面试时被问到JVM调优你做过什么却不知道如何组织答案的同学。1. 先找准病根GC调优前必须吃透的JVM内存模型1.1 对象在堆里如何流动新生代、老年代与元空间的分工很多人调JVM参数之前连一次GC日志都没看过上来就改-Xmx、改-XX:MaxGCPauseMillis结果调完比不调还糟糕。原因很简单你连对象在堆里是怎么流动的都不清楚参数改得再花哨也只是碰运气。JVM的堆内存主要分两大块新生代和老年代。新生代里又分为一个Eden区和两个Survivor区S0和S1默认比例是8:1:1。绝大多数new出来的对象会先落在Eden区如果Eden区放不下大对象可能直接在老年代分配。Eden区快满时触发Young GC活下来的对象进入Survivor区并且年龄加1。每经历一次Young GC存活对象的年龄加1当年龄达到阈值默认15但JVM会根据实际情况动态调整或者Survivor区装不下时对象就晋升到老年代。大对象比如那些很大的数组为了避免在Survivor区反复复制也会直接进入老年代。元空间不在堆内主要存储类元信息和方法数据默认受本地内存限制如果这块区域耗尽会报Metadata space溢出的错那属于另一类问题。为什么要把堆分成这么细因为有研究数据表明绝大部分对象的存活时间非常短可能创建出来没几个毫秒就变成垃圾。用两个Survivor区配合复制算法可以高效地把这些短命对象清理干净只保留少数长命对象进入老年代。老年代里的对象存活率高如果也用复制算法复制成本太高所以老年代普遍采用标记-整理或者并发标记的方式处理。还有一个常见误区JRE和JVM不是一回事。JRE是Java运行环境包含JVM、核心类库和启动命令JVM是JRE内部负责执行字节码、管理内存的部分。垃圾回收只是JVM内存管理的一个环节但恰恰是最影响运行表现的部分。1.2 标记、清除、复制与整理三种基础算法如何配合GC要做的事情本质上是回答两个问题哪些对象还活着哪些对象已经可以被回收判断存活的基础是从GC Roots出发做可达性分析。GC Roots包括线程栈上的局部变量、静态字段引用、JNI引用等凡是能从这些根出发到达的对象都算活的。三种基础算法各有取舍标记-清除先标记可达对象再清除不可达对象。实现简单但会产生内存碎片。碎片一旦变多分配大对象时触发老年代GC的概率就会升高所以我们很少在Java堆里完全靠这种算法打天下。标记-复制把存活对象复制到另一块区域然后整体清空原区域。新生代经常用这种方式效率高、没有碎片代价是浪费一部分空间Survivor区本来就是留一块用来复制的。标记-整理把存活对象向一端移动并整理出连续空间老年代常用。没有碎片问题但移动对象本身有开销而且同样需要STW。不管哪种算法GC的关键瓶颈都在于Stop The World。为了准确记录对象引用关系GC时必须暂停应用程序线程这是物理层面的约束不是哪个回收器能完全绕开的。所谓调优不是消灭STW而是把STW的频率和单次时长控制在业务能接受的范围里。理解到这层再看网上那些参数贴子就会清醒很多堆开多大、Survivor比例怎么设、晋升阈值改不改本质上都是在调整短命对象的清理成本和长命对象的晋升速度之间的平衡。1.3 用数据说话jstat、jmap和GC日志的使用门槛调优的第一个动作是收集数据不是改参数。JDK自带的工具完全足够完成这一步。先找到Java进程PID用jstat看实时GC情况jstat -gcutil 12345 1000输出里S0、S1、E、O分别是Survivor区、Eden区和老年代的使用百分比YGC和YGCT是Young GC次数与总耗时FGC和FGCT是Full GC次数与总耗时。如果O列持续在90%以上、怎么都降不下来基本可以断定老年代压力异常Full GC很快会来。想看堆整体配置用jmap -heap 12345能看到新生代、老年代当前容量和参数。想看正在运行的JVM到底生效了哪些参数用jinfo -flags 12345。GC日志建议从一开始就打开。JDK 9以后统一采用-Xlog参数例如java -Xlog:gc*:file/opt/app/logs/gc.log:time,uptime,level:filecount5,filesize20m -jar app.jar这段的意思是记录所有gc相关日志输出到指定文件最多保留5个文件、每个20MB。上线时就把这行写进启动脚本等出问题的时候才不至于两眼一抹黑。我见过不少团队线上服务GC日志没开等Full GC把服务打挂之后才发现拿不到任何现场信息只能重启了事。Java垃圾回收调优的第一步永远是建立可观测性。没有数据后面所有操作都是瞎猜。2. 垃圾回收器怎么选主流回收器的设计逻辑与场景边界2.1 主流回收器各有各的脾气从Serial到ZGC不同JDK版本默认回收器不一样。很多人还在用JDK 8默认是ParallelJDK 9以后默认切换成G1。如果不了解默认行为直接套网上那些XX参数调优很可能南辕北辙。Serial是最朴素的单线程回收器适合客户端程序、单核小内存场景现代后端基本不碰。Parallel-XX:UseParallelGC是JDK 8默认的吞吐量优先回收器Young GC用多线程复制Old GC用多线程标记-整理。它的设计目标是最大化吞吐量不太在乎单次停顿时间适合后台批处理、离线计算这类对延迟不敏感的任务。我自己跑离线报表任务时用Parallel反而比G1更稳。CMS-XX:UseConcMarkSweepGC在JDK 9被标记废弃、JDK 14被移除。它的初衷是减少老年代GC的停顿用并发标记代替大部分串行标记但引入了浮动垃圾和严重的内存碎片问题最终被G1取代。如果你维护的旧系统还在用CMS建议尽早规划迁移到G1或ZGC。G1是JDK 9之后的默认回收器核心是把堆划分成很多小块Region根据每个Region里垃圾的比例动态决定优先回收哪些Region。它提供了可预测的停顿时间模型通过-XX:MaxGCPauseMillis给出目标停顿G1在每次回收时挑选回收收益最大的Region集合。我用G1处理过不少4G-16G堆的服务整体调节空间比Parallel大很多也是目前后端最主流的选项。ZGC是面向超大堆低延迟场景的回收器目标是让STW控制在10毫秒以内而且停顿时间基本不随堆大小增长。它使用染色指针和读屏障实现并发转移代价是需要额外的CPU开销。如果机器核数富裕堆又有十几个G体验会很好。不同回收器的适用场景差异很大我用下面这张表做个总结回收器设计目标适用场景明显缺点Serial简单单线程客户端、小堆单次停顿久Parallel吞吐量优先批处理、离线计算单次停顿不可控CMS减少老年代停顿已淘汰旧系统残留碎片、浮动垃圾G1可控停顿兼顾吞吐JDK 9默认通用后端服务参数和调优逻辑复杂ZGC超低延迟大堆在线服务、低延迟要求CPU占用偏高2.2 先定目标再选回收器吞吐量和低延迟怎么权衡这里的权衡不是绝对二选一但确实需要按业务权重取舍。吞吐量的意思是百分之多少的CPU时间花在处理业务上计算公式大致是有效运行时间 /有效运行时间 GC时间。低延迟看的是每次GC造成的停顿有没有让请求变慢、超时。如果是凌晨跑批处理、日志清洗、离线报表优先级显然是吞吐量Parallel往往比G1更合适。如果是用户能直接感知的接口服务目标是P99稳定那G1或者ZGC是更合理的选择。选完回收器对应的参数体系也不一样。Parallel系列调优主要看堆大小和代际比例G1调优重心是停顿目标、Region大小和新生代占比范围ZGC的调优空间相对小重点是把堆初始值和最大值设一致、避免动态扩容然后交给它自动管理。2.3 换回收器前先确认GC到底是不是瓶颈有一类项目GC看起来频繁但实际上罪魁祸首是代码里的锁竞争或者数据库慢查询。如果不做判断盲目把Parallel换成G1甚至ZGC业务延迟该高还是高只会留下一个调过JVM也没用的错误印象。我的判断方法很朴素打开GC日志算两笔账。第一步看GC总耗时占比。假设服务运行了30分钟Young GC累计耗时3秒Full GC耗时1秒GC总占比约0.2%那GC绝对不可能是主要瓶颈。第二步看GC发生的时机。如果每次P99飙高的时间点都伴随YGC那要关注单次停顿和分配速率如果和YGC没有明显对应更值得查的是线程池、锁或下游依赖。只有当GC总时间占比明显偏高比如超过5%或者单次停顿已经超过业务容忍线才值得进入调优环节。否则调优还没开始方向就已经错了。3. 调优参数如何落地一套能直接抄作业的流程与验证方法3.1 第一步把基线数据留下来启动参数怎么打我见过最普遍的问题是没有基线。启动脚本里没有GC日志监控面板上也没有堆指标等压测完或者线上故障发生时只能凭记忆说好像是GC的问题。正确做法上线或者压测前先在启动脚本里加上完整的观测参数java -Xms4g -Xmx4g -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -Xlog:gc*:file/opt/app/logs/gc.log:time,uptime,level:filecount5,filesize20m \ -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/opt/app/logs/ \ -jar order-service.jar这里有两个非常关键的生产参数-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath。一旦OOMJVM会自动把当时的堆快照落盘后面分析内存泄漏全靠它。没有这两行OOM时你连现场都找不到。跑一段时间压测或观察线上后记录三个基础数据Young GC频率和单次停顿、Full GC频率和单次停顿、老年代占用曲线。这就是调优前的基线后面任何一次参数变更都要拿它来对比。3.2 第二步按观察拆分两类场景针对性调参场景A接口服务堆4GQPS高YGC每隔几秒一次每次20ms左右Full GC偶尔出现但单次停顿1秒以上。分析下来要么是对象分配速率太高要么是新生代空间偏小。可以先微调新生代占比给G1的年轻代更多动态空间-XX:G1NewSizePercent10 -XX:G1MaxNewSizePercent60注意G1默认新生代范围是5%到60%大部分情况不需要手动指定。如果观察发现新生代总是被压得很小才触发YGC可以试着调高G1MaxNewSizePercent让G1在每次分配时有更多年轻代可用。但不要一上来就改先判断是不是对象分配速率过高。如果YGC后大量对象快速晋升老年代表现为老年代占用曲线被反复冲高那要关注晋升阈值。G1的年龄阈值默认是动态调整的手动指定-XX:MaxTenuringThreshold意义有限。真正有效的是从代码层面检查是不是有频繁出现的临时大集合、字符串拆分拼接、或者那些生命周期比预期长很多的短命对象。场景B缓存型服务堆20G老年代常年高位Full GC一次3秒。分析下来堆太大导致GC时STW时间过长可能是缓存设计不合理或者对象生命周期确实很长。这种场景我不会尝试把G1调成永远不Full GC而是直接评估换ZGCjava -Xms20g -Xmx20g -XX:UseZGC \ -Xlog:gc*:file/opt/app/logs/gc.log:time,uptime,level:filecount5,filesize20m \ -jar cache-service.jarZGC的优点是哪怕堆涨到几十G它依然能把STW控制在很低的范围。代价是GC线程会占用一些CPU如果机器只有4核ZGC未必合适。每个项目差异很大参数只能作为起点。我的原则是一次只改一个变量。改多个参数遇到问题你根本没有办法定位是哪一步导致了恶化。3.3 第三步验证效果别用平均值骗自己调完之后不能只看Full GC好像少了要看业务指标和GC指标是否同步改善。JDK 11自带JFRJava Flight Recorder启动时加一行-XX:StartFlightRecordingfilename/tmp/app.jfr,duration60s或者线上动态启用jcmd 12345 JFR.start nametest filename/tmp/app.jfr duration60s跑完用JFR看GC暂停时间趋势。我更推荐直接在压测里做一组对照组调优前压测10分钟记录P99、YGC次数、FGC次数、GC总耗时调优后同样流量压10分钟再对比。假设压测前GC总耗时占运行时间8%P99 200ms调优后GC总耗时降到3%P99回到120ms这才是有效的调优。如果GC指标改善了但业务P99反而变差说明你优化的方向和业务瓶颈不完全一致要继续查代码逻辑、锁、数据库。4. 线上Full GC高发案例复盘从告警到根因的完整链路4.1 现象与初判GC日志里藏着最直接的线索案例背景一个订单查询服务8C16G堆设了6G用了G1平时P99在80ms左右。某天上午10点运维告警接口P99涨到2.3秒Full GC频繁服务接近不可用。我先做的不是看代码而是看GC日志。日志里有大段这样的内容[Full GC (Allocation Failure) 5445M-5340M(6144M), 1.45s]关键信息是Full GC之后堆从5445M只降到5340M一次Full GC总共才释放了100M。老年代几乎纹丝不动这是典型的内存占用性上涨而不是临时流量冲击。如果是流量冲击Full GC后老年代应该明显下降因为短命对象已经被清理了。接着看jstatjstat -gcutil 12345 1000O列在97%左右FGC从一次变成每两分钟一次FGCT仍在增长。同时在监控里确认QPS没有暴增、最近没有上新版本。到这里我的初步判断已经偏向存在某种对象被长期持有导致老年代只升不降。下一步就是找到谁在持有这些对象。4.2 堆转储与MAT分析把疑似内存泄漏钉死到具体代码对一个生产服务做堆转储要谨慎因为dump瞬间可能影响线上。我的建议是优先在压测环境复现或者选择低峰期操作并且使用jcmd代替jmapjcmd 12345 GC.heap_dump /tmp/heap.hprof拿到hprof文件后用Eclipse MAT打开。先看Leak Suspects报告MAT会根据支配树找出占用堆最大的对象网络。在这个案例里报告指向一个HashMap占了整个堆的82%。顺着Dominator Tree往下钻找到一个叫OrderCache.instance的静态Map里面存了近千万条订单实体对象。这个Map本意是减少数据库查询但写入时没有容量限制也没有过期策略。订单数据每多一单就往里放一条老年代被这些对象逐渐塞满最终触发频繁Full GC。这里分享一个MAT实用技巧如果Leak Suspects不够直白可以使用OQLObject Query Language直接查询SELECT * FROM INSTANCEOF com.example.Order然后用MAT的List objects - with incoming references一层层往上看是哪个类引用它。这种逆向追踪方式在排查生产环境堆时非常管用。4.3 修复方案与上线后验证缓存也要有寿命根因是缓存设计问题跟JVM参数其实没有直接关系。修复动作有两个层面代码层面把无界Map换成Caffeine本地缓存限制最大条数和过期时间JVM层面保留G1和原有的6G堆不再做额外调参。Caffeine示例CacheString, Order orderCache Caffeine.newBuilder() .maximumSize(100_000) .expireAfterWrite(Duration.ofMinutes(30)) .build();上线后跑了一轮压测Full GC消失老年代稳定在2G以下YGC照常发生但单次停顿在20ms左右接口P99回到85ms。之后连续监控一周FGC次数为0堆占用曲线呈现健康的锯齿状涨上去又降下来问题才算真正关闭。这个案子给我的教训是很多被误判为JVM参数问题的线上故障根因都在应用代码。调参只是止血找到并修复对象持有的源头才是根治。5. 我踩过的调优误区和四条经验建议5.1 误区一堆越大越好、参数越激进越好堆从4G调到8G看起来YGC频率降低了但单次停顿可能从500ms变成1.2秒接口超时反而更严重。因为单次STW会直接卡住请求线程堆越大每次GC要处理的对象越多停顿时间通常也更长。合理堆大小要考虑对象分配速率、存活对象总量和GC停顿目标。与其盲目扩堆不如先清理无用对象。还有一个常见操作在代码里调用System.gc()希望主动触发一次GC来释放内存。这通常适得其反。System.gc()只是建议JVM执行Full GC不保证立即执行而且显式Full GC往往造成不必要的停顿。RMI、NIO等框架可能隐式调用这也是为什么有些团队会加-XX:DisableExplicitGC来屏蔽显式调用。加之前要先确认不影响业务逻辑尤其要确认第三方库是否依赖System.gc()来腾内存。5.2 误区二把没有GC当目标忽略长尾延迟有人在压测后开心地告诉我我把FGC调成了0但YGC每秒钟都在发生单次停顿虽然小GC总耗时却很高。目标应该是让GC停顿不影响用户体验不是追求0次GC。只要程序一直在new对象GC就不可能消失也没必要消失。还有一类错误是只盯平均值。平均停顿100ms看起来很优秀但P99.9可能已经到5秒。长尾延迟对用户体验的杀伤力远大于平均值。调优前后我建议都打开百分位指标专门看P99和P99.9而不是只看平均耗时。5.3 我的四步调优心法先量化、再定位、后调参、终验证这四个词的顺序不能反。量化——GC日志必须开jstat定期记录最好接上监控大盘定位——根据老年代曲线、FGC日志和堆转储回答是内存泄漏、晋升过快还是堆太小调参——一次只动一个参数改完记录变更验证——用压测数据和业务指标确认收益不行就回滚。日常开发中我给团队的建议更简单缓存一定要设上限大循环和批量任务里不要创建大量临时对象能用基本类型就别装箱日志框架尽量用异步减少对象拼接。这些代码习惯本身就是在给GC减负。最后说一句面试相关的观察。被问到JVM调优你做过什么最容易得高分的回答不是报参数名而是按现象-分析-定位-调参-验证讲一个真实案例。哪怕你只是调大过新生代也比干巴巴背一百个-XX参数强因为面试官想看的就是你有没有完整的问题排查闭环。我在实际项目里调完GC参数之后一定会再做一件事把当时的GC日志、参数变更和压测结果归档写一段为什么这么调的说明。下次再遇到类似问题或者线上出了新变化这份记录能帮全组少走很多冤枉路。JVM调优没有银弹但有了数据和思路性能瓶颈基本都能被一层层剥开。