
做Java后端开发的人迟早会在线上环境撞上“GC优化”这堵墙。我第一次接手GC调优时以为就是搜一堆参数抄上去比如把堆内存调大、把回收器换成G1结果线上频繁Full GC的问题不但没解决反而多了好几个参数警告。真正做下来才明白GC优化是一个“定位问题、设定目标、小步调整、反复验证”的循环核心不是背参数而是理解JVM垃圾回收器在你的业务场景里到底卡在哪。这篇文章我会用一次真实的线上调优经历为主线把这个过程完整拆开讲为什么先看指标而不是改参数、JVM内存模型和主流垃圾回收器怎么选、GC日志怎么读、Full GC频繁要怎么排查。内容适合Java后端开发、一年以上工作经验正被线上性能问题困扰的人也适合准备系统学习JVM调优、想避开常见坑的同学。我不讲教科书里那种面面俱到的理论只说实际项目中真正用得上的东西。1. 调优开始前先想清楚这三个问题1.1 你要的到底是吞吐量还是低延迟GC优化里最核心的两个指标是吞吐量和停顿时间STWStop The World。吞吐量指的是业务线程执行时间占整段时间的比例停顿时间指的是垃圾回收过程中业务线程暂停的时长。这两个目标天然冲突想让系统每秒处理更多请求回收器就要在“集中时间干活”和“频繁小步干活”之间做取舍。举个例子你就明白了。Parallel Scavenge回收器的设计目标就是高吞吐它会尽量让GC少发生几次但每次GC停顿时间可能略长。G1的设计目标则是可预测停顿它会把堆切成很多个小Region每次只回收一部分区域从而把单次停顿控制在你设定的目标范围内。做批处理、离线计算这种对响应时间不敏感的任务选Parallel没问题但线上Web接口、RPC服务这种用户实时调用的场景你更该关注TP99和长尾延迟这时G1甚至ZGC更合适。所以我做GC优化的第一件事永远不是打开搜索引擎抄参数而是问业务方一个问题这个接口的响应时间上限是多少能接受几次几百毫秒的卡顿如果对方说“接口平均50ms但偶尔一次1秒也忍不了”那你的优化方向就是降低单次GC停顿如果对方说“晚上批量跑任务慢一点没关系但总量必须在2小时内算完”那你的方向是高吞吐。1.2 默认参数跑得好好的就别折腾GC优化很多项目其实不需要主动做GC调优JVM在绝大多数场景下的默认参数已经够用了。强制调优反而可能引入新问题。我在很多团队见过一种现象项目刚上线监控面板看起来一切正常但有同学看了一篇“JVM调优实战”文章就动手把-Xmx从默认值调大、又加了一堆CMS参数结果第二天线上频繁Full GC。原因很简单默认参数是根据硬件资源和通用场景压测过的你机器内存只有4G堆设成3G老年代又配了一堆并发参数系统反而会频繁触发垃圾回收甚至直接OOM。那什么时候才需要动手我的判断标准是出现以下三个迹象之一Full GC频率明显异常。比如过去1小时只有几次现在每几分钟就一次。单次GC停顿时间过长直接影响了接口超时和调用方反馈。堆内存使用率持续涨高用jstat看老年代一直接近上限回收后下降很少。在动手前一定要用数据确认问题的存在。运维面板上的CPU曲线、接口TP99曲线、GC日志里的Full GC计数这些都是证据。没有证据就改参数和蒙着眼修车没有区别。1.3 一次只改一个参数改完就观察这是我踩过最深的坑。刚学调优时我习惯一次性把-Xms、-Xmn、-XX:MaxTenuringThreshold、-XX:UseG1GC这些参数全部改掉然后上线验证结果系统确实恢复了。但如果你问我到底是哪个参数发挥了作用我完全答不上来。更麻烦的是如果优化后性能反而变差你都找不到回滚的依据。正确做法是每次只改一个变量剩下的全部保持不变然后观察至少一天或一个业务峰谷周期。逻辑很简单GC行为是多个因素共同作用的结果你同时动了堆大小和晋升阈值某次老年代回收变频繁了根本没法确定是堆太小还是晋升太快导致的。做过几次这样的“单变量实验”后你会建立起一套自己的敏感度直觉哪种参数对这个业务场景影响最大哪种参数改了等于没改。提示调优前一定要记录基线数据至少包括GC频率、单次GC耗时、接口TP99、CPU使用率。没有基线你后面所有“验证优化有效”的结论都站不住脚。2. JVM内存模型与垃圾回收器选型得先搞懂再动手2.1 堆内存怎么划分对象是怎么一步步变“老”的JVM的堆内存Heap分三块新生代Young Generation、老年代Old Generation以及JDK 8起用来存放类元信息的元空间Metaspace。新生代内部又分成一个Eden区和两个Survivor区这两个Survivor一般叫S0和S1也叫From和To。你可以把Eden区想象成一个新生婴儿室绝大多数新建对象都先在这里出生。当Eden区满的时候JVM会触发一次Minor GC把仍然存活的对象拷贝到S0区清空Eden。下一次Minor GC时会把Eden和S0里存活的对象一同拷贝到S1区同时对象年龄加1然后清空Eden和S0。如此反复每经过一次Minor GC存活对象就在S0和S1之间“倒腾”一次年龄加一。当对象年龄超过-XX:MaxTenuringThreshold设定的阈值常见默认值是15CMS默认是6时它就会被晋升到老年代。理解这个机制极其重要因为GC优化的本质是控制对象的去向和回收成本。如果大量短命对象没能在Minor GC中被清掉而是很快晋升到老年代老年代就会迅速膨胀最终触发Full GC。线上服务Full GC频繁最常见的根因之一就是对象晋升速度太快老年代根本来不及回收。另外还有一种情况大对象比如大数组、大集合会被JVM直接放入老年代因为在新生代倒腾大对象成本太高。JVM提供-XX:PretenureSizeThreshold参数控制这个阈值但更关键的是写代码时尽量避免频繁创建超大对象。2.2 主流垃圾回收器对比别只会选G1GC优化的选型决定了你后续所有参数调整的大方向。我用一张表把主流垃圾回收器整理出来方便你对照业务场景选择回收器设计目标线程模式适用场景备注Serial单线程、低内存占用单线程STW客户端程序、小堆内存几乎不在服务端用Parallel Scavenge Parallel Old高吞吐量多线程STW批处理、离线计算、后台任务JDK 8默认回收器CMS低停顿并发标记清除少量STWWeb后端、注重响应时间JDK 9起废弃JDK 14移除G1可预测停顿时间分区化多线程部分并发大堆内存4G以上、通用服务端JDK 9起默认回收器ZGC极低停顿毫秒级并发回收染色指针超大堆几百GB、金融交易等极端低延迟JDK 15起逐渐成熟Shenandoah极低停顿并发回收超大堆、低延迟OpenJDK集成部分厂商未默认很多人一上来就换G1理由是“G1是默认的肯定最好”。但JDK 8默认的Parallel Scavenge在老年代回收任务压力不大时吞吐量表现并不会比G1差。G1的优势在于堆很大超过4G甚至几十G并且业务对停顿时间敏感。因为G1把堆划分成一个个大小相等的Region默认约1M到32MGC时会优先回收垃圾最多的Region通过这种增量回收方式把单次停顿控制在目标范围内。如果堆只有1G到2G业务又不复杂用Parallel Scavenge照样很稳。真正选回收器的时候应该先看你的堆内存规模、业务对响应时间的敏感度、以及团队对某一回收器的熟悉程度而不是随波逐流。2.3 核心参数逐个讲清楚网上抄的配置到底是什么意思参数看不懂改起来就没有底气。我列几个最常用、也最影响GC行为的参数参数作用经验值/建议-Xms/-Xmx初始堆大小 / 最大堆大小服务端建议设为相同避免运行期动态扩容-Xmn新生代大小建议占堆内存的1/3到1/4过大过小都有坑-XX:NewRatio新生代与老年代比例默认2即老年代占2/3新生代占1/3-XX:SurvivorRatioEden与单个Survivor的比例默认8即Eden占新生代的8/10S0/S1各占1/10-XX:MaxTenuringThreshold对象最大年龄超过则进入老年代默认值因回收器而异通常15或6-XX:PretenureSizeThreshold超过该大小的大对象直接进入老年代需要实测不要盲目设置-XX:MaxGCPauseMillisG1的停顿时间目标默认200ms按业务可接受值设置-XX:G1HeapRegionSizeG1 Region大小默认由堆大小自动推导一般不用手调-XX:ParallelGCThreadsGC并行线程数默认以CPU核数为依据一般不动以-Xmn为例假设堆内存固定在4G你把新生代设成2GEden区就有约1.6G按SurvivorRatio8老年代还剩2G。新生代大Minor GC间隔变长但每次停顿时的存活对象拷贝量也可能变大老年代小Full GC可能更早到来。新生代设成1GMinor GC会更频繁但每次停顿时间更短。没有绝对的对错只有是否匹配业务对象分配速率。JDK 9之后GC日志参数也变了旧版的-XX:PrintGCDetails -XX:PrintGCDateStamps -Xloggc:/path/gc.log已经被统一日志系统替代。新版建议直接用-Xlog:gc*:file/data/logs/gc-%t.log:time,uptime,level,tags。网上老文章常写的CMS相关参数比如-XX:UseConcMarkSweepGC在JDK 14之后直接不识别复制过来是会出事的。2.4 参数组合背后的真实效果上面那些参数从来不是独立生效的它们组合在一起决定了对象在堆里的“流动速度”和“回收节奏”。举个例子。假设你设了-Xms4g -Xmx4g -Xmn2g -XX:SurvivorRatio8那么新生代2G里Eden约1.6GS0和S1各约200M。新建对象大多从Eden分配Minor GC时Eden中存活对象会被拷贝到S0如果S0放不下多出来的部分会提前晋升到老年代。接下来你设了-XX:MaxTenuringThreshold5那么对象只要在Survivor区熬过5次Minor GC第6次就进入老年代。这样组合起来你会看到新生代的回收效率取决于对象存活率与Survivor容量的匹配度。如果很多对象生命周期刚好比Minor GC周期长一点点但又不到5次Minor GC那么长那么它们会反反复复在Survivor区里倒腾占着内存不放压缩了Eden的空间反而抬高了Minor GC频率。遇到这种情况对应的解法不是盲目调大堆内存而是把SurvivorRatio调大比如从8改到16给Survivor更多空间缓冲这些“中间寿命”对象或者适当调低晋升阈值让它们早点进老年代反正老年代的回收器可以并发处理。还是那句话参数本身不神秘神秘的是它们组合后在你的业务负载下产生的行为。3. 实操流程一次真实的全链路GC调优3.1 调优第一步把GC日志完整记录下来没有GC日志你连问题长什么样都看不清。我接手任何项目的Java服务第一件事就是确认启动参数里有没有打开GC日志。这一步成本极低收益却非常大。启动参数参考这样写JDK 8及以前版本java -Xms4g -Xmx4g -Xmn2g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -XX:PrintGCDetails \ -XX:PrintGCDateStamps \ -Xloggc:/data/logs/gc-%t.logJDK 9及以后版本统一日志格式会更清晰java -Xms4g -Xmx4g -Xmn2g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis100 \ -Xlog:gc*:file/data/logs/gc-%t.log:time,uptime,level,tags这里为什么要加-XX:PrintGCDateStamps因为它会在日志里打印具体的时间戳方便你对比业务高峰和GC频率的对应关系。不加的话日志里只有JVM启动后的秒数你得自己换算非常麻烦。日志文件名里的%t是启动时间这样每次重启都会生成新文件不会覆盖旧日志方便反复对比。注意GC日志本身是有性能开销的但在生产环境记录GC日志的代价远小于出问题时两眼一抹黑的代价。保留最近几天日志量并不大建议长期开启。3.2 读懂GC日志里的四个关键信息打开GC日志后你会看到大量类似这样的内容2025-01-10T10:22:33.1230800: 12345.678: [GC (Allocation Failure) [PSYoungGen: 1048576K-209715K(1572864K)] 2097152K-524288K(4194304K), 0.0123456 secs] [Times: user0.02 sys0.00, real0.01 secs]这段日志里最关键的信息是GC类型。GC代表Minor GCFull GC代表老年代回收。原因。Allocation Failure表示Eden区对象分配失败才会触发GC这是最常见也最正常的触发原因。内存变化。PSYoungGen: 1048576K-209715K(1572864K)表示新生代回收前1048576K、回收后209715K、总容量1572864K。括号前的总容量是整个堆数据。耗时。0.0123456 secs是本次GC停顿耗时最后面的user/sys/real是CPU耗时与实际耗时。如果real明显小于user说明GC过程中使用了多线程并行。新手最容易忽略的是“分配速率”和“晋升速率”。分配速率指单位时间内新生代对象占用的内存量晋升速率指单位时间内晋升到老年代的对象内存量。这两个速率如果一直很高即使每次GC看起来正常老年代也迟早被塞满。计算方式很简单用GC日志里新生代回收前的占用值除以两次GC之间的时间间隔就可以得到大致分配速率。3.3 真实案例订单服务高峰Full GC和TP99飙升有一次我负责的订单查询服务在晚高峰出现接口变慢监控显示TP99从平时80ms涨到800ms。我拿到GC日志后发现每5分钟就有一次Full GC每次耗时接近2秒。当时看起来非常吓人。我的排查步骤是这样的第一步用jstat -gcutil 12345 1000实时查看各代使用率。这里12345是进程PID1000是每1秒打印一次。输出里FGC列会显示Full GC累积次数FGCT显示Full GC累积耗时。连续观察几分钟后发现老年代用了不到1分钟就涨到90%以上。第二步用jmap -histo:live 12345 | head -50查看内存中实例分布。这一步会触发一次Full GC加了live参数后生产环境操作时要小心但已经全量观测到问题影响可控。输出显示有一个订单状态机相关对象占了整个堆的40%数量明显异常。第三步结合代码排查。项目里有一个全局Map容器缓存了推送中的订单状态原因是为了快速检索但这个Map只放不删订单终态后也没有清理逻辑。晚高峰下单量大Map里的订单对象越积越多全部待在老年代最终把老年代撑爆。定位到根因后我给业务侧提了代码修复订单状态流转到终态后立即从Map中移除同时把启动参数里老年代占比调大了一点并给G1设置了-XX:MaxGCPauseMillis80。代码上线后Full GC从每5分钟一次降到一天几次TP99恢复正常。这个案例里最值钱的经验是GC优化不是改完参数就完事它往往能帮你反推出应用代码里的内存“坑”。反过来如果只是调参数而不改代码这个Map还是会一直涨只是时间换空间迟早还得爆。3.4 学会用工具别只靠肉眼盯日志看GC日志是基本功但线上服务日志文件动辄几百MB靠肉眼根本盯不过来。我常用的工具分为两类命令行工具和可视化分析工具。命令行工具方面jstat适合实时看内存和GC情况jmap适合查看堆内对象的实例分布和堆转储jstack适合查看线程状态jcmd适合在JDK 8上做很多线上诊断操作。当服务卡顿疑似GC时先top -Hp 进程ID看哪个线程CPU高再jstack看线程栈能快速判断是业务线程问题还是GC线程问题。可视化分析方面我强烈推荐GCViewer和gceasy。GCViewer是开源工具把GC日志文件拖进去就能展示内存曲线、GC频率、停顿时间分布。gceasy是在线服务上传日志后会自动生成诊断报告甚至连“建议增大Eden”“晋升异常”这类结论都会列出来。对于刚开始接触GC调优的同学这类工具能帮你更快建立“日志内容”和“实际问题”之间的对应关系。提示用jmap -F或jmap -dump导出堆转储文件后再分析比直接在线上做-histo:live更稳妥。堆转储文件可以用MATEclipse Memory Analyzer打开查内存泄漏比jmap更直观。4. 常见问题与排查技巧实录4.1 老年代频繁Full GC上来就加堆内存是错误示范这种情况太常见了现象是监控里Full GC次数上升接口时不时卡一下。很多人的第一反应是把-Xmx从4G改到8G结果确实管用了一段时间但下一次业务量起来后问题又重现。正确做法是先搞清楚老年代里装了什么。用jmap -histo:live 进程ID查看实例分布如果某类对象数量大得离谱基本可以断定是代码层面的引用没释放或者缓存无上限。按我上面的订单案例排查路径应当是jstat看趋势jmap看对象分布jstack看线程栈是否存在异常再结合代码找持有者。如果确认没有内存泄漏只是单纯地堆太小再考虑调整堆内存。但这里也有个细节一次性从4G调到8G不一定好堆越大GC停顿时间也可能越长因为回收时需要扫描和整理的对象更多。稳妥的做法是小步增加比如每次加1G-2G观察GC频率和单次停顿是否会达到平衡。4.2 Young GC频繁导致CPU飙高CPU飙高有很多原因其中之一是Minor GC发生得太频繁GC线程占用了大量CPU。这时候看jstat -gcutil会发现YGC数值增长极快比如几秒钟一次。这种情况大概率是新生代尤其是Eden区设置偏小和业务对象分配速率不匹配。可以考虑适当调大-Xmn给新生代更多空间。这里有一个平衡点新生代过大时Minor GC间隔变长但单次GC复制存活对象的时间变长而且老年代变小Full GC可能提前新生代过小时GC次数变多CPU在GC线程上浪费的时间变多。还有一个容易被忽略的原因Survivor区空间不足导致对象过早晋升老年代。当你发现Minor GC后老年代占用也在上涨且对象存活率并不低时就要怀疑是Survivor空间不够晋升阈值形同虚设。解法是调大SurvivorRatio中Survivor的占比或者调高-XX:TargetSurvivorRatio默认50表示目标Survivor占用率50%让Survivor多容纳一些对象。4.3 G1的停顿时间目标设得太小反而拖垮性能G1的-XX:MaxGCPauseMillis是一个软目标不是硬保证。有的人看到默认200ms觉得太慢直接改成50ms结果G1为了赶这个目标会频繁发起Young GC因为每次回收的Region数量变少了导致GC次数暴涨CPU占用升高吞吐量反而下降。正确做法是把这个目标设置成业务真正能接受的值。如果你接口要求TP99在200ms内停顿时间目标可以设在80-150ms之间如果你压测发现50ms在业务上根本没意义就没必要让G1那么“激进”。G1自己会在停顿目标和吞吐量之间寻找平衡你给它一个不切实际的目标它只会通过更频繁的GC来“凑数”实际效果非常糟糕。另外G1的调优还有一个隐藏难点Mixed GC跟不上老年代积累速度。如果老年代的Region积累过快G1来不及在目标停顿时间内完成混合回收最终仍会发生Full GC。这时排查点在于对象的晋升速率常见的解法是调整-XX:G1MixedGCCountTarget和-XX:G1HeapWastePercent但归根结底还得看业务侧是否产生了太多长生命周期对象。4.4 元空间OOM和堆外内存泄漏GC日志解决不了GC优化不只是堆内的事情。JDK 8之后类元信息存放在元空间默认是没有上限的可以用-XX:MaxMetaspaceSize限制。如果线上出现了java.lang.OutOfMemoryError: Metaspace多半是动态生成类太多比如CGLIB代理、反射生成类、热部署未清理。这种OOM和GC参数关系不大主要靠代码层面控制。堆外内存泄漏更难排查。NIO的DirectByteBuffer使用堆外直接内存如果分配过快且没有及时释放系统物理内存可能被耗尽但GC日志和堆内监控都表现正常。遇到这种情况可以用jcmd 进程ID VM.native_memory查看Native内存分布配合pmap确认内存是哪个模块占用的。GC优化的名字里虽然带“GC”但它覆盖不到的角落往往才是真正的拦路虎。4.5 别把GC优化当成万能药代码问题才是大头把GC日志分析清楚后我经常发现某些接口性能差的根因根本不在GC而是慢SQL拖长了事务事务又持有数据库连接和对象引用导致内存占用居高不下、GC压力变大。这种情况下你无论怎么调GC参数都是隔靴搔痒。所以我会建议任何追求性能的团队把GC优化和SQL优化放在同一个优先级上考虑。慢SQL优化时你打开EXPLAIN看type、key、rows、Extra四个字段就能判断索引是否生效、扫描是否过多GC优化时你看的是GC频率、停顿时间、分配速率和晋升速率。两件事看似不同底层逻辑是相通的先量化现状再定位瓶颈最后精准修改而不是拍脑袋做全局优化。4.6 排查速查表直接照着做现象优先查看的数据常见原因初步处理方向Full GC频繁jstat -gcutil、GC日志Full GC计数晋升速率过高、内存泄漏、堆过小jmap定位对象分布修代码或小步调堆Minor GC过于频繁jstat -gcutil中YGC增长Eden太小、对象分配速率过高调大-Xmn控制大对象单次GC停顿过长GC日志单次secs耗时老年代回收压力大、用错回收器评估CMS/G1/ZGC调整目标停顿CPU飙高top -Hp jstackGC线程过多、业务线程死循环区分GC线程和业务线程再针对性处理系统内存被耗尽但堆正常jcmd VM.native_memory堆外内存泄漏、DirectByteBuffer检查NIO缓存、Native内存分配Metaspace OOM日志中的OOM类型动态生成类过多控制代理/反射类设置MaxMetaspaceSize5. 从GC优化延展开的调优通用思路5.1 为什么GC优化和慢SQL优化总是被一起提你在搜GC优化时大概率会同时看到慢SQL优化、性能优化这一类内容。原因很现实后端接口变慢时GC和数据库往往是最容易被怀疑的两个方向。它们有一个共同的排查原则先看执行计划或GC日志再谈优化。慢SQL优化要看EXPLAIN里的索引是否命中、扫描行数是否合理GC优化要看日志里的回收频率、回收耗时、内存变化趋势。两者都不支持“先瞎改一通试试”的作风。掌握了这个通用思路后你会发现GC优化不是孤立的技术活。它和代码质量、数据库设计、缓存策略、线程池配置都是联动的。你优化了GC参数但应用代码里存在无界队列、无界缓存问题迟早会以另一种形式暴露出来。所以真正可靠的性能优化永远是从全局指标找线索回到局部代码找根因。5.2 我的调优顺序供你参考我现在的GC调优流程已经固定成了五个步骤每一步都是踩坑换来的第一步开GC日志收集至少一整天或一个完整业务高峰周期的数据。第二步用可视化工具分析GC日志记录当前的GC频率、停顿时间、内存使用率和慢GC分布时段。第三步结合业务高峰期的时间点反向排查代码看是否有大对象创建、无界缓存、异常线程堆积等问题。第四步基于根因决定改代码还是改参数一次只改一个点记录改动前后的单变量对比数据。第五步保留历史日志和监控报表方便下一次性能波动的横向对比。这套流程听起来朴素但能做到的人并不多。大部分时间我们都在“处理症状”而不是“处理病因”。只要坚持用数据说话GC优化其实没有那么多玄学。我在实际项目里的体会是GC优化最大的门槛从来不是参数背得够不够多而是你愿不愿意静下心来把日志看完、把对象分布查清楚、把代码逻辑理明白。有一次我花了一整天追一个Full GC问题最后发现只是某个定时任务里把一个静态List当成临时缓存用只add不remove。改一行代码比任何优化参数都管用得多。最后再分享一个小技巧每次上线GC优化前把三组数据记录下来——GC频率、单次GC耗时的P99、接口TP99。上线后第二天拿着这三组数据和前一天对比优化是否有效立刻一目了然。这三组数据比任何监控面板上的花哨指标都更能说明问题。