线程池 OOM 实战:无界队列配错,5 万个任务撑爆 JVM

线程池 OOM 实战:无界队列配错,5 万个任务撑爆 JVM

线程池配置改了一行,上线 10 分钟就 OOM。不是内存泄漏,不是大对象,是队列没设上限。


一、事故现场

周一上午 10 点,我们上线了一个新功能:批量发送通知。代码审查通过,测试环境跑通,灰度 10% 没问题,全量上线。

10 分钟后,告警群炸了:

css 复制代码
[告警] order-service 内存使用率 > 90%
[告警] order-service 频繁 Full GC,STW > 3s
[告警] order-service 进程已重启(OOM Killer)

服务被 OOM Killer 直接杀掉了。看 GC 日志:

scss 复制代码
[Full GC (Ergonomics) [PSYoungGen: 0K] [ParOldGen: 1718920K->1718918K(1718944K)] 
 1718920K->1718918K(1718976K), 3.214s] 
# 每隔几秒一次 Full GC,老年代几乎不回收,3 秒以上的 STW

老年代 1.7G 基本不回收,Full GC 间隔越来越短,最终 OOM。

第一反应:内存泄漏?大对象?缓存没清?

都不是。dump 下来一看,堆里塞满了 ThreadPoolExecutor 的任务队列对象,将近 5 万个通知任务堆积在队列里

5 万个任务?我们配的线程池 corePoolSize 才 80,maxPoolSize 160,队列怎么堆了 5 万个?


二、先看看我们怎么配的线程池

出问题的代码长这样:

java 复制代码
@Configuration
public class ThreadPoolConfig {

    @Bean("notifyThreadPool")
    public ThreadPoolExecutor notifyThreadPool() {
        int cpuCores = Runtime.getRuntime().availableProcessors();  // 生产环境 8 核
        return new ThreadPoolExecutor(
            cpuCores * 10,                    // corePoolSize = 80
            cpuCores * 20,                    // maxPoolSize = 160
            60, TimeUnit.SECONDS,             // 空闲 60 秒回收
            new LinkedBlockingQueue<>(),       // 无界队列!没传容量,默认 Integer.MAX_VALUE
            new ThreadFactoryBuilder()
                .setNameFormat("notify-pool-%d").build(),
            new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略
        );
    }
}

看着没什么问题对吧?8 核机器,80 个核心线程,160 个最大线程,拒绝策略也有。这是网上最常见的"合理配置"。

但上线后 10 分钟就 OOM 了。问题出在哪?


三、5 秒复现:线程池怎么把内存撑爆

3.1 复现代码

java 复制代码
import java.util.concurrent.*;

public class ThreadPoolOOM {

    public static void main(String[] args) throws InterruptedException {
        int cpuCores = 8;  // 模拟 8 核

        ThreadPoolExecutor pool = new ThreadPoolExecutor(
            cpuCores * 10,                    // 80
            cpuCores * 20,                    // 160
            60, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(),       // 无界队列(关键!)
            r -> {
                Thread t = new Thread(r);
                t.setName("notify-pool-" + t.getId());
                return t;
            },
            new ThreadPoolExecutor.CallerRunsPolicy()
        );

        // 模拟批量发通知:瞬间提交 50000 个任务
        System.out.println("提交 50000 个任务...");
        for (int i = 0; i < 50000; i++) {
            pool.execute(() -> {
                try {
                    Thread.sleep(5000);  // 每个任务执行 5 秒
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        System.out.println("当前线程数: " + pool.getPoolSize());
        System.out.println("队列任务数: " + pool.getQueue().size());
        System.out.println("已完成任务数: " + pool.getCompletedTaskCount());

        pool.shutdown();
        pool.awaitTermination(1, TimeUnit.HOURS);
    }
}

运行结果(JVM 参数:-Xmx256m):

makefile 复制代码
提交 50000 个任务...
当前线程数: 80
队列任务数: 49920

注意:线程数只有 80(corePoolSize),49920 个任务全堆在无界队列里。因为队列永远不会满,maxPoolSize 的 160 根本没机会触发,CallerRunsPolicy 也不会触发。

任务对象本身不大,但 50000 个堆在一起,加上每个任务闭包捕获的上下文(用户 ID、通知模板等),堆内存扛不住,最终 Java heap space OOM。

3.2 为什么会 OOM

逐步分析线程池的行为:

阶段 1:核心线程没满(0~80)

前 80 个任务进来,线程池创建 80 个核心线程直接执行。没问题。

阶段 2:核心线程满了,任务进队列(80~50000)

第 81 个任务进来,80 个核心线程在忙,任务被放进队列。这里是关键:我们用的是 new LinkedBlockingQueue<>()没传容量参数,默认容量是 Integer.MAX_VALUE(约 21 亿)

队列永远不满,任务就一直往里塞。maxPoolSize 的 160 根本没机会触发(要队列先满才会创建非核心线程),CallerRunsPolicy 也不会触发(要线程数达到 maxPoolSize 且队列满才会拒绝)。

50000 个任务提交完,80 个线程在跑,49920 个堆在队列里。每个任务对象不大,但 5 万个加起来,再算上每个任务闭包捕获的上下文(用户 ID、通知模板等),堆就爆了。

很多人以为配了 maxPoolSize 和拒绝策略就安全了。但 JDK 线程池的规则是:队列不满就不扩容,不扩容就不拒绝 。无界队列永远不会满,所以 maxPoolSize 和拒绝策略形同虚设。这也是阿里规约禁止用 Executors.newFixedThreadPool() 的原因,它内部用的就是无界队列。


四、问题根源:无界队列 + corePoolSize 过大

网上流传最广的线程池配置公式:

ini 复制代码
CPU 密集型:corePoolSize = CPU 核数 + 1
IO 密集型:corePoolSize = CPU 核数 × 2

或者更"精细"的:

ini 复制代码
corePoolSize = CPU 核数 × (1 + 线程等待时间 / 线程 CPU 时间)

我们的配置 cpuCores * 10 是按"IO 密集型,放大一点"来配的。但问题在于:

4.1 无界队列让 maxPoolSize 和拒绝策略形同虚设

new LinkedBlockingQueue<>() 不传容量,默认 Integer.MAX_VALUE。线程池的扩容规则是"队列满了才创建非核心线程",无界队列永远不满,所以 maxPoolSize=160 和 CallerRunsPolicy 根本没机会生效。50000 个任务全进了队列,堆爆了。

这是最常见的坑:很多人从网上抄配置,队列随手写 new LinkedBlockingQueue<>(),以为有 maxPoolSize 兜底,实际上兜底机制根本没触发。

4.2 corePoolSize 过大也浪费内存

就算队列有界,corePoolSize=80 也不小。每个线程默认 1MB 栈空间(-Xss1m),80 个核心线程就是 80MB 栈内存,加上 JVM 其他开销,在容器内存受限时也是压力。IO 密集型任务用 CPU × 2(16 个)足够了。

4.3 有界队列 + CallerRunsPolicy 也有坑

如果你改用了有界队列(比如 1000)+ CallerRunsPolicy,确实不会无界堆积了。但 CallerRunsPolicy 有个前提:提交任务的线程数量可控

如果只有一个提交线程(比如定时任务),CallerRunsPolicy 没问题,它会让提交线程自己去执行任务,起到背压限流作用。但如果多个请求线程同时提交,每个线程都可能被叫去执行任务,并发线程数 = maxPoolSize + 提交线程数。不过在 maxPoolSize=160 的情况下,即使多个线程提交,总线程数也就 160 + N,不至于到 5 万。真正的 5 万级堆积,只有无界队列才会发生。


五、怎么避免这类 OOM

方案 1:根据任务类型正确配置线程池

java 复制代码
// ✅ 正确配置:根据实际任务类型和流量模型来配
@Bean("notifyThreadPool")
public ThreadPoolExecutor notifyThreadPool() {
    int cpuCores = Runtime.getRuntime().availableProcessors();
    
    // IO 密集型 + 突发批量场景
    // 核心线程数不宜过大,靠队列缓冲
    return new ThreadPoolExecutor(
        cpuCores * 2,                        // 16 个核心线程
        cpuCores * 4,                        // 32 个最大线程
        60, TimeUnit.SECONDS,
        new LinkedBlockingQueue<>(500),      // 队列缩小,减少内存压力
        new ThreadFactoryBuilder()
            .setNameFormat("notify-pool-%d").build(),
        new ThreadPoolExecutor.CallerRunsPolicy()
    );
}

核心线程从 80 降到 16,最大线程从 160 降到 32,关键是队列从无界改成有界 500。无界队列是这次 OOM 的主因,有界队列直接堵住这个口子。

代价:处理速度变慢,50000 个通知要排队。但发通知不是实时接口,慢一点不会出事。

方案 2:用有界队列 + AbortPolicy,配合业务重试

java 复制代码
// ✅ 有界队列 + AbortPolicy,拒绝的任务走重试
return new ThreadPoolExecutor(
    16, 32, 60, TimeUnit.SECONDS,
    new LinkedBlockingQueue<>(500),
    new ThreadFactoryBuilder().setNameFormat("notify-pool-%d").build(),
    new ThreadPoolExecutor.AbortPolicy()  // 拒绝时抛异常,业务层捕获后重试或降级
);

业务层处理:

java 复制代码
public void batchSendNotify(List<Long> userIds) {
    List<Long> failed = new ArrayList<>();
    for (Long userId : userIds) {
        try {
            pool.execute(() -> sendNotify(userId));
        } catch (RejectedExecutionException e) {
            failed.add(userId);  // 暂存失败的任务
        }
    }
    // 失败的任务延迟重试,或者写入 MQ 异步处理
    if (!failed.isEmpty()) {
        retryQueue.addAll(failed);
    }
}

AbortPolicy 的好处是拒绝行为可控,你知道哪些任务被拒绝了,可以重试。CallerRunsPolicy 的问题是它"悄悄"让调用线程执行,在高并发下会失控。

方案 3:用消息队列削峰,别让线程池扛突发流量

java 复制代码
// ✅ 突发批量场景,用 MQ 削峰
public void batchSendNotify(List<Long> userIds) {
    for (Long userId : userIds) {
        rabbitTemplate.convertAndSend("notify.queue", userId);  // 先写 MQ
    }
}

// 消费端按固定速率消费
@RabbitListener(queues = "notify.queue", concurrency = "8-16")
public void consumeNotify(Long userId) {
    sendNotify(userId);  // MQ 控制并发,不需要自己管线程池
}

这是最推荐的方式。 50000 个通知先写进 MQ,消费端按 8-16 个并发慢慢处理。线程池/MQ 消费者的并发数完全可控,不会因为突发流量 OOM。

如果你不想引入 MQ,方案 1 + 方案 2 的组合也够用。

方案 4:限制提交速率

java 复制代码
// ✅ 用 Semaphore 限制并发提交数
private final Semaphore semaphore = new Semaphore(100);  // 最多 100 个任务在途

public void batchSendNotify(List<Long> userIds) {
    for (Long userId : userIds) {
        try {
            semaphore.acquire();
            pool.execute(() -> {
                try {
                    sendNotify(userId);
                } finally {
                    semaphore.release();
                }
            });
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            break;
        }
    }
}

Semaphore 保证同一时间最多 100 个任务在途(线程池里 + 队列里),从源头限流。


六、CheckList:线程池上线前排查

# 检查项 风险点 正确做法
1 corePoolSize 过大 线程栈内存消耗大 IO 密集型用 CPU×2,不要 ×10
2 无界队列 任务堆积导致 OOM 用有界队列,设定明确上限
3 CallerRunsPolicy + 多线程提交 调用方被叫去执行任务,并发失控 单线程提交可用,多线程提交换 AbortPolicy
4 突发批量任务直接提交线程池 瞬间打满线程池 + 队列 用 MQ 削峰或 Semaphore 限流
5 没有监控 线程池满了不知道 监控活跃线程数、队列大小、拒绝次数
6 线程池没有命名 出问题无法定位 ThreadFactory 设置线程名前缀

七、总结

回到这次 OOM:线程池用了无界队列 new LinkedBlockingQueue<>(),50000 个突发任务全部堆进队列,maxPoolSize 和拒绝策略根本没机会生效,最终堆内存撑爆。

记住这三点:

  1. 队列一定要有界new LinkedBlockingQueue<>() 不传容量就是无界,maxPoolSize 和拒绝策略全废了
  2. corePoolSize 不是越大越好,每个线程默认 1MB 栈空间,IO 密集型用 CPU×2 够了
  3. 突发批量任务别直接砸线程池,用 MQ 削峰或 Semaphore 限流,从源头控制

配置公式记一个就够了:

CPU 密集型:N+1(N = CPU 核数) IO 密集型:2N 突发批量:2N + 有界队列 + MQ 削峰


附录:本地复现完整代码

java 复制代码
import java.util.concurrent.*;

public class ThreadPoolOOM {

    public static void main(String[] args) throws InterruptedException {
        // 模拟出问题的配置:8核 × 10 = 80 核心线程
        int cpuCores = 8;
        ThreadPoolExecutor pool = new ThreadPoolExecutor(
            cpuCores * 10,                    // 80 核心线程
            cpuCores * 20,                    // 160 最大线程
            60, TimeUnit.SECONDS,
            new LinkedBlockingQueue<>(),      // 无界队列!(关键)
            r -> {
                Thread t = new Thread(r);
                t.setName("notify-pool-" + t.getId());
                return t;
            },
            new ThreadPoolExecutor.CallerRunsPolicy()
        );

        // 模拟突发批量:瞬间提交 50000 个任务
        System.out.println("提交 50000 个任务...");
        for (int i = 0; i < 50000; i++) {
            pool.execute(() -> {
                try {
                    Thread.sleep(5000);  // 每个任务 5 秒
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            });
        }

        // 打印线程池状态
        System.out.println("线程数: " + pool.getPoolSize());
        System.out.println("队列任务数: " + pool.getQueue().size());

        pool.shutdown();
        pool.awaitTermination(1, TimeUnit.HOURS);
    }
}

运行命令:

bash 复制代码
java -Xmx256m -Xms256m ThreadPoolOOM

建议本地跑一遍(本文基于 JDK 20 验证,JDK 8+ 均可复现)。

复现要点:关键是队列用 new LinkedBlockingQueue<>()(无界),50000 个任务全部进队列堆积。如果把队列改成有界(比如 1000),任务会在 1160 个时触发 CallerRunsPolicy 限流,不会堆积到 5 万。


你的线程池是怎么配的?评论区聊聊你的配置方案。

觉得有用的话点个赞 + 收藏,下次配线程池翻出来参考。


系列导航

本文是「Java 生产环境踩坑实录」系列第 7 篇,往期回顾:

下篇预告:用了 2 年 @Async 才知道:它默认不复用线程,随时 OOM


系列持续更新中,关注公众号。

相关推荐
渣波1 小时前
拒绝 SQL 焦虑!手把手带你用 NestJS + Prisma + DTO 写出“防弹”级后端代码
javascript·数据库·后端
用户61541317281272 小时前
# 写接口自动化时,我在断言上栽过的两个跟头
后端
SamDeepThinking2 小时前
Java微服务练习方式
java·后端·微服务
IT_陈寒2 小时前
Vue的响应式真把我坑惨了,原来问题出在这
前端·人工智能·后端
codedx3 小时前
LangChain 和 LangGraph 构建的 Agent 项目模版
后端·langchain·agent
葫芦和十三3 小时前
图解 MongoDB 08|ESR 原则:复合索引的字段顺序怎么定
后端·mongodb·agent
葫芦和十三11 小时前
图解 MongoDB 07|索引类型:七种索引,七种访问形状
后端·mongodb·agent
朦胧之12 小时前
AI 编程-老项目改造篇
java·前端·后端
爱勇宝15 小时前
我做了一个只用来搜歌词的小 App
android·前端·后端