线上Spring Boot应用频繁Full GC,我用这招把延迟降了80%

线上Spring Boot应用频繁Full GC,我用这招把延迟降了80%

凌晨两点,告警短信把睡梦中的我惊醒:“TP99 延迟超过 2s”。登录监控一看,G1 垃圾回收器每小时触发了 12 次 Full GC,每次停顿超过 300ms。用户下单成功的接口直接超时。

这不是个例。Spring Boot 应用里 Full GC 频发是一个经典问题,根因往往不在 JVM 参数,而是代码里藏着的内存泄漏或者大对象分配。

事故现场

那晚的场景是这样的:一个优惠券核销服务,用户下单时系统会自动扣减优惠。代码逻辑看起来很简单:

@Service
public class CouponService {

    public void redeem(String userId, String couponCode) {
        Coupon coupon = couponDao.findByCode(couponCode);
        List<Order> orders = orderDao.findRecentOrders(userId);
        List<Goods> goods = goodsDao.findByCoupon(couponCode);

        // 业务逻辑
        validateCoupon(coupon, orders, goods);
        deductInventory(coupon);
        recordUsage(userId, coupon);
    }
}

问题在哪?三个查询返回的 List 没有限制大小。当用户订单量大时,这些列表会膨胀到几千甚至上万条记录,全部加载到堆内存里。

而 Full GC 的真正元凶,是这些临时对象在老年代不断积累,最后触发大对象直接进入老年代,导致老年代空间迅速耗尽。

排查过程

首先用 jstat 确认了 GC 情况:

jstat -gcutil <pid> 1000

输出显示:

S0     S1     E      O      M     YGC     YGCT    FGC    FGCT     GCT
0.00  62.45  45.23  98.12  78.56    123   2.456     8   15.678   18.134

O(老年代)使用率 98%,FGC 频繁触发达 8 次。这说明对象在不断进入老年代,而老年代空间已经不够用。

接着用 jmap 导出堆内存快照:

jmap -dump:format=b,file=heap.hprof <pid>

用 MAT(Memory Analyzer Tool)分析后,发现了三个大问题:

  1. OrderService 里有个 static Map<String, List<Order>> 缓存了所有历史订单,且从不清理
  2. GoodsService 的分页查询被业务层绕过了,直接查出全量数据
  3. 优惠券详情对象被重复创建,每个请求都 new 一个新的

根因分析

1. 静态缓存没有边界

这是一个典型的“缓存潘峥”问题:

public class OrderService {
    // 这个缓存在应用启动后就一直增长
    private static Map<String, List<Order>> orderCache = new HashMap<>();

    public List<Order> findRecentOrders(String userId) {
        if (!orderCache.containsKey(userId)) {
            orderCache.put(userId, orderDao.findRecentOrders(userId));
        }
        return orderCache.get(userId);
    }
}

问题在于:这个缓存只有 put,没有过期机制,也没有容量限制。用户越多,缓存越大,直到撑满堆内存。

2. 大对象直接进入老年代

JVM 的 G1 收集器默认把超过 G1HeapRegionSize 一半的对象视为大对象,直接进入老年代。在 4MB 的 Region 下,超过 2MB 的对象就是大对象。

当我们查询出几千条订单记录时,产生的 ArrayList 和 Order 对象远超这个阈值,直接进入老年代,而不是年轻代的 Survivor 区。

3. 短生命周期对象的快速晋升

年轻代的对象默认在 Survivor 区存活 15 次 Minor GC 后才会晋升到老年代。但如果对象太大,Survivor 区放不下,也会直接晋升到老年代。

解决方案

方案一:修复缓存潘峥

引入 Spring Cache 和 Redis,添加合理的过期时间:

@Service
public class OrderService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;

    private static final String ORDER_CACHE_PREFIX = "orders:";
    private static final long CACHE_EXPIRE_MINUTES = 30;

    public List<Order> findRecentOrders(String userId) {
        String key = ORDER_CACHE_PREFIX + userId;
        List<Order> cached = (List<Order>) redisTemplate.opsForValue().get(key);

        if (cached != null) {
            return cached;
        }

        List<Order> orders = orderDao.findRecentOrders(userId);
        // 限制返回数量
        List<Order> limited = orders.stream()
            .limit(100)
            .collect(Collectors.toList());

        redisTemplate.opsForValue().set(key, limited,
            CACHE_EXPIRE_MINUTES, TimeUnit.MINUTES);

        return limited;
    }
}

方案二:修复分页查询

业务层不应该绕过分页:

@Service
public class GoodsService {

    // 强制分页上限
    private static final int MAX_QUERY_SIZE = 500;

    public Page<Goods> findByCoupon(String couponCode, int page, int size) {
        // 限制最大页大小
        int limitedSize = Math.min(size, 100);
        return goodsDao.findByCoupon(couponCode, page, limitedSize);
    }

    // 如果确实需要批量处理,拆分成小批次
    public void processGoodsBatch(List<String> goodsIds) {
        // 分批处理,每批100条
        List<List<String>> batches = Lists.partition(goodsIds, 100);
        for (List<String> batch : batches) {
            processBatch(batch);
        }
    }
}

方案三:对象池化减少 GC

对于频繁创建的大对象,使用对象池:

public class CouponDetailPool {
    private static final int POOL_SIZE = 100;
    private final ConcurrentLinkedQueue<CouponDetail> pool = new ConcurrentLinkedQueue<>();

    public CouponDetail borrow() {
        CouponDetail obj = pool.poll();
        if (obj == null) {
            obj = new CouponDetail();
        }
        return obj;
    }

    public void release(CouponDetail obj) {
        // 重置状态
        obj.reset();
        if (pool.size() < POOL_SIZE) {
            pool.offer(obj);
        }
    }
}

不过这个方案比较重量级,更推荐的是直接减少对象创建。

JVM 参数调优

代码修复之外,也调整了 JVM 参数:

JAVA_OPTS="-Xms4g -Xmx4g \
  -XX:+UseG1GC \
  -XX:MaxGCPauseMillis=100 \
  -XX:InitiatingHeapOccupancyPercent=45 \
  -XX:+ParallelRefProcEnabled \
  -XX:+PrintGCDetails \
  -XX:+PrintGCDateStamps \
  -Xloggc:/var/logs/gc.log"

关键参数说明:

  • MaxGCPauseMillis=100:目标停顿时间,G1 会尽量满足
  • InitiatingHeapOccupancyPercent=45:堆占用 45% 时开始并发标记,提前发现回收机会
  • ParallelRefProcEnabled:并行处理 Reference 对象,减少 Reference 处理时间

最终效果

上线后监控数据:

  • Full GC 次数:从每小时 12 次降到 0 次
  • YGC 次数:基本持平
  • 接口 TP99:从 2000ms 降到 350ms
  • 堆内存使用率:稳定在 60% 左右

经验总结

Full GC 的根因很少是 JVM 参数问题,更多是代码层面的内存泄漏或者使用不当。排查时按这个顺序:

  1. 先用 jstat 确认 Full GC 频率和持续时间
  2. jmap 导出堆快照,用 MAT 分析大对象和内存泄漏
  3. 重点关注 static 集合、缓存、大查询
  4. 代码修复优先于参数调优

参数调优只是给问题续命,代码优化才是根治。

最后更新 4/20/2026, 4:48:48 AM