前言去年双十一大促前我们的订单系统突然开始出现间歇性卡顿。起初以为是数据库慢查询直到看了GC日志才发现问题远比想象中复杂。这篇文章记录的是我们从一个频繁Full GC、停顿时间超过3秒的系统优化到平均停顿小于50ms的完整过程。真实场景订单系统的GC噩梦我们的系统规模部署环境4核8G容器JDK 17堆内存4G-Xms4g -Xmx4g日均订单量50万峰值QPS3000G13poL问题症状每隔10-20分钟系统会出现一次3-5秒的完全卡顿监控显示这段时间CPU使用率飙升至100%用户投诉订单提交超时排查过程第一步开启GC日志# JVM参数添加 -XX:PrintGCDetails \ -XX:PrintGCDateStamps \ -XX:PrintTenuringDistribution \ -Xloggc:/app/logs/gc-%t.log \ -XX:UseGCLogFileRotation \ -XX:NumberOfGCLogFiles5 \ -XX:GCLogFileSize100M第二步分析GC日志使用GCViewer和http://GCeasy.io分析发现了几个关键问题问题1频繁的Full GC2024-11-05T14:23:45.1230800: [Full GC (Ergonomics) [PSYoungGen: 768M-0M(1024M)] [ParOldGen: 2048M-2140M(3072M)] 2816M-2140M(4096M), [Metaspace: 128M-128M(256M)], 3.2456789 secs]关键数据Full GC频率每15分钟一次平均停顿时间3.2秒老年代回收效率极低2048M-2140M几乎没回收什么问题2对象过早晋升观察Tenuring Distribution发现大量对象在年龄2-3时就晋升到了老年代导致老年代迅速填满。// 典型的过早晋升场景大对象直接进入老年代 public class OrderProcessor { // 这个缓存就是罪魁祸首 private static final MapLong, OrderDetail ORDER_CACHE new ConcurrentHashMap(10000); public void processOrder(Order order) { // 订单详情对象平均大小约2KB // 高峰期每秒产生300个订单对象 // 这些对象在年轻代存活时间超过2个GC周期就晋升 } }根因分析通过heap dump分析使用Eclipse MAT发现了几个关键问题1. 缓存设计不合理// 问题代码 Cacheable(value orders, key #orderId) public OrderDetail getOrderDetail(Long orderId) { // 每次查询都加载完整关联对象 return orderRepository.findWithDetails(orderId); }实际问题缓存未设置TTL导致大量冷数据堆积缓存对象未序列化存储的是完整JPA实体包含代理对象单个OrderDetail对象实际占用内存约15KB远超预期的2KB2. 年轻代过小初始JVM参数-Xms4g -Xmx4g -XX:NewRatio3 # 年轻代:老年代 1:3这意味着年轻代只有1G而我们的对象产生速率约为300MB/秒导致对象迅速填满年轻代并晋升。3. 使用了Parallel GCJDK 17默认是G1但我们因为某些历史原因手动指定了Parallel GC-XX:UseParallelGCParallel GC的停顿时间不可控在高堆内存场景下容易产生长停顿。优化方案优化1调整JVM参数# 优化后的JVM参数 -Xms4g -Xmx4g -Xmn2g # 年轻代固定2G -XX:MetaspaceSize256m # 避免元空间频繁扩容 -XX:MaxMetaspaceSize256m -XX:UseG1GC # 切换到G1 -XX:MaxGCPauseMillis200 # 目标停顿时间200ms -XX:G1HeapRegionSize4M # Region大小4M -XX:G1NewSizePercent30 # 年轻代最小占比 -XX:G1MaxNewSizePercent40 # 年轻代最大占比 -XX:InitiatingHeapOccupancyPercent35 # 更早启动并发标记 -XX:G1MixedGCLiveThresholdPercent85 # Mixed GC阈值关键调整说明年轻代调整到2G对象在年轻代有更多时间死亡减少晋升切换到G1可预测的停顿时间适合响应时间敏感的应用IHOP调整到35%更早启动并发标记避免并发模式失败优化2重构缓存策略// 优化后的缓存方案 Cacheable( value orders, key #orderId, unless #result null ) public OrderDTO getOrderDetail(Long orderId) { Order order orderRepository.findById(orderId); // 转换为DTO避免存储JPA实体 return OrderConverter.toDTO(order); } // 添加缓存淘汰策略 CacheEvict(value orders, key #orderId) public void onOrderCancelled(Long orderId) { // 订单取消时主动淘汰缓存 } // 使用Caffeine作为二级缓存 Bean public CacheManager cacheManager() { CaffeineCacheManager manager new CaffeineCacheManager(); manager.setCaffeine(Caffeine.newBuilder() .initialCapacity(1000) .maximumSize(10000) .expireAfterWrite(10, TimeUnit.MINUTES) .recordStats()); return manager; }优化3优化对象创建// 问题代码每次都创建新对象 public class PriceCalculator { public BigDecimal calculate(Order order) { BigDecimal basePrice order.getBasePrice(); BigDecimal discount discountService.calculate(order); // 临时对象过多 return basePrice.multiply(discount); } } // 优化后复用对象使用原始类型 public class PriceCalculator { private static final BigDecimal ZERO BigDecimal.ZERO; private static final BigDecimal ONE_HUNDRED new BigDecimal(100); public BigDecimal calculate(Order order) { // 避免不必要的对象创建 if (order.getDiscount() 0) { return order.getBasePrice(); } // 使用valueOf复用对象 BigDecimal discount BigDecimal.valueOf(order.getDiscount()); return order.getBasePrice() .multiply(BigDecimal.ONE_HUNDRED.subtract(discount)) .divide(ONE_HUNDRED); } }优化4数据库查询优化// 原来N1查询问题 Transactional public ListOrderDTO getOrderList(Long userId) { ListOrder orders orderRepository.findByUserId(userId); // 每个order都会触发一次详情查询 return orders.stream() .map(o - getOrderDetail(o.getId())) .collect(toList()); } // 优化后使用join fetch一次性加载 Query(SELECT o FROM Order o LEFT JOIN FETCH o.items LEFT JOIN FETCH o.payment WHERE o.userId :userId) ListOrder findByUserIdWithDetails(Param(userId) Long userId);优化效果GC性能指标对比指标优化前优化后提升Full GC频率每15分钟1次0次100%平均Young GC停顿120ms35ms70.8%最大停顿时间3245ms180ms94.5%GC总耗时占比12.3%2.1%82.9%堆内存利用率92%68%更健康业务指标对比指标优化前优化后提升接口平均响应时间450ms85ms81.1%P99响应时间3200ms210ms93.4%QPS峰值3000500066.7%错误率0.8%0.02%97.5%踩坑细节坑1G1的IHOP不是越小越好起初我们把IHOP设置成20%希望更早启动并发标记。结果导致并发标记过于频繁占用CPU资源年轻代被压缩对象晋升率反而上升经验IHOP设置在35-45%之间比较合理具体要根据应用的对象分配速率调整。坑2Metaspace未设置上限导致OOM优化过程中我们发现Metaspace在动态生成代理类时无限增长// 问题代码每次请求都创建新的代理 public class DynamicProxyFactory { public static T T createProxy(ClassT interfaceClass) { return (T) Proxy.newProxyInstance( interfaceClass.getClassLoader(), new Class[]{interfaceClass}, new InvocationHandler() { // 匿名内部类每次都会生成新的类 } ); } }解决设置Metaspace上限并复用代理实例。坑3监控不到位导致问题复现优化上线后一周问题又出现了。排查发现是新上线的功能引入了类似的问题// 新功能的问题代码 PostMapping(/api/orders/batch) public ListOrderDTO batchQuery(RequestBody ListLong orderIds) { // 一次查询1000个订单导致年轻代瞬间被打满 return orderIds.parallelStream() .map(this::getOrderDetail) .collect(toList()); }解决建立GC监控告警设置Young GC频率、停顿时间等指标的阈值。监控与告警优化后我们建立了完善的GC监控体系# Prometheus GC监控告警规则 groups: - name: jvm_gc rules: - alert: HighGCPauseRate expr: rate(jvm_gc_pause_seconds_count[5m]) 2 annotations: summary: GC频率过高: {{ $value }}次/秒 - alert: LongGCPause expr: histogram_quantile(0.99, rate(jvm_gc_pause_seconds_bucket[5m])) 0.5 annotations: summary: GC停顿时间过长: P99{{ $value }}s - alert: FrequentFullGC expr: increase(jvm_gc_collection_seconds_count[10m]) 0 annotations: summary: 发生Full GC需要立即排查经验总结不要盲目调参先通过GC日志和heap dump找到根因再针对性优化年轻代不是越大越好我们试过把年轻代调到3G结果老年代只有1GMixed GC过于频繁G1适合大部分场景除非你对停顿时间有极致要求微秒级否则G1是很好的选择缓存是双刃剑不合理的缓存设计往往比没有缓存更糟糕监控先行没有监控的优化是盲人摸象工具推荐GC日志分析http://GCeasy.io在线、GCViewer离线内存分析Eclipse MAT、JProfiler实时监控JConsole、VisualVM、PrometheusGrafana压测JMeter、wrk后记这次优化让我们深刻认识到JVM调优不是简单的参数调整而是需要深入理解应用的对象分配模式、生命周期特征结合业务场景进行系统性优化。如果你也在面临类似的GC问题欢迎在评论区交流讨论。