一、FullGC为什么让人头疼

做Java开发的同学应该都遇到过这样的场景:系统运行一段时间后,响应越来越慢,监控一看,FullGC频繁得跟闹钟似的。这时候老板的脸色可能比FullGC的STW(Stop-The-World)还要难看。

FullGC频繁本质上是因为老年代空间被快速填满,导致JVM不得不停下所有应用线程来清理垃圾。想象一下,你家的垃圾桶总是很快就满了,不得不频繁下楼倒垃圾,这期间啥家务都做不了——FullGC就是这么个烦人的过程。

示例场景:电商促销导致FullGC暴增

假设我们有个Java开发的秒杀系统,平时运行良好,但大促时频繁FullGC:

// 技术栈:Java 8 + SpringBoot
@RestController
public class SeckillController {
    // 使用HashMap缓存秒杀商品(问题代码!)
    private static Map<Long, Item> cache = new HashMap<>();
    
    @PostMapping("/seckill")
    public Result seckill(@RequestBody Order order) {
        // 1. 检查库存(从缓存读取)
        Item item = cache.get(order.getItemId());
        if (item.getStock() <= 0) {
            return Result.fail("已售罄");
        }
        // 2. 扣减库存(未考虑并发问题)
        item.setStock(item.getStock() - 1);
        // 3. 生成订单(模拟老年代对象积累)
        OrderService.createOrder(order); // 每次调用产生200KB订单对象
        return Result.success();
    }
}

问题分析

  1. HashMap缓存没有设置大小限制,大促时可能堆积GB级数据
  2. createOrder产生的订单对象最终会进入老年代
  3. 没有处理并发场景,导致产生多余对象

二、揪出FullGC的元凶

2.1 工具准备

工欲善其事,必先利其器:

  • jstat -gcutil [pid] 1000:实时查看GC统计
  • jmap -histo:live [pid]:查看对象分布
  • GC日志(添加JVM参数):
    -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/tmp/gc.log
    

2.2 实战分析

通过GC日志发现这样的记录:

2023-08-20T14:30:01.123+0800: [Full GC (Allocation Failure) 
  [PSOldGen: 819200K->819199K(819200K)] 
  1024000K->1023999K(1024000K), 
  [Metaspace: 25600K->25600K(1056768K)]

关键指标

  • 老年代(PSOldGen)回收前后几乎无变化
  • 回收后空间仍然不足(Allocation Failure)

三、调优三十六计

3.1 基础参数调整

# 建议配置(针对8核机器,堆内存4G)
-Xms4g -Xmx4g           # 避免堆动态扩容
-XX:NewRatio=2          # 新生代:老年代=1:2
-XX:SurvivorRatio=8     # Eden:Survivor=8:1:1
-XX:+UseConcMarkSweepGC # CMS收集器(Java8推荐)

3.2 代码优化方案

改造之前的秒杀代码:

// 优化版本:使用Caffeine缓存 + 对象复用
@RestController 
public class SeckillControllerV2 {
    // 使用带上限的缓存
    private static Cache<Long, Item> cache = Caffeine.newBuilder()
        .maximumSize(10_000)
        .build();
    
    // 对象池复用订单对象
    private static ObjectPool<Order> orderPool = new GenericObjectPool<>(
        new BasePooledObjectFactory<Order>() {
            @Override public Order create() { return new Order(); }
            @Override public void passivateObject(Order obj) { obj.clear(); }
        }
    );

    @PostMapping("/v2/seckill")
    public Result seckill(@RequestBody Order order) throws Exception {
        Item item = cache.get(order.getItemId(), k -> queryFromDB(k));
        // 使用CAS保证原子性
        while (true) {
            int stock = item.getStock();
            if (stock <= 0) return Result.fail("已售罄");
            if (item.cas(stock, stock - 1)) break;
        }
        // 从对象池获取订单对象
        Order pooledOrder = orderPool.borrowObject();
        pooledOrder.copyFrom(order);
        OrderService.createOrder(pooledOrder);
        orderPool.returnObject(pooledOrder);
        return Result.success();
    }
}

优化点

  1. 用Caffeine替代HashMap,避免内存无限增长
  2. 引入对象池减少老年代对象创建
  3. 采用CAS解决并发问题

四、防患于未然的建议

4.1 监控体系建设

推荐配置:

  • Prometheus + Grafana监控JVM指标
  • 关键报警项:
    • FullGC次数 > 1次/分钟
    • 老年代使用率 > 80%持续5分钟

4.2 压测验证方法

使用JMeter模拟大促流量:

<!-- JMeter测试片段 -->
<ThreadGroup>
  <duration>300</duration> <!-- 持续5分钟 -->
  <rate>1000</rate>        <!-- 1000请求/秒 -->
  <httpSampler>
    <path>/v2/seckill</path>
    <postBody>{"itemId":123}</postBody>
  </httpSampler>
</ThreadGroup>

4.3 其他注意事项

  1. 谨慎使用大对象(如大数组),容易直接进入老年代
  2. 避免在循环中创建集合对象
  3. 注意第三方库的内存泄漏(如未关闭的连接池)

五、总结回顾

FullGC频繁的本质是内存管理失衡。通过本文的案例我们可以看到,从参数调整、代码优化到监控预警,每个环节都需要精心设计。记住一个黄金法则:让对象尽可能在年轻代消亡,减少晋升到老年代的机会。

当遇到FullGC问题时,建议按照这个流程排查:

  1. 确认现象(通过GC日志)
  2. 定位问题对象(jmap/jvisualvm)
  3. 针对性优化(代码/参数)
  4. 验证效果(压测+监控)