线程池用了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
公众号:服务端技术精选
    评论
    0 评论
avatar

取消