线程池参数怎么定:先测等待比,再压测验证
「CPU 核数 + 1」「2 × 核数」只能当起点,不能当答案。线程池大小取决于任务在等待什么、下游能承受多少并发,以及你愿意让请求排队多久。
说到线程池参数,最常见的依据是两个公式;可一旦问起「线上这个 200 是怎么来的」,往往说不清。把参数定对,需要先搞清三件事:ThreadPoolExecutor 按什么顺序接收任务,线程池除了复用线程还承担着限流职责,以及怎样用数据证明一个参数合理。
一、任务来了,线程池按什么顺序处理
很多人以为线程数会先涨到 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。反过来,newCachedThreadPool的线程数没有上限,在线服务中同样要慎用。最大线程数也不是设大一点更安全:队列有界时,过多线程会增加上下文切换,并把压力转嫁给下游。 - 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做重要任务时,要显式配置队列容量或并发上限。
二、估算起点:两个公式怎么用
2.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」的来源:多出的一个线程用来弥补偶发的缺页等停顿。
2.2 按吞吐目标反推并发数
Little's Law(利特尔法则)从另一个方向估算:
系统中同时在处理的任务数 L = 到达速率 λ × 平均处理时间 W目标 400 QPS、平均 RT 100ms,稳态下同时在处理的请求约 400 × 0.1 = 40 个。线程数低于 40 就必然排队;高于 40 很多也用不上。
两个公式得到的数字应该互相印证:按等待比算出 56,按吞吐算出 40,那么 40—60 是合理的压测区间。差得很远时,通常说明等待比测得不准,或者瓶颈在别处。
2.3 别忘了下游天花板
线程数再合理,也不能超过下游容量:
- 任务要访问数据库,而连接池只有 20 个连接,超过 20 的线程只会在连接池上等待;
- 第三方接口限制 50 QPS,线程数和 RT 决定的发送速率不能超过它;
- 多个实例共享同一个下游时,要按实例数 × 单机并发计算总量。
因此实际取值是 min(公式估算, 下游可承受并发)。
三、队列与拒绝策略
队列容量决定「最多让请求等多久」,可以反推:
最大排队时间 ≈ 队列容量 / 线程池吞吐量接口超时是 1 秒、池子吞吐 200 个/秒,那么队列超过 200 时,排在后面的任务即使执行了,调用方也早就超时放弃了,这些执行纯属浪费。**队列容量应该由延迟预算推出来,而不是随手写 1000。**队列容量只限制了排队的任务;任务被取出之后如果又交给别的线程异步处理,那部分数量不受它约束,见队列设了上限,内存还是涨上去了。
四种内置拒绝策略的取舍:
| 策略 | 行为 | 适合 | 风险 |
|---|---|---|---|
AbortPolicy(默认) | 抛出 RejectedExecutionException | 在线请求,快速失败并返回降级结果 | 调用方必须处理异常 |
CallerRunsPolicy | 提交线程自己执行 | 批处理、离线任务,形成天然反压 | 拖慢 Tomcat 请求线程或 MQ 消费线程,可能级联 |
DiscardPolicy | 静默丢弃 | 几乎不推荐 | 丢了都不知道 |
DiscardOldestPolicy | 丢弃队头最老的任务再重试提交 | 只关心最新值的场景,如刷新状态 | 同样静默,且与优先级队列一起用会丢掉最重要的任务 |
无论选哪种,都要给拒绝计数打点。可以包装一个自定义 RejectedExecutionHandler,先记录指标再委托给内置策略。
四、按任务类型隔离
下面这些任务不应该共用一个池(parallelStream() 与不传执行器的 supplyAsync 默认共用的那一个也算在内,见一个 parallelStream,拖慢了不相干的接口):
- 毫秒级的缓存查询与秒级的第三方 HTTP 调用;
- 在线请求与批量导出、报表;
- 核心链路与非核心链路(如下单与推荐)。
共用的问题是队头阻塞:某个第三方接口变慢,占满了所有线程,本来很健康的缓存查询也排不上队。拆分之后,每个池可以单独设置容量、超时和熔断,一个依赖出问题只影响自己那部分功能。这就是舱壁隔离(Bulkhead)模式。
拆分也有代价:池越多,总线程数越多,闲置越多。实践中按「依赖 × 重要性」分出几个池就够了,不必一个接口一个池。
4.1 父任务不要在同一个池里等子任务
还有一种任务必须分池:会向线程池提交子任务、并且等子任务结果的任务。实测(JDK 25.0.4)2 个线程的固定线程池里,2 个父任务各自向同一个池提交一个子任务并 get():
| 子任务提交到 | 500ms 内完成的父任务 |
|---|---|
| 同一个池 | 0/2(活跃线程 2,队列里等待的任务 2) |
| 单独的池 | 2/2 |
| 每任务一个虚拟线程 | 2/2 |
两个线程都被父任务占着,等的是排在队列里的子任务,而子任务没有线程可用。没有锁参与,线程转储里也看不到死锁,只有一组线程停在 FutureTask.get。它的触发条件是「同时运行的父任务数达到线程数」,所以平时正常,高峰期才出现;把线程数调大只是推迟触发点。做法是让等待方和被等待方不共用线程:子任务用单独的池,或者父任务用虚拟线程。get() 也应该带超时,超时之后的处理见超时了、取消了,任务其实还在跑。
get(),500 毫秒内完成 0/2。没有锁参与,线程转储里也看不到死锁,只有一组线程停在 FutureTask.get。子任务改用单独的池或父任务用虚拟线程,2/2 完成4.2 线程是复用的,ThreadLocal 要清理
池里的线程会依次执行不同请求的任务,线程上的 ThreadLocal 不会自动清空。实测单线程池上:
| 写法 | 下一个任务读到 |
|---|---|
任务 A set("alice") 后不清理 | alice |
任务在 finally 里 remove() | null |
ScopedValue.where(USER, "carol").run(…) | 未绑定 |
第一行意味着上一个请求的用户身份、租户、链路追踪 ID 会出现在下一个请求里。凡是在池化线程上设置 ThreadLocal,都要在 try/finally 里清理;嵌套设置时恢复旧值,而不是直接 remove()。JDK 25 起 ScopedValue 是正式 API(JEP 506),绑定只在指定的代码块内有效,结束后自动解除,新代码传递请求上下文可以优先用它。请求上下文在线程池、消息等边界上丢失或串到别的请求的后果,见 压测流量的标记,过了线程池就没了。
五、虚拟线程之后还要线程池吗
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();
}
}信号量的获取与释放要严格配对,写错的后果见信号量有 2 个许可,两个线程却拿到了同一个连接。
CPU 密集型任务仍然适合用固定大小的平台线程池,虚拟线程在这里没有收益。迁移的完整讨论见 虚拟线程迁移。
运行中调整线程数时,参数写进去了却不起作用的几种情况,见配置中心显示已推送,线上行为没有变。
六、线上要观察什么
只看「活跃线程数」无法判断池子是否健康,至少要有下面这些指标:
| 指标 | 获取方式 | 说明问题 |
|---|---|---|
| 活跃线程数 / 最大线程数 | getActiveCount() / getMaximumPoolSize() | 长期接近 1 说明容量不足 |
| 队列长度 | getQueue().size() | 持续增长说明处理速度跟不上 |
| 排队时间 | 提交时记录时间戳,执行前计算差值 | 比队列长度更直接地反映用户感知的延迟 |
| 拒绝次数 | 自定义 RejectedExecutionHandler 计数 | 非零就需要关注来源 |
| 任务执行 p99 | 包装 Runnable 计时 | 区分「排队慢」和「执行慢」 |
| 下游连接池等待 | HikariCP 的 pending 指标等 | 线程池瓶颈可能其实在连接池 |
| CPU 利用率 | 进程与容器的 CPU 指标 | 利用率低不一定是线程不够,也可能是线程都在等锁或连接池,加线程只会更糟 |
使用 Micrometer 时,ExecutorServiceMetrics.monitor(...) 可以直接把前几项暴露出来,排队时间通常需要自己包装任务来采集。
七、一套可执行的调参流程
- 分类:列出服务中的异步任务,按依赖和重要性分组,决定拆几个池。
- 测等待比:用链路追踪或 JFR 拿到每类任务的计算时间与等待时间。
- 算起点:用等待比公式和 Little's Law 各算一次,与下游容量取较小值。
- 定队列:由接口超时和池子吞吐反推队列上限,选择拒绝策略并打点。
- 压测:以起点值为中心上下各取几档,记录 p99、CPU、排队时间、错误率。发压方式与计时起点会直接改变 p99,见 固定 10 个并发压测,服务卡了 2 秒,报告里只有 10 个慢请求。
- 选拐点:取 p99 开始明显上升之前的那一档,并留出余量。
- 上线观察:至少覆盖一个完整的业务高峰周期;参数最好支持动态调整(
setCorePoolSize等方法可在运行时调用)。 - 定期复核:下游变慢、流量结构变化都会让原来的最优值失效,下游容量或任务类型变化后重新走一遍。
多线程之间如何安全地共享状态,见 JMM 与 happens-before。
配套实验
- codesphere-labs/java/thread-pool-order:
core=2、max=4、队列容量 2 时 7 个任务的去向,输出与期望逐行比较(验证记录) - codesphere-labs/java/pool-starvation-and-threadlocal:JDK 25.0.4 上父任务在同一个池里等子任务、子任务分池与虚拟线程的对照,以及复用线程上的
ThreadLocal残留与ScopedValue(验证记录) - 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」