线程池用了LinkedBlockingQueue没设容量,把整台机器搞OOM了
凌晨运维群炸了,一条告警:订单服务响应超时率飙到 80%,紧接着 JVM 挂掉,Pod 重启。
上去一看,OOM 前老年代被打满了。heap dump 里最大的对象是一个 LinkedBlockingQueue,里面堆了 300 多万个任务对象,光队列就占了将近 2G。
翻代码,线程池是这么配的:
ExecutorService executor = new ThreadPoolExecutor(
10, // corePoolSize
20, // maximumPoolSize
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>() // ← 没设容量,默认 Integer.MAX_VALUE
);
10 个核心线程忙不过来的时候,任务进了队列。队列没有上限,上游的定时任务每 10 毫秒投递一个任务,半小时就塞了 300 万个。
线程在消费,队列在膨胀——消费速度跟不上生产速度,OOM 只是时间问题。
这就是 LinkedBlockingQueue 最坑人的地方:默认构造是无界的。
一、为什么无界队列是定时炸弹
new LinkedBlockingQueue<>() 等价于 new LinkedBlockingQueue<>(Integer.MAX_VALUE)。
Integer.MAX_VALUE = 2147483647,21 亿个任务。你的机器可能在 300 万的时候就 OOM 了——离天花板远得很,但内存早爆了。
无界队列还有第二个问题:它让 maximumPoolSize 成了摆设。
ThreadPoolExecutor 的逻辑是:任务来了 → 核心线程满了 → 进队列 → 队列满了 → 开新线程到 maximumPoolSize → 线程也满了 → RejectedExecutionHandler。
队列无界意味着"队列满了"这个条件永远不会触发。线程数永远不超过 corePoolSize 的 10 个,你设的 maximumPoolSize=20 形同虚设。
等于你用 ThreadPoolExecutor 配了半天的参数,实际跑起来就是 10 个固定线程 + 一个无限膨胀的队列。和 Executors.newFixedThreadPool(10) 没任何区别,还多了一层你会以为"队列满了会自动扩线程"的错觉。
二、正确的线程池配置
丢掉 Executors 工具类,丢掉无参 LinkedBlockingQueue。每个线程池都显式配完所有参数:
@Bean("seckillExecutor")
public ThreadPoolExecutor seckillExecutor() {
ThreadPoolExecutor executor = new ThreadPoolExecutor(
10, // corePoolSize
20, // maximumPoolSize
60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(5000), // ← 显式设容量
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略
);
executor.allowCoreThreadTimeOut(true);
return executor;
}
五个参数每个都要想清楚:
corePoolSize=10:日常 10 个线程常驻。根据业务 QPS × 平均 RT 算出来——如果每秒 200 个任务,平均 RT 50ms,10 个线程刚好够(200×0.05=10)。
maximumPoolSize=20:高峰时最多 20 个线程。别设太大——线程不是免费的,每个线程 1MB 栈空间,20 个就是 20MB。再加上线程上下文切换,40 个线程可能比 20 个更慢。
队列容量=5000:队列到 5000 就触发"线程不够——开新线程"的流程。为什么是 5000?因为 5000 个任务 × 50ms RT ÷ 10 线程 = 25 秒。意味着最坏情况下,一个任务在队列里最多等 25 秒才会被执行。如果你的业务能接受 25 秒延迟,5000 就合理。
拒绝策略=CallerRunsPolicy:队列满、线程也满,任务在哪执行?让提交任务的线程自己跑。这种策略相当于"自动限流"——提交线程被迫自己干活,生产速度自然降下来。
拒绝之后记一条 WARN 日志 + 打监控指标:
executor.setRejectedExecutionHandler((r, e) -> {
rejectedCounter.increment(); // 上监控
log.warn("线程池[seckill]已满, active={}, queue={}",
executor.getActiveCount(), executor.getQueue().size());
new ThreadPoolExecutor.CallerRunsPolicy()
.rejectedExecution(r, e);
});
三、队列容量怎么算
公式:
队列容量 = (最大可接受延迟秒数 × 核心线程数) ÷ 平均任务耗时秒数
举例:
- 业务能接受的最大延迟:30 秒
- 核心线程数:10
- 平均任务耗时:50ms = 0.05 秒
队列容量 = (30 × 10) ÷ 0.05 = 6000
反过来验证:6000 个任务 ÷(10 个线程 × 每秒 20 个)= 30 秒——等 30 秒正好是瓶颈。
如果你希望延迟更低(比如 5 秒),队列应该更小:
队列容量 = (5 × 10) ÷ 0.05 = 1000
队列小了,满了怎么办?要么开新线程(maximumPoolSize > corePoolSize),要么拒绝。拒绝比 OOM 强一万倍。
四、必须上的监控
线程池配好了不监控,等于没配。三个核心指标:
@Scheduled(fixedRate = 10_000)
public void monitorThreadPool() {
ThreadPoolExecutor pool = seckillExecutor();
// 1. 队列堆积量:最关键的告警指标
int queueSize = pool.getQueue().size();
meterRegistry.gauge("threadpool.queue.size", queueSize);
if (queueSize > pool.getQueue().remainingCapacity() * 0.7) {
log.warn("线程池队列即将满载: {}/{}",
queueSize, queueSize + pool.getQueue().remainingCapacity());
alertService.send("线程池堆积告警", "seckillExecutor 队列使用率超 70%");
}
// 2. 活跃线程数:判断是否需要调大 corePoolSize
int activeCount = pool.getActiveCount();
meterRegistry.gauge("threadpool.active", activeCount);
// 3. 拒绝次数
long completedTasks = pool.getCompletedTaskCount();
meterRegistry.gauge("threadpool.completed", completedTasks);
}
告警分层:
| 条件 | 级别 | 动作 |
|---|---|---|
| 队列使用率 > 70% | WARNING | 通知开发群,关注趋势 |
| 队列使用率 > 90% | CRITICAL | 通知 on-call,准备扩容 |
| 持续拒绝 > 5 分钟 | CRITICAL | 上游限流或临时扩线程 |
| 活跃线程 = maximumPoolSize 且队列持续增长 | CRITICAL | 线程数和队列全满了,马上处理 |
线程池监控最怕的就是告警不及时——队列从 0 涨到 100 可能只需要几秒,如果监控是每分钟跑一次,可能还没采到数据已经 OOM 了。建议 10 秒采一次。
五、不同场景的策略选择
不是所有场景都适合 CallerRunsPolicy。四种常见策略怎么选:
| 策略 | 行为 | 适合场景 | 不适合 |
|---|---|---|---|
| AbortPolicy | 抛异常 | 任务不能丢,需要上游感知失败 | 用户请求直接报 500 |
| CallerRunsPolicy | 提交线程自己跑 | 任务必须执行完,延迟可接受 | 不允许限流的场景 |
| DiscardPolicy | 静默丢弃 | 非关键任务(日志、统计) | 核心业务 |
| DiscardOldestPolicy | 丢最老的 | 最新数据比历史重要 | 幂等性有要求的任务 |
对于订单、支付这类核心业务链路,用 CallerRunsPolicy——既不会丢任务,又能自然限流。
对于上报监控数据、打点统计这类,用 DiscardPolicy——丢了不心疼。
对于行情推送、实时数据这种"要最新的不要最老的",用 DiscardOldestPolicy。
还有个坑:AbortPolicy 的异常如果不处理,会在提交线程里爆出来。 如果提交线程是 tomcat worker 线程,用户看到的 500 是 RejectedExecutionException,不是你的业务异常。至少要包一层 try-catch 把异常转成业务友好的提示。
六、Executors 工具类的坑
Executors.newFixedThreadPool(10) 和 Executors.newCachedThreadPool() 为什么不推荐用:
// newFixedThreadPool 的源码
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()); // ← 无界队列
}
// newCachedThreadPool 的源码
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>()); // ← 无限线程
}
newFixedThreadPool 有前文说的无界队列问题。
newCachedThreadPool 走另一个极端——队列是 SynchronousQueue(容量 0),但最大线程数是 Integer.MAX_VALUE。来一个任务开一个线程。如果 1000 个任务同时到达,就开 1000 个线程——CPU 上下文切换先把你机器拖死。
两个都是阿里规约里明确禁止的。Alibaba Java Coding Guidelines 第 6 条:
线程池不允许使用 Executors 去创建,而是通过 ThreadPoolExecutor 的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。
七、一个线程池配置自检清单
每个线程池上线前,过一遍:
- LinkedBlockingQueue 是否显式设置了容量?
- 队列容量是否通过公式计算过,而不是拍脑袋?
- corePoolSize 和 maximumPoolSize 是否经过了压测验证?
- 拒绝策略是否选对了(CallerRuns vs Abort vs Discard)?
- 是否接入了队列使用率监控和告警?
- 线程是否有命名(ThreadFactory 传 prefix),方便 jstack 排查?
- 是否设置了 allowCoreThreadTimeOut(如果希望空闲时回收核心线程)?
- 如果用了第三方库的线程池(如 Dubbo 的线程池),是否检查过它的默认配置?
线程池是服务端的肌肉,队列是血管。血管堵了,肌肉再强也没用。
你的项目跑过多少个线程池了,有检查过那些 LinkedBlockingQueue 有没有设容量吗?评论区报一下,看看有多少定时炸弹。
标题:线程池用了LinkedBlockingQueue没设容量,把整台机器搞OOM了
作者:jiangyi
地址:http://jiangyi.space/articles/2026/06/23/1782013222319.html
公众号:服务端技术精选
评论