线程池参数怎么定:先测等待比,再压测验证
「CPU 核数 + 1」「2 × 核数」只能当起点,不能当答案。线程池大小取决于任务在等待什么、下游能承受多少并发,以及你愿意让请求排队多久。
说到线程池参数,最常见的依据是两个公式;可一旦问起「线上这个 200 是怎么来的」,往往说不清。把参数定对,需要先搞清三件事:ThreadPoolExecutor 按什么顺序接收任务,线程池除了复用线程还承担着限流职责,以及怎样用数据证明一个参数合理。
一、先说结论
- 线程池有两个职责:复用昂贵的平台线程,以及用有限的并发保护本服务和下游。后者在虚拟线程时代依然存在。
- 参数要成组看:
corePoolSize、maximumPoolSize、队列类型与容量、拒绝策略、任务超时,单独调一个没有意义。 - 公式只给压测起点:CPU 密集型从核数附近开始;I/O 密集型按等待时间与计算时间之比放大,同时受下游容量封顶。
- 最终值由指标决定:p99 延迟、排队时间、CPU 利用率、拒绝次数和下游错误率。
- 最危险的组合:无界队列、无超时、多种任务共用一个池、没有拒绝指标。
二、任务来了,线程池按什么顺序处理
很多人以为线程数会先涨到 maximumPoolSize,再开始排队。实际顺序正好相反:
下面这段代码可以直接验证(JDK 21,Thread.ofPlatform() 是 JDK 21 的 API):
var executor = new ThreadPoolExecutor(
2, 4, // core = 2, max = 4
30, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2), // 队列容量 2
Thread.ofPlatform().name("price-", 0).factory(),
new ThreadPoolExecutor.AbortPolicy());
var latch = new CountDownLatch(1); // 让所有任务都卡住,便于观察
for (int i = 1; i <= 7; i++) {
try {
executor.execute(() -> {
try { latch.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
});
System.out.printf("task %d -> poolSize=%d queue=%d%n",
i, executor.getPoolSize(), executor.getQueue().size());
} catch (RejectedExecutionException e) {
System.out.printf("task %d -> rejected%n", i);
}
}
latch.countDown();
executor.shutdown();实际输出(JDK 21.0.5):
task 1 -> poolSize=1 queue=0
task 2 -> poolSize=2 queue=0
task 3 -> poolSize=2 queue=1
task 4 -> poolSize=2 queue=2
task 5 -> poolSize=3 queue=2
task 6 -> poolSize=4 queue=2
task 7 -> rejected第 3、4 个任务宁可排队也没有新建线程,直到队列满了,第 5、6 个任务才扩到 4 个线程。由此得出两个推论:
- 队列无界时
maximumPoolSize形同虚设。 队列永远放得下,线程数停在corePoolSize,压力全部变成排队延迟,严重时变成 OOM。Executors.newFixedThreadPool用的就是无界的LinkedBlockingQueue。 - Spring Boot 的默认异步执行器也是这样。 自动配置的
ThreadPoolTaskExecutor默认core-size为 8,queue-capacity不设置时为无界,官方属性说明写得很清楚:队列无界时max-size被忽略。在 Spring Boot 4.1.1 上实测,向默认执行器提交 50 个卡住的@Async任务,线程数停在 8,另外 42 个在队列里排队。开启spring.threads.virtual.enabled后,默认执行器换成SimpleAsyncTaskExecutor,50 个任务同时在虚拟线程上运行,没有任何上限。用@Async做重要任务时,要显式配置队列容量或并发上限。
三、估算起点:两个公式怎么用
3.1 按等待比估算单机线程数
Brian Goetz 在《Java 并发编程实战》中给出的估算方式可以写成:
线程数 ≈ 可用核数 × 目标 CPU 利用率 × (1 + 等待时间 / 计算时间)举例:8 核机器,目标 CPU 利用率 70%,单个任务 RT 100ms,其中 CPU 计算 10ms、等待下游 90ms:
8 × 0.7 × (1 + 90 / 10) = 56这里的难点不是乘法,而是等待时间和计算时间从哪里来:
- 用 APM 或链路追踪的 span 拆分:远程调用、数据库、锁等待都算等待;
- 或者用 JFR 看线程在
RUNNABLE与WAITING / TIMED_WAITING状态上的时间分布; - 注意 GC、序列化、日志格式化也吃 CPU,不要只算业务逻辑。
纯 CPU 密集型任务(等待比接近 0)代入后就是「核数 × 利用率」,这也是「核数 + 1」的来源:多出的一个线程用来弥补偶发的缺页等停顿。
3.2 按吞吐目标反推并发数
Little's Law(利特尔法则)从另一个方向估算:
系统中同时在处理的任务数 L = 到达速率 λ × 平均处理时间 W目标 400 QPS、平均 RT 100ms,稳态下同时在处理的请求约 400 × 0.1 = 40 个。线程数低于 40 就必然排队;高于 40 很多也用不上。
两个公式得到的数字应该互相印证:按等待比算出 56,按吞吐算出 40,那么 40—60 是合理的压测区间。差得很远时,通常说明等待比测得不准,或者瓶颈在别处。
3.3 别忘了下游天花板
线程数再合理,也不能超过下游容量:
- 任务要访问数据库,而连接池只有 20 个连接,超过 20 的线程只会在连接池上等待;
- 第三方接口限制 50 QPS,线程数和 RT 决定的发送速率不能超过它;
- 多个实例共享同一个下游时,要按实例数 × 单机并发计算总量。
因此实际取值是 min(公式估算, 下游可承受并发)。
四、队列与拒绝策略
队列容量决定「最多让请求等多久」,可以反推:
最大排队时间 ≈ 队列容量 / 线程池吞吐量接口超时是 1 秒、池子吞吐 200 个/秒,那么队列超过 200 时,排在后面的任务即使执行了,调用方也早就超时放弃了,这些执行纯属浪费。队列容量应该由延迟预算推出来,而不是随手写 1000。
四种内置拒绝策略的取舍:
| 策略 | 行为 | 适合 | 风险 |
|---|---|---|---|
AbortPolicy(默认) | 抛出 RejectedExecutionException | 在线请求,快速失败并返回降级结果 | 调用方必须处理异常 |
CallerRunsPolicy | 提交线程自己执行 | 批处理、离线任务,形成天然反压 | 拖慢 Tomcat 请求线程或 MQ 消费线程,可能级联 |
DiscardPolicy | 静默丢弃 | 几乎不推荐 | 丢了都不知道 |
DiscardOldestPolicy | 丢弃队头最老的任务再重试提交 | 只关心最新值的场景,如刷新状态 | 同样静默,且与优先级队列一起用会丢掉最重要的任务 |
无论选哪种,都要给拒绝计数打点。可以包装一个自定义 RejectedExecutionHandler,先记录指标再委托给内置策略。
五、按任务类型隔离
下面这些任务不应该共用一个池:
- 毫秒级的缓存查询与秒级的第三方 HTTP 调用;
- 在线请求与批量导出、报表;
- 核心链路与非核心链路(如下单与推荐)。
共用的问题是队头阻塞:某个第三方接口变慢,占满了所有线程,本来很健康的缓存查询也排不上队。拆分之后,每个池可以单独设置容量、超时和熔断,一个依赖出问题只影响自己那部分功能。这就是舱壁隔离(Bulkhead)模式。
拆分也有代价:池越多,总线程数越多,闲置越多。实践中按「依赖 × 重要性」分出几个池就够了,不必一个接口一个池。
六、虚拟线程之后还要线程池吗
JDK 21 起,虚拟线程让「为了复用线程而池化」失去意义,I/O 密集型任务可以一个任务一个虚拟线程。但线程池的第二个职责——限流——并没有消失,只是需要显式实现:
private final Semaphore dbGate = new Semaphore(20); // 与数据库连接池大小对齐
Order load(long id) throws InterruptedException {
dbGate.acquire();
try {
return repository.find(id);
} finally {
dbGate.release();
}
}CPU 密集型任务仍然适合用固定大小的平台线程池,虚拟线程在这里没有收益。迁移的完整讨论见 虚拟线程迁移。
七、线上要观察什么
只看「活跃线程数」无法判断池子是否健康,至少要有下面这些指标:
| 指标 | 获取方式 | 说明问题 |
|---|---|---|
| 活跃线程数 / 最大线程数 | getActiveCount() / getMaximumPoolSize() | 长期接近 1 说明容量不足 |
| 队列长度 | getQueue().size() | 持续增长说明处理速度跟不上 |
| 排队时间 | 提交时记录时间戳,执行前计算差值 | 比队列长度更直接地反映用户感知的延迟 |
| 拒绝次数 | 自定义 RejectedExecutionHandler 计数 | 非零就需要关注来源 |
| 任务执行 p99 | 包装 Runnable 计时 | 区分「排队慢」和「执行慢」 |
| 下游连接池等待 | HikariCP 的 pending 指标等 | 线程池瓶颈可能其实在连接池 |
使用 Micrometer 时,ExecutorServiceMetrics.monitor(...) 可以直接把前几项暴露出来,排队时间通常需要自己包装任务来采集。
八、一套可执行的调参流程
- 分类:列出服务中的异步任务,按依赖和重要性分组,决定拆几个池。
- 测等待比:用链路追踪或 JFR 拿到每类任务的计算时间与等待时间。
- 算起点:用等待比公式和 Little's Law 各算一次,与下游容量取较小值。
- 定队列:由接口超时和池子吞吐反推队列上限,选择拒绝策略并打点。
- 压测:以起点值为中心上下各取几档,记录 p99、CPU、排队时间、错误率。
- 选拐点:取 p99 开始明显上升之前的那一档,并留出余量。
- 上线观察:至少覆盖一个完整的业务高峰周期;参数最好支持动态调整(
setCorePoolSize等方法可在运行时调用)。
九、常见误区
- 「最大线程数设大一点更安全」:队列无界时它根本不生效;队列有界时,过多线程会增加上下文切换并把压力转嫁给下游。
- 「CPU 利用率低说明线程不够」:也可能是线程都在等锁或连接池,加线程只会更糟。
- 「用
Executors工厂方法很方便」:newFixedThreadPool和newSingleThreadExecutor的队列无界,newCachedThreadPool的线程数没有上限,在线服务中都要慎用。 - 「参数定一次就不用管」:下游变慢、流量结构变化都会让原来的最优值失效。
小结
线程池参数没有通用的正确数字。可靠的做法是:理解「核心线程 → 队列 → 最大线程 → 拒绝」的接收顺序,用等待比和吞吐目标算出起点,用下游容量封顶,用延迟预算约束队列,最后用压测和线上指标确认。把这条链路走通,比记住任何公式都可靠。
多线程之间如何安全地共享状态,见 JMM 与 happens-before。
配套实验
- codesphere-labs/java/thread-pool-order:
core=2、max=4、队列容量 2 时 7 个任务的去向,输出与期望逐行比较(验证记录) - codesphere-labs/spring/async-executor-defaults:Spring Boot 默认异步执行器的参数与排队,开启虚拟线程后的执行器类型(验证记录)
参考资料
- ThreadPoolExecutor(JDK 21 API)
- Spring Boot:Task Execution and Scheduling
- Spring Boot 配置属性:
spring.task.execution.* - Brian Goetz 等,《Java Concurrency in Practice》,第 8 章「Applying Thread Pools」