JVM GC调优实战:从线上频繁Full GC到RT降低80%的全过程
前言去年双十一大促前一周我们的订单中心突然出现频繁的Full GC平均每分钟3-4次导致接口RT从50ms飙升至800ms差点搞挂整个下单链路。这篇文章记录了我们如何从监控告警开始一步步定位问题、调整JVM参数、最终将GC频率降低99%的完整过程。这不是一篇GC算法的科普文而是真实的线上问题排查记录。我会尽量还原当时的思考过程和踩坑细节希望能给遇到类似问题的同学一些参考。问题现象监控告警早上10点监控平台开始疯狂告警Full GC频率从每30分钟1次飙升至每20秒1次年轻代GC时间从平均15ms上升至50-80ms老年代使用率持续在85%以上频繁触发Full GC接口RT P99从50ms上升至800msTPS从5000下降至1200应用基本信息应用订单中心服务JVM版本OpenJDK 11 G1 GC堆内存8G-Xmx8g -Xms8g业务峰值QPS约3000单次订单处理产生的对象约2-5MB排查过程第一步获取GC日志我们首先开启了详细GC日志这个参数应该作为标配-XX:PrintGCDetails \ -XX:PrintGCDateStamps \ -XX:PrintGCTimeStamps \ -Xloggc:/opt/logs/gc-%t.log \ -XX:UseGCLogFileRotation \ -XX:NumberOfGCLogFiles5 \ -XX:GCLogFileSize100M踩坑细节当初上线时为了”省性能”没有开启GC日志导致问题发生后只能看到”频繁Full GC”的现象却不知道原因。这是第一个教训生产环境必须开启GC日志性能损耗可以忽略不计。第二步分析GC日志通过GC日志我们发现了几个关键异常2025-10-28T09:45:12.3450800: 15432.123: [Full GC (Ergonomics) Metadata GC Threshold] [PSYoungGen: 0K-0K(2725376K)] [ParOldGen: 6992345K-6992345K(6993920K)] 6992345K-6992345K(9719296K), [Metaspace: 256M-256M(1280M)], 2.3456789 secs] [Times: user15.23 sys0.89, real2.35 secs]关键发现Metaspace扩容触发Full GCMetadata GC Threshold说明是Metaspace扩容导致的Full GC老年代几乎满ParOldGen: 6992345K-6992345K说明老年代已经无法回收Full GC耗时2.35秒这意味着这2.35秒内应用完全停顿STW原理分析Metaspace在JDK 8替代了永久代用于存储类的元数据Class信息、方法信息、常量池等。当Metaspace使用率达到MetaspaceSize默认约20M时会触发Full GC试图回收无用的类元数据。但在我们的场景中应用频繁动态生成代理类Spring AOP MyBatis Mapper导致Metaspace持续增长每次扩容都触发Full GC。第三步定位代码问题通过jstat -gcutil pid 1000实时观察GC情况同时导出堆内存分析# 导出堆内存 jmap -dump:formatb,fileheap.hprof pid # 使用MAT或JProfiler分析发现问题MyBatis Mapper Proxy过多每次请求都创建新的Mapper代理对象Spring AOP代理类未复用Transactional方法每次都生成新代理反射调用缓存未开启大量Method.invoke()触发JVM生成字节码代码示例问题代码// 错误示例每次请求都创建Mapper代理 public class OrderService { public OrderDTO getOrder(Long orderId) { // 每次都创建新的SqlSession和Mapper代理 SqlSession session sqlSessionFactory.openSession(); OrderMapper mapper session.getMapper(OrderMapper.class); Order order mapper.selectById(orderId); session.close(); return convertToDTO(order); } }改进后代码// 正确做法复用Mapper代理 Service public class OrderService { Autowired private OrderMapper orderMapper; // 注入单例Mapper Transactional(readOnly true) public OrderDTO getOrder(Long orderId) { Order order orderMapper.selectById(orderId); return convertToDTO(order); } }踩坑细节原代码中sqlSessionFactory.openSession()每次都创建新的SqlSession导致SqlSession级别的缓存一级缓存无法生效同时每次都生成新的Mapper代理类MetaSpace持续增长。第四步调整JVM参数在修复代码问题的同时我们也调整了JVM参数原参数-Xmx8g -Xms8g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:G1HeapRegionSize4M优化后参数-Xmx8g -Xms8g \ -XX:UseG1GC \ -XX:MaxGCPauseMillis200 \ -XX:G1HeapRegionSize8M \ -XX:MetaspaceSize256M \ # 初始Metaspace大小避免频繁扩容 -XX:MaxMetaspaceSize512M \ # 限制Metaspace最大大小 -XX:ExplicitGCInvokesConcurrent \ # 显式GCSystem.gc()改为并发GC -XX:DisableExplicitGC \ # 禁止显式GC根据业务决定 -XX:G1NewSizePercent30 \ # 年轻代初始占比 -XX:G1MaxNewSizePercent40 \ # 年轻代最大占比 -XX:InitiatingHeapOccupancyPercent45 \ # 触发并发GC的堆占用率 -XX:G1ReservePercent10 \ # 保留空间百分比 -XX:PrintGCDetails \ -XX:PrintGCDateStamps \ -Xloggc:/opt/logs/gc-%t.log参数调整说明MetaspaceSize256M避免Metaspace频繁扩容触发Full GCMaxMetaspaceSize512M限制Metaspace上限防止内存泄漏导致OOMG1HeapRegionSize8M原来4M导致Region数量过多增加管理开销InitiatingHeapOccupancyPercent45提前触发并发标记避免老年代满G1NewSizePercent和G1MaxNewSizePercent调整年轻代大小减少短生命周期对象进入老年代原理分析G1 GC的InitiatingHeapOccupancyPercentIHOP参数非常关键。当堆使用率达到这个阈值时G1会触发并发标记周期。默认值45%在大多数场景下是合理的但如果应用有明显的”晋升失败”Promotion Failure需要降低这个阈值让并发标记更早开始。我们通过gc.log观察到”to-space exhausted”日志说明对象晋升失败因此将IHOP从45%降低到35%。性能数据对比优化前后性能对比大促峰值时段指标优化前优化后改善幅度Full GC频率每20秒1次每2小时1次99%降低年轻代GC时间50-80ms15-25ms60%降低接口RT P99800ms60ms92.5%降低TPS12005500358%提升老年代使用率85-95%45-55%稳定Metaspace使用率250M持续增长220M稳定稳定经验总结1. 必须开启的JVM参数# GC日志生产环境必须开启 -XX:PrintGCDetails \ -XX:PrintGCDateStamps \ -Xloggc:/opt/logs/gc-%t.log # 堆内存溢出时自动dump -XX:HeapDumpOnOutOfMemoryError \ -XX:HeapDumpPath/opt/logs/heapdump.hprof # OOM后退出避免服务假死 -XX:ExitOnOutOfMemoryError2. 常见GC问题排查流程观察现象RT升高、TPS下降、CPU飙高查看GC日志判断是否频繁Full GC、GC耗时是否过长分析堆内存通过jmapMAT分析内存泄漏定位代码找到频繁创建对象的代码位置调整参数根据GC日志和应用特性调整JVM参数压测验证在生产环境镜像中压测验证效果3. G1 GC调优要点MaxGCPauseMillis不是承诺这是目标暂停时间G1会尽力达成但不保证Region大小影响性能堆内存小于4G用1M/2M/4M4-16G用4M/8M大于16G用8M/16M/32M避免晋升失败通过观察gc.log中的”to-space exhausted”提前调整IHOPMetaspace要设初始值避免频繁扩容触发Full GC4. 代码层面的优化建议对象池化频繁创建的对象如ByteArrayOutputStream考虑池化避免反射热点代码用MethodHandle或LambdaMetafactory谨慎使用动态代理Spring AOP、MyBatis Mapper要注意代理复用及时关闭资源数据库连接、文件流、线程池要正确关闭踩过的坑坑1误信”GC自动调优”JVM有自适应调优机制Ergonomics但不要迷信它。我们最初用的是默认参数结果在大促流量下频繁Full GC。JVM无法预知你的业务模型必须根据实际情况调优。坑2盲目追求低延迟曾经把MaxGCPauseMillis设为50ms结果导致G1频繁进行增量GC反而增加了GC总耗时。后来调整为200ms单次GC时间略有增加但GC频率大幅降低总体吞吐量和延迟都更好。坑3忽略Metaspace很多同学只关注堆内存忽略了Metaspace。在重度使用Spring、MyBatis、动态代理的场景下Metaspace可能成为性能瓶颈。坑4生产环境不敢调优很多人担心调整JVM参数会导致问题宁愿忍受频繁的GC。其实只要按照”测试环境验证→灰度发布→全量发布”的流程风险是完全可控的。相反频繁的Full GC才是最大的风险。工具推荐GC日志分析GCViewer、http://GCeasy.io堆内存分析Eclipse MAT、JProfiler实时监控JConsole、VisualVM、Arthas压测工具JMeter、Gatling结语JVM调优不是一蹴而就的需要结合业务场景、监控数据、多次迭代。本文记录的是我们真实的调优过程希望对大家有帮助。如果你有JVM调优的问题或经验欢迎在评论区交流。