“为什么要用线程池"这个问题,答案不只是"复用线程省创建开销”。更深一层:线程是稀缺且昂贵的系统资源(每个线程默认占约 1MB 栈空间,还有内核调度成本),线程池的本质是给并发装一个限流闸门 + 缓冲队列 + 拒绝策略,让系统在过载时优雅降级而不是直接崩。本文拆解 ThreadPoolExecutor 的核心机制和参数到底该怎么配。

机制:七个参数与任务的流转逻辑

ThreadPoolExecutor 的构造函数有七个参数,但只要理解任务进来后的流转判断顺序,这些参数就都活了:

1
2
3
4
5
6
7
8
9
new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maximumPoolSize, // 最大线程数
keepAliveTime, // 非核心线程空闲存活时间
unit,
workQueue, // 任务队列
threadFactory, // 线程工厂(给线程命名很重要)
handler // 拒绝策略
);

一个任务 execute 进来,决策流程是:

1
2
3
4
5
6
7
8
9
任务到达

├─ 当前线程数 < corePoolSize ? ──是──> 直接新建核心线程执行
│ 否
├─ 队列没满 ? ─────────────────是──> 入队等待
│ 否
├─ 当前线程数 < maximumPoolSize ?─是──> 新建非核心线程执行
│ 否
└─> 触发拒绝策略 handler

这里有个最反直觉的点:线程池优先把任务塞进队列,而不是优先扩容到 maximumPoolSize。也就是说,只有当队列满了,才会去创建核心线程之外的线程。如果你用了一个无界队列(如默认的 LinkedBlockingQueue),队列永远不会满,maximumPoolSize 这个参数就完全失效了,池子永远只有 corePoolSize 个线程在干活,任务无限堆积,最终 OOM。这是线上最常见的坑之一。

源码:核心状态用一个 AtomicInteger 表达

ThreadPoolExecutor 用一个 32 位的 AtomicInteger 同时编码线程池状态(高 3 位)和工作线程数(低 29 位):

1
2
3
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static int runStateOf(int c) { return c & ~CAPACITY; }
private static int workerCountOf(int c){ return c & CAPACITY; }

状态机:RUNNING(接收新任务并处理队列) → SHUTDOWN(不接新任务但处理完队列) → STOP(不接新任务且丢弃队列、中断正在执行的) → TIDYINGTERMINATEDshutdown() 走的是 SHUTDOWN(优雅),shutdownNow() 走 STOP(强制)。

每个工作线程被包成一个 Worker,它本身继承了 AQS 实现了一把不可重入锁,核心循环是 runWorker:不停地从队列 getTask() 取任务并 run(),取不到任务(且允许回收)时线程退出。keepAliveTime 就作用在这个 getTask()poll(timeout) 上——空闲超时取不到任务,非核心线程就被回收。

拒绝策略:过载时的最后防线

队列满 + 线程数到顶,新任务交给 RejectedExecutionHandler,JDK 内置四种:

  • AbortPolicy(默认):直接抛 RejectedExecutionException。能让调用方立刻感知过载。
  • CallerRunsPolicy:让提交任务的线程自己执行这个任务。这等于给上游加了反压(back-pressure)——提交方被拖慢,自然降低提交速率。处理突发流量时往往是最稳的选择。
  • DiscardPolicy:默默丢弃,不报错。慎用,容易丢数据无感知。
  • DiscardOldestPolicy:丢掉队列里最老的那个,再尝试入队。

参数调优:CPU 密集 vs IO 密集

参数没有标准答案,取决于任务类型。一个经典的经验起点:

  • CPU 密集型(加密、计算、压缩):线程多了只会增加上下文切换,corePoolSize ≈ CPU 核数 + 1。多出来的 1 是为了在偶发缺页/中断时仍能压满 CPU。
  • IO 密集型(网络调用、DB 查询、文件):线程大部分时间在等 IO,可以开更多。一个参考公式:
1
线程数 ≈ CPU 核数 × (1 + 平均等待时间 / 平均计算时间)

等待占比越高,系数越大。比如一次外部 HTTP 调用 90% 时间在等响应,理论上可以开到核数的约 10 倍。但这只是起点,真正的依据是压测:看 CPU 利用率、RT、队列堆积情况,逐步逼近。

队列大小同样关键:队列太小,稍有突发就触发拒绝;队列太大,任务长时间排队导致 RT 飙升、还掩盖了线程数不足的问题,极端情况 OOM。有界队列 + 合理拒绝策略几乎总是优于无界队列。

常见误区与踩坑

  • 用 Executors 工厂方法图省事:Executors.newFixedThreadPoolnewSingleThreadExecutor 用的是无界 LinkedBlockingQueue(可堆积 Integer.MAX_VALUE 个任务),newCachedThreadPoolmaximumPoolSize 是 Integer.MAX_VALUE(可创建近乎无限线程)。两者都可能 OOM。生产环境一律手动 new ThreadPoolExecutor,自己定有界队列和拒绝策略。

  • 线程不命名:出问题 dump 线程栈时全是 pool-1-thread-3,根本分不清是哪个业务池。务必通过 threadFactory 给线程起有意义的名字。

  • 异常被吞:通过 execute 提交的任务抛异常会导致该 Worker 线程退出并新建一个,异常打到默认 handler;通过 submit 提交的任务异常被包进 Future,你不调 future.get()永远看不到这个异常,排查时一脸懵。

  • 核心线程默认不回收:核心线程即使空闲也常驻。若想让核心线程也能在空闲时回收,需 allowCoreThreadTimeOut(true)

  • 共用一个池:把 RPC 调用、DB 查询、日志写入全塞进同一个线程池,一个慢任务拖垮所有。按业务隔离线程池(舱壁模式),避免相互影响。

小结

线程池的核心是一套"先建核心线程 → 再入队 → 再扩到最大线程 → 最后拒绝"的流转逻辑,而最大的陷阱是无界队列让 maximumPoolSize 失效。参数调优没有银弹:CPU 密集贴近核数,IO 密集按等待比例放大,但都要用有界队列、明确的拒绝策略和压测数据来兜底。记住三条铁律:手动 new、命名线程、按业务隔离池。