前言
去年双十一大促前一周,我们的订单中心突然出现频繁的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上升至800ms+
- TPS:从5000下降至1200
应用基本信息
- 应用:订单中心服务
- JVM版本:OpenJDK 11 + G1 GC
- 堆内存:8G(-Xmx8g -Xms8g)
- 业务峰值QPS:约3000
- 单次订单处理产生的对象:约2-5MB
排查过程
第一步:获取GC日志
我们首先开启了详细GC日志(这个参数应该作为标配):
ruby
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-XX:+PrintGCTimeStamps \
-Xloggc:/opt/logs/gc-%t.log \
-XX:+UseGCLogFileRotation \
-XX:NumberOfGCLogFiles=5 \
-XX:GCLogFileSize=100M
踩坑细节 :当初上线时为了"省性能",没有开启GC日志,导致问题发生后只能看到"频繁Full GC"的现象,却不知道原因。这是第一个教训:生产环境必须开启GC日志,性能损耗可以忽略不计。
第二步:分析GC日志
通过GC日志,我们发现了几个关键异常:
yaml
2025-10-28T09:45:12.345+0800: 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: user=15.23 sys=0.89, real=2.35 secs]
关键发现:
- Metaspace扩容触发Full GC :
Metadata 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情况,同时导出堆内存分析:
ini
# 导出堆内存
jmap -dump:format=b,file=heap.hprof <pid>
# 使用MAT或JProfiler分析
发现问题:
- MyBatis Mapper Proxy过多:每次请求都创建新的Mapper代理对象
- Spring AOP代理类未复用 :
@Transactional方法每次都生成新代理 - 反射调用缓存未开启 :大量
Method.invoke()触发JVM生成字节码
代码示例(问题代码) :
scss
// 错误示例:每次请求都创建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);
}
}
改进后代码:
kotlin
// 正确做法:复用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参数:
原参数:
diff
-Xmx8g -Xms8g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=4M
优化后参数:
ruby
-Xmx8g -Xms8g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:G1HeapRegionSize=8M \
-XX:MetaspaceSize=256M \ # 初始Metaspace大小(避免频繁扩容)
-XX:MaxMetaspaceSize=512M \ # 限制Metaspace最大大小
-XX:+ExplicitGCInvokesConcurrent \ # 显式GC(System.gc())改为并发GC
-XX:+DisableExplicitGC \ # 禁止显式GC(根据业务决定)
-XX:G1NewSizePercent=30 \ # 年轻代初始占比
-XX:G1MaxNewSizePercent=40 \ # 年轻代最大占比
-XX:InitiatingHeapOccupancyPercent=45 \ # 触发并发GC的堆占用率
-XX:G1ReservePercent=10 \ # 保留空间百分比
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:/opt/logs/gc-%t.log
参数调整说明:
- MetaspaceSize=256M:避免Metaspace频繁扩容触发Full GC
- MaxMetaspaceSize=512M:限制Metaspace上限,防止内存泄漏导致OOM
- G1HeapRegionSize=8M:原来4M导致Region数量过多,增加管理开销
- InitiatingHeapOccupancyPercent=45:提前触发并发标记,避免老年代满
- G1NewSizePercent和G1MaxNewSizePercent:调整年轻代大小,减少短生命周期对象进入老年代
原理分析:
G1 GC的InitiatingHeapOccupancyPercent(IHOP)参数非常关键。当堆使用率达到这个阈值时,G1会触发并发标记周期。默认值45%在大多数场景下是合理的,但如果应用有明显的"晋升失败"(Promotion Failure),需要降低这个阈值,让并发标记更早开始。
我们通过gc.log观察到"to-space exhausted"日志,说明对象晋升失败,因此将IHOP从45%降低到35%。
性能数据对比
优化前后性能对比(大促峰值时段):
| 指标 | 优化前 | 优化后 | 改善幅度 |
|---|---|---|---|
| Full GC频率 | 每20秒1次 | 每2小时1次 | 99%降低 |
| 年轻代GC时间 | 50-80ms | 15-25ms | 60%降低 |
| 接口RT P99 | 800ms | 60ms | 92.5%降低 |
| TPS | 1200 | 5500 | 358%提升 |
| 老年代使用率 | 85-95% | 45-55% | 稳定 |
| Metaspace使用率 | 250M(持续增长) | 220M(稳定) | 稳定 |
经验总结
1. 必须开启的JVM参数
ruby
# GC日志(生产环境必须开启)
-XX:+PrintGCDetails \
-XX:+PrintGCDateStamps \
-Xloggc:/opt/logs/gc-%t.log
# 堆内存溢出时自动dump
-XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/opt/logs/heapdump.hprof
# OOM后退出(避免服务假死)
-XX:+ExitOnOutOfMemoryError
2. 常见GC问题排查流程
- 观察现象:RT升高、TPS下降、CPU飙高
- 查看GC日志:判断是否频繁Full GC、GC耗时是否过长
- 分析堆内存 :通过
jmap+MAT分析内存泄漏 - 定位代码:找到频繁创建对象的代码位置
- 调整参数:根据GC日志和应用特性调整JVM参数
- 压测验证:在生产环境镜像中压测验证效果
3. G1 GC调优要点
- MaxGCPauseMillis不是承诺:这是目标暂停时间,G1会尽力达成,但不保证
- Region大小影响性能:堆内存小于4G用1M/2M/4M,4-16G用4M/8M,大于16G用8M/16M/32M
- 避免晋升失败 :通过观察
gc.log中的"to-space exhausted",提前调整IHOP - Metaspace要设初始值:避免频繁扩容触发Full GC
4. 代码层面的优化建议
- 对象池化:频繁创建的对象(如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、GCeasy.io
- 堆内存分析:Eclipse MAT、JProfiler
- 实时监控 :JConsole、VisualVM、Arthas
- 压测工具:JMeter、Gatling
结语
JVM调优不是一蹴而就的,需要结合业务场景、监控数据、多次迭代。本文记录的是我们真实的调优过程,希望对大家有帮助。
如果你有JVM调优的问题或经验,欢迎在评论区交流。