线上服务频繁Full GC,排查发现是这个原因

周三下午,监控告警群疯狂报警:生产环境某服务响应时间飙升,Full GC每分钟触发十几次。

排查了两个小时,最后发现是一个很隐蔽的问题。记录一下,希望对大家有帮助。


一、问题现象

监控数据:

  • Full GC频率:从正常的几小时一次 → 每分钟10+次
  • 服务响应时间:从100ms → 5秒+
  • CPU使用率:飙到90%+(大部分是GC线程)

告警信息:


[WARN] GC overhead limit exceeded
[WARN] Full GC (Allocation Failure)

---

## 二、紧急处理

先恢复业务再说。

1. 保留现场

jmap -dump:format=b,file=/tmp/heap.hprof $(pgrep -f myservice)

2. 重启服务

systemctl restart myservice

重启后暂时恢复,但不解决根因迟早还会出问题。

---

## 三、排查过程

### 3.1 先看GC日志

GC日志分析:

2024-01-17T14:32:15.123+0800: [Full GC (Allocation Failure)
[PSYoungGen: 87040K->87040K(87040K)]
[ParOldGen: 174080K->174050K(174080K)]
261120K->261090K(261120K), 0.8523450 secs]

**关键发现:**
- Full GC后内存几乎没释放
- 年轻代和老年代都接近满了
- 说明有大量对象无法回收

### 3.2 分析Heap Dump

用MAT(Memory Analyzer Tool)打开dump文件。

**Leak Suspects Report显示:**

Problem Suspect 1:
One instance of "java.util.concurrent.LinkedBlockingQueue"
occupies 850,000,000 bytes (78% of the heap)

一个LinkedBlockingQueue占了78%的内存?

### 3.3 定位代码

点击查看这个队列的引用链:

java.util.concurrent.LinkedBlockingQueue
↑ java.util.concurrent.ThreadPoolExecutor.workQueue
↑ com.example.service.AsyncService.executor

找到了!是`AsyncService`里的线程池。

### 3.4 查看代码

```java
@Service
public class AsyncService {
    
    // 问题就在这里!
    private ExecutorService executor = new ThreadPoolExecutor(
        5,                      // 核心线程数
        10,                     // 最大线程数
        60L, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>()  // 无界队列!
    );
    
    public void asyncProcess(Task task) {
        executor.submit(() -> {
            processTask(task);
        });
    }
}

问题根因:

1. 使用了无界队列 LinkedBlockingQueue<>()
2. 任务处理速度 < 任务提交速度
3. 任务不断堆积在队列里
4. 队列越来越大,内存越来越少
5. 最终触发频繁Full GC

四、修复方案

方案1:使用有界队列

private ExecutorService executor = new ThreadPoolExecutor(
    5,
    20,  // 最大线程数调大
    60L, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(1000),  // 有界队列,最多1000个任务
    new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略
);

方案2:推荐配置

@Bean
public ThreadPoolExecutor asyncExecutor() {
    return new ThreadPoolExecutor(
        10,                                    // 核心线程数
        50,                                    // 最大线程数
        60L, TimeUnit.SECONDS,                 // 空闲存活时间
        new LinkedBlockingQueue<>(500),        // 有界队列
        new ThreadFactoryBuilder()
            .setNameFormat("async-pool-%d")
            .build(),
        new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略
    );
}

阿里Java开发手册明确禁止使用Executors创建线程池!


五、拒绝策略选择

策略 行为 适用场景
AbortPolicy 抛异常 不允许丢弃任务
CallerRunsPolicy 调用者线程执行 希望降速,不丢任务
DiscardPolicy 静默丢弃 允许丢弃
DiscardOldestPolicy 丢弃队列最老的 只关心最新任务

一般推荐CallerRunsPolicy


六、深入理解:为什么无界队列危险

6.1 ThreadPoolExecutor工作原理

任务提交流程:
1. 线程数 < corePoolSize → 创建新线程
2. 线程数 >= corePoolSize → 放入队列
3. 队列满 && 线程数 < maxPoolSize → 创建新线程
4. 队列满 && 线程数 >= maxPoolSize → 执行拒绝策略

无界队列问题:
- 队列永远不会满
- 步骤3永远不会触发
- maxPoolSize参数失效
- 任务无限堆积 → 内存溢出

6.2 线程池参数估算

CPU密集型:
corePoolSize = CPU核数 + 1

IO密集型:
corePoolSize = CPU核数 * 2 * (1 + 平均等待时间/平均计算时间)

队列容量估算:
queueCapacity = (任务最大响应时间 - 任务平均处理时间) * 每秒任务数

6.3 JVM内存与GC的关系

GC触发条件:
- Young GC:Eden区满
- Full GC:
  1. 老年代空间不足
  2. Metaspace不足
  3. System.gc()调用
  4. CMS GC失败(Concurrent Mode Failure)

本次问题:
大量Task对象存活时间长 → 晋升到老年代 → 老年代满 → Full GC
Full GC无法回收(对象还在被引用)→ 反复Full GC → GC overhead

七、如何避免类似问题

7.1 代码规范

// ❌ 禁止
Executors.newFixedThreadPool(10);
new LinkedBlockingQueue<>();

// ✅ 推荐
new ThreadPoolExecutor(
    核心线程数,
    最大线程数,
    存活时间, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(队列容量),
    拒绝策略
);

7.2 JVM参数推荐

# 生产环境推荐配置
-Xms4g -Xmx4g                          # 堆内存,建议相等避免动态调整
-XX:+UseG1GC                           # G1垃圾收集器
-XX:MaxGCPauseMillis=200               # 目标停顿时间
-XX:+HeapDumpOnOutOfMemoryError        # OOM时自动dump
-XX:HeapDumpPath=/tmp/heapdump.hprof   # dump路径
-XX:+PrintGCDetails                    # GC详情
-Xloggc:/var/log/gc.log                # GC日志

7.4 监控线程池

@Scheduled(fixedRate = 60000)
public void monitorThreadPool() {
    ThreadPoolExecutor executor = asyncExecutor;
    
    log.info("线程池状态: 活跃线程={}, 队列大小={}", 
        executor.getActiveCount(),
        executor.getQueue().size());
    
    if (executor.getQueue().size() > 300) {
        // 发送告警
    }
}

八、远程Dump分析技巧

生产环境dump文件动辄几个G,下载到本地分析很麻烦。

我用星空组网把本地和服务器连起来,直接scp拉文件:

# 组网后直接传文件
scp root@192.168.188.10:/tmp/heap.hprof ./

或者用VisualVM远程连接JMX实时看内存情况。比开VPN方便,速度也快。


九、总结

这次问题的根因:

无界队列 + 任务处理慢 = 队列无限增长 = 内存溢出 = 频繁Full GC

教训:

  1. 永远不要用无界队列
  2. 永远不要用Executors创建线程池
  3. 线程池必须监控
  4. 拒绝策略要根据业务选择

希望这篇文章能帮到遇到类似问题的朋友。

有问题评论区交流~


posted @ 2025-12-05 10:34  花宝宝  阅读(1)  评论(0)    收藏  举报