线程池不是越大越好:队列、拒绝与背压
从 ThreadPoolExecutor 的任务接收顺序入手,理解有界队列为什么是系统容量的一部分。
请求变慢时,很容易想把线程数调大。可如果真正的瓶颈是数据库连接、下游配额或 CPU,更多线程可能只是制造更多等待。线程池首先是资源管理工具,其次才是并发执行的便利接口。
先看清任务接收的顺序
对一个运行中的 ThreadPoolExecutor,可以先用四个步骤理解其核心接收逻辑:工作线程少于 corePoolSize 时尝试创建线程;否则尝试入队;队列无法接收时,才尝试增加线程直到 maximumPoolSize;仍然无法接收就执行拒绝策略。实际实现还会重新检查运行状态,处理关闭等并发变化。
因此,使用几乎不会满的无界队列时,maximumPoolSize 往往不能像想象中那样发挥扩容作用,而排队时间和内存占用可能持续增长。
一个明确限制容量的配置
下面的数字仅用于演示配置关系,不是面向所有服务的推荐参数。示例适用于 Java 17 及以上。
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
public class PoolDemo {
public static void main(String[] args) throws InterruptedException {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 8, 30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(32),
new ThreadPoolExecutor.AbortPolicy()
);
try {
for (int i = 0; i < 10; i++) {
final int job = i;
pool.execute(() -> System.out.println("job " + job));
}
} finally {
pool.shutdown();
if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
}
}
}这里队列最多暂存 32 个任务。AbortPolicy 在饱和或关闭时抛出 RejectedExecutionException。上层需要把这个信号转成明确行为,例如快速失败、可控重试或返回“系统繁忙”,而不是无上限地重新提交。生产代码还需要正确处理中断和应用关闭生命周期。
CallerRunsPolicy 为什么不能到处套用
CallerRunsPolicy 在池仍运行时会让提交任务的线程执行任务,让生产者慢下来。这有时形成背压,但也意味着调用线程会阻塞。如果提交方是事件循环线程,或者持有重要的锁,这可能放大延迟甚至引发其他问题。线程池关闭时,该策略会丢弃任务,也需要纳入业务语义。
比线程数更值得盯住的指标
- 排队时长:任务提交到开始执行之间等了多久。
- 执行时长与下游等待:线程忙着计算,还是在等网络和连接?
- 队列深度、拒绝次数与超时率:容量不足何时开始出现?
- 端到端延迟分位数:平均值正常时,尾部请求可能已经不可接受。
调参时固定输入负载,逐项改变配置,再观察吞吐、延迟和资源占用。别把一次本地跑得更快,直接当成生产环境的容量结论。