“为什么要用线程池"这个问题,答案不只是"复用线程省创建开销”。更深一层:线程是稀缺且昂贵的系统资源(每个线程默认占约 1MB 栈空间,还有内核调度成本),线程池的本质是给并发装一个限流闸门 + 缓冲队列 + 拒绝策略,让系统在过载时优雅降级而不是直接崩。本文拆解 ThreadPoolExecutor 的核心机制和参数到底该怎么配。
机制:七个参数与任务的流转逻辑
ThreadPoolExecutor 的构造函数有七个参数,但只要理解任务进来后的流转判断顺序,这些参数就都活了:
1 | new ThreadPoolExecutor( |
一个任务 execute 进来,决策流程是:
1 | 任务到达 |
这里有个最反直觉的点:线程池优先把任务塞进队列,而不是优先扩容到 maximumPoolSize。也就是说,只有当队列满了,才会去创建核心线程之外的线程。如果你用了一个无界队列(如默认的 LinkedBlockingQueue),队列永远不会满,maximumPoolSize 这个参数就完全失效了,池子永远只有 corePoolSize 个线程在干活,任务无限堆积,最终 OOM。这是线上最常见的坑之一。
源码:核心状态用一个 AtomicInteger 表达
ThreadPoolExecutor 用一个 32 位的 AtomicInteger 同时编码线程池状态(高 3 位)和工作线程数(低 29 位):
1 | private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0)); |
状态机:RUNNING(接收新任务并处理队列) → SHUTDOWN(不接新任务但处理完队列) → STOP(不接新任务且丢弃队列、中断正在执行的) → TIDYING → TERMINATED。shutdown() 走的是 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.newFixedThreadPool和newSingleThreadExecutor用的是无界 LinkedBlockingQueue(可堆积 Integer.MAX_VALUE 个任务),newCachedThreadPool的maximumPoolSize是 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、命名线程、按业务隔离池。