VOL.001 · 创刊号Token 天团 API

Token 天团

—— 一份关于代码与生活的个人刊物 ——

技术Java教程

线程池:别再 Executors 了——Java 工兵连第 1 篇

「Java 工兵连」开栏第 1 篇。这个专栏讲 Java 的深水区——并发、JVM、集合、Spring——每篇只讲透一件事,讲完你能背下来。开栏选题给了线程池,因为它是面试考得最多、生产炸得最疼、误解也最深的一个。

一、先建直觉:一家银行网点

把线程池想成一家银行网点,任务就是进门的客户:

  • 核心柜员(corePoolSize):网点常驻员工,开门就上班,没客户也留着;
  • 大厅排队区(workQueue):柜员全忙时,客户取号入座等待;
  • 临时窗口(maximumPoolSize):排队区坐满了,经理才会拉桌子开临时窗口;
  • 保安拦人(拒绝策略):连临时窗口都满了,保安出面——婉拒、让你自己看着办、或者请最早排队的客户先回去。

日常代码里 Executors.newFixedThreadPool(10) 一把梭的人,就像只告诉银行「给我留 10 个柜员」,其他全按默认来——而默认,恰恰是出事的地方。

二、四步走:一次提交的完整旅程

线程池处理任务的四步决策:核心线程、队列、临时线程、拒绝策略,附口诀

execute(task) 提交任务后,JDK 的决策顺序是固定的四步:

  1. 线程数 < corePoolSize? → 新建一个核心线程执行。注意:哪怕现有线程有空闲,只要没到核心数,照样新建——先把编制用满。
  2. 核心满了? → 尝试入队(workQueue)。任务在大厅排队,不新增人手。
  3. 队列也满了? → 线程数 < maximumPoolSize 就新建临时线程救急。
  4. 临时线程也到上限了? → 触发拒绝策略。

最容易记反的是第 2、3 步的顺序:是「队列满了才开临时窗口」,不是「先开满窗口再让人排队」。核心与最大线程数之间的差额(max − core),只在队列爆了之后才启用。所以口诀背这个:

先核心,再排队;队满了,开临时;满上满,才拒绝。

临时线程有下岗机制:闲超过 keepAliveTime 就回收;allowCoreThreadTimeOut(true) 甚至能让核心柜员也下班——低流量省资源,但下次任务来了要重新付「建线程」的成本,慎用。

三、Executors 的两颗地雷:都是「无界」惹的祸

《阿里巴巴 Java 开发手册》有一条【强制】:线程池不允许使用 Executors 创建,必须手动 new ThreadPoolExecutor。不是规范洁癖,是两颗真实的 OOM 地雷:

地雷一:newFixedThreadPool / newSingleThreadExecutor —— 任务堆积型 OOM。 它们用的队列是 LinkedBlockingQueue,容量默认 Integer.MAX_VALUE,约等于无界。上游一旦生产快于消费,任务在队列里无声堆积,直到堆内存耗尽。这类故障最阴险:没有任何报错,服务只是越来越慢,最后 OOM。

地雷二:newCachedThreadPool / newScheduledThreadPool —— 线程爆炸型 OOM。 它们的 maximumPoolSize 是 Integer.MAX_VALUE。突发流量下线程无限新建,每个线程默认栈约 1MB,几千个线程就能把内存吃穿,还会把 CPU 耗在无意义的上下文切换上。

两颗雷的共性值得记住:不限界的资源,迟早吞掉你的堆。队列如此,线程如此,后面讲 MQ、讲缓存淘汰,你会反复遇到这句话。

四、正确姿势:七个参数 + 有界队列

ThreadPoolExecutor pool = new ThreadPoolExecutor(
        8,                                      // corePoolSize:常驻柜员
        16,                                     // maximumPoolSize:含临时窗口的总上限
        60, TimeUnit.SECONDS,                   // keepAliveTime:临时线程的下班时限
        new ArrayBlockingQueue<>(500),          // 有界队列!大厅只有 500 个座位
        new ThreadFactory() {                   // 命名:出事时日志里找得到人
            private final AtomicInteger seq = new AtomicInteger();
            public Thread newThread(Runnable r) {
                return new Thread(r, "order-pool-" + seq.incrementAndGet());
            }
        },
        new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:见下文,按业务选
);

三个细节说下「为什么」:

  • 有界队列是灵魂。队列满了才有机会触发「开临时线程」和「拒绝策略」这两个保护动作——无界队列等于把保险丝焊死。
  • 线程命名不是美化。线上 CPU 飙高时,jstack 抓线程栈,命名清晰的池一眼定位,匿名 pool-3-thread-1 时代查问题全靠猜。
  • 拒绝策略四种,各有用途:
策略 行为 适合
AbortPolicy(默认) 抛 RejectedExecutionException 快速失败,上游可感知、可重试
CallerRunsPolicy 提交线程自己执行 天然反压——用上游的速度拖慢自己
DiscardPolicy 静默丢弃 几乎不适用:丢任务无感知
DiscardOldestPolicy 丢队首最老任务再重试 只关心最新值的场景(行情、心跳)

生产环境的答案往往是第五种:自定义——实现 RejectedExecutionHandler,记日志、打告警指标,能降级则降级(落库补偿、返回兜底结果)。CallerRunsPolicy 用得最多但要清醒:它把任务塞回 Tomcat 工作线程执行,等于让 Web 线程替线程池打工,下游越慢、Web 层越堵。

五、参数怎么定:公式是起点,压测是裁判

教科书公式:CPU 密集型 ≈ 核数 + 1;IO 密集型 ≈ 核数 × 2 起步(更细的版本是 N × (1 + 等待时间/计算时间))。方向没错,但别当真理——真实业务的「等待/计算比」只有压测知道。定参的务实路径:公式给初值 → 压测观察吞吐和延迟 → 调整复测。

另外记住两个救命的运行时能力:setCorePoolSize() 和 setMaximumPoolSize() 可以在运行中调整。大促前临时扩容、出事后紧急缩容,不用重启(业界「动态线程池」的思路正是把它接进配置中心 + 监控告警)。

老兵点评

写了十五年代码,关于线程池我的三个立场:

  1. 线程池的本质不是「多线程」,是「资源隔离 + 流量整形」。它是你挡在自身与流量之间的堤坝:队列削峰、上限保命、拒绝降级。想通这一点,参数怎么定自然就懂了——堤坝修多大,取决于洪水多大、下游河道多宽,不取决于公式。
  2. 池要按业务分,不要全局一个池。订单、积分、通知各用各的池。共用一个池等于所有楼层共用一根下水管——楼下装修堵了管,整栋楼停水。隔离的是爆炸半径。
  3. 一个没监控的线程池等于盲开。至少盯三个指标:活跃线程数、队列深度、拒绝次数。队列深度持续上涨就是积压前兆,拒绝次数大于零就该收到告警了。

顺带补一颗 Spring 的暗雷:@Async 默认用的 SimpleAsyncTaskExecutor 根本不是池——每个任务新建线程。用 @Async 必须自定义 ThreadPoolTaskExecutor,否则等于把地雷埋进了注解里。

带走三句话

  1. 四步走:先核心,再排队;队满了,开临时;满上满,才拒绝——顺序别记反。
  2. Executors 的原罪是无界:队列无界堆任务,线程无界炸内存;手写七参数 + 有界队列 + 命名工厂。
  3. 拒绝策略不是「选一个」,是设计一段降级逻辑;池按业务分,监控盯三样:活跃数、队列深度、拒绝数。

本篇是「Java 工兵连」第 1 篇。→ 继续阅读:第 2 篇 · HashMap 的扩容与树化 | 浏览整个专栏 →。流程图为本刊依据 JDK ThreadPoolExecutor#execute 源码自绘。

❦ 终 ❦

← 回到首页全部归档 →