线程池为什么不扩容:队列、拒绝与过载边界
线程池达到核心线程数后优先入队,入队失败才尝试扩到最大线程数;队列与线程都无法接纳时才拒绝。 因而把最大线程数调大,未必改善积压。过载控制需要同时约束排队量、等待时间和下游并发,并把拒绝结果交还业务调用方。
适用版本与边界
本文按 OpenJDK 21 的 ThreadPoolExecutor 与平台线程说明,使用标准队列和正常创建线程的线程工厂。不直接套用到 ForkJoinPool、定时线程池或虚拟线程执行器;框架包装的执行器还需检查实际队列与拒绝处理器。代码参数仅供解释规则,不是生产推荐值。
1. 任务到底先排队还是先创建线程?
在运行状态下,工作线程少于 corePoolSize 时先尝试创建线程;达到核心数后调用队列的 offer。入队失败,再尝试按 maximumPoolSize 创建线程,失败则进入拒绝处理。成功入队后还会复查关闭状态,必要时移除并拒绝任务。可对照 OpenJDK 21u 的 execute 实现。
这里用的是非阻塞 offer,不是等待空位的 put。“使用阻塞队列”不表示提交线程会自动等到入队成功。
| 队列选择 | 核心线程忙时 | 主要代价 |
|---|---|---|
| 未指定容量的 LinkedBlockingQueue | 通常持续排队,最大线程数难以发挥作用 | 接近无界的积压占用内存并拉长等待 |
| ArrayBlockingQueue 等有界队列 | 队列满后才尝试增加非核心线程 | 容量需要结合延迟预算和下游负载确定 |
| SynchronousQueue | 没有内部存储容量,无法立即交接便尝试增线程 | 有限线程上限下更早拒绝,不能靠队列吸收突发 |
JDK 21 API说明了队列与线程数的关系。OpenJDK 的 Executors 实现中,newFixedThreadPool 使用未指定容量的 LinkedBlockingQueue;固定线程数并不等于固定积压量。
2. 如何构造可解释的饱和时序?
下面的 Java 片段已在本机 Windows、Microsoft Build of OpenJDK 21.0.12+8-LTS 中验证一次:片段保持原样,仅增加导入、完整类与 main 方法外壳,在临时目录编译执行。编译与运行退出码均为 0,运行 stderr 为空。
可下载 完整源码 和 原始 stdout 复核;使用 javac 时将源码另存为 ThreadPoolOverloadVerification.java。门闩让任务持续占用工作线程,避免用睡眠猜测提交顺序。
var gate = new CountDownLatch(1);
var pool = new ThreadPoolExecutor(
2, 4, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
new ThreadPoolExecutor.AbortPolicy());
Runnable task = () -> {
try {
gate.await();
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
}
};
try {
for (int i = 1; i <= 7; i++) {
try {
pool.execute(task);
} catch (RejectedExecutionException ex) {
System.out.println("rejected task " + i);
}
}
} finally {
gate.countDown();
pool.shutdown();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
}本次原始 stdout 只有 rejected task 7,与第 7 项被拒绝的预期一致。若线程正常创建且门闩未放行,按规则推导:任务 1、2 分配给核心线程,3、4 排队,5、6 分配给新增线程。片段没有记录各线程分配和任务开始顺序,这部分仍是规则推导。任务 5 可能早于任务 3 开始,FIFO 队列也不保证整个线程池按提交顺序执行。本次结果仅验证该饱和示例,不代表持续超载或性能测试。
3. 拒绝策略为什么会影响请求线程?
AbortPolicy 抛出 RejectedExecutionException,入口应将其映射为明确的繁忙响应或可恢复状态;不能捕获后仍返回“任务成功”。对于必须完成的任务,应先可靠落盘再调度,内存队列不能承担持久化职责。
CallerRunsPolicy 在提交线程执行任务,能使同步生产者降速,但也把耗时转移到请求线程。若提交发生在事件循环中,可能阻塞其他请求;执行器已关闭时它会丢弃任务,详见 CallerRunsPolicy API。
拒绝处理器正常返回,不一定表示任务已经被接纳。submit 默认把任务包装为 FutureTask 后交给 execute,因此拒绝策略也决定等待方能否得到结果。下面对照标准 ThreadPoolExecutor 的默认包装行为:
| 拒绝策略 | 饱和时的行为 | 对 submit 调用方的影响 |
|---|---|---|
AbortPolicy | 抛出拒绝异常 | submit 直接抛异常,不返回 Future |
CallerRunsPolicy | 未关闭时由提交线程执行 | submit 要等该任务执行结束才返回;关闭时丢弃,返回的 Future 可能一直未完成 |
DiscardPolicy | 静默丢弃新任务 | 返回的 Future 不会因丢弃而自动完成或取消 |
DiscardOldestPolicy | 未关闭时移除队头,再重试新任务 | 被移除任务的 Future 可能一直未完成;关闭时直接丢弃新任务 |
包装机制见 JDK 21 AbstractExecutorService,策略行为见前述线程池 API;DiscardOldestPolicy 官方说明还明确建议考虑取消被移除的 Future 并记录失败。等待方需要超时和失败处理,但仅设置等待超时不会替被丢弃的任务完成 Future。依赖每项结果的业务应优先让拒绝显式可见。
4. 队列容量应该依据什么?
队列只吸收短时突发,持续到达量超过处理能力时,再大也会耗尽。可先用稳定吞吐量估算排队预算:假设固定 8 个工作线程、每项耗时 50 毫秒且无竞争,理想处理能力约每秒 160 项。停止新任务流入时,80 项积压约需半秒消化;若仍以每秒 120 项持续接纳任务,净消化速率只有每秒 40 项,清空这批积压约需 2 秒。这里的清空时间与某个新任务的排队等待时间不是同一个指标。接纳速率达到或超过处理速率时,积压无法持续下降;有界队列只能把问题转为拒绝,不能提高处理能力。这是固定速率下的简化推导,并非实测数据;长尾、任务差异和下游限流都会改变等待时间,应通过测量校准。
把队列等待、执行和下游调用纳入同一个请求截止时间,出队时检查剩余预算,避免处理调用方已经放弃的任务。Future.get(timeout) 仅限制等待,不会自动停止任务;取消与中断也需要任务和客户端配合,参见 OpenJDK Future 契约。
5. 排障时按什么顺序检查?
- 确认核心数、最大数、队列容量、拒绝策略与关闭状态,先区分容量拒绝和关闭拒绝。
- 同时记录活跃线程、积压、完成速率、拒绝数,以及从提交到开始执行的等待分布。
- 检查线程栈、连接池等待与下游超时;线程都在等待连接时,扩线程可能只增加争抢。
- 在测试环境分别制造瞬时突发、持续超载、慢下游与关闭期间提交,核对失败可见、内存有界和恢复过程。这些步骤尚未在本文环境执行。
还要避免所有工作线程都提交子任务到同一池并同步等待。例如固定大小的线程池中,父任务占满工作线程,子任务又全部成功入队:父任务等待子任务结果,子任务等待工作线程释放,双方便无法推进。队列仍可接纳时,调高 maximumPoolSize 也不会触发扩容;原因仍是先入队的规则。JDK 21 线程池 API 的队列说明专门讨论了存在内部依赖的任务及直接交接的取舍。SynchronousQueue 可以避免这类排队,但线程上限耗尽时仍会拒绝,不能替代依赖设计。可改为异步组合,或让子任务使用有独立容量预算的执行器,再决定限流位置。
