线程池 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 和拒绝策略根本没机会生效,最终堆内存撑爆。
记住这三点:
- 队列一定要有界 ,
new LinkedBlockingQueue<>()不传容量就是无界,maxPoolSize 和拒绝策略全废了- corePoolSize 不是越大越好,每个线程默认 1MB 栈空间,IO 密集型用 CPU×2 够了
- 突发批量任务别直接砸线程池,用 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 篇,往期回顾:
- 第 1 篇:Redis 分布式锁的正确姿势:你写的可能是"假锁"
- 第 2 篇:用了 3 年 Spring Boot 才发现:@Transactional 的 7 个坑
- 第 3 篇:ThreadLocal 内存泄漏:你的应用正在悄悄 OOM
- 第 4 篇:MySQL 间隙锁是怎么"悄悄"制造死锁的
- 第 5 篇:唯一索引并发插入为什么也会死锁
- 第 6 篇:MySQL RR 隔离级幻读真相
- 第 7 篇:线程池参数配错致 OOM(本文)
下篇预告:用了 2 年 @Async 才知道:它默认不复用线程,随时 OOM
系列持续更新中,关注公众号。