线上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)分析后,发现了三个大问题:
OrderService里有个static Map<String, List<Order>>缓存了所有历史订单,且从不清理GoodsService的分页查询被业务层绕过了,直接查出全量数据- 优惠券详情对象被重复创建,每个请求都 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 参数问题,更多是代码层面的内存泄漏或者使用不当。排查时按这个顺序:
- 先用
jstat确认 Full GC 频率和持续时间 - 用
jmap导出堆快照,用 MAT 分析大对象和内存泄漏 - 重点关注
static集合、缓存、大查询 - 代码修复优先于参数调优
参数调优只是给问题续命,代码优化才是根治。
