Skip to content

线程池参数怎么定:先测等待比,再压测验证 ​

「CPU 核数 + 1」「2 × 核数」只能当起点,不能当答案。线程池大小取决于任务在等待什么、下游能承受多少并发,以及你愿意让请求排队多久。

说到线程池参数,最常见的依据是两个公式;可一旦问起「线上这个 200 是怎么来的」,往往说不清。把参数定对,需要先搞清三件事:ThreadPoolExecutor 按什么顺序接收任务,线程池除了复用线程还承担着限流职责,以及怎样用数据证明一个参数合理。

一、任务来了,线程池按什么顺序处理 ​

很多人以为线程数会先涨到 maximumPoolSize,再开始排队。实际顺序正好相反:

提交任务线程数 < core ?队列未满 ?线程数 < max ?拒绝拒绝策略否否否新建核心线程进入队列等待扩容至 max是是是示例:任务 1–2示例:任务 3–4示例:任务 5–6示例:任务 7
图 1 · 任务先占满核心线程,再进入队列,队列满了才扩到最大线程数,最后执行拒绝策略。示例与下文实测一致(core = 2、队列容量 2、max = 4)

下面这段代码可以直接验证(JDK 21,Thread.ofPlatform() 是 JDK 21 的 API):

java
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):

text
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 个线程。由此得出两个推论:

  1. 队列无界时 maximumPoolSize 形同虚设。 队列永远放得下,线程数停在 corePoolSize,压力全部变成排队延迟,严重时变成 OOM。Executors.newFixedThreadPool 用的就是无界的 LinkedBlockingQueue。反过来,newCachedThreadPool 的线程数没有上限,在线服务中同样要慎用。最大线程数也不是设大一点更安全:队列有界时,过多线程会增加上下文切换,并把压力转嫁给下游。
  2. 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 并发编程实战》中给出的估算方式可以写成:

text
线程数 ≈ 可用核数 × 目标 CPU 利用率 × (1 + 等待时间 / 计算时间)

举例:8 核机器,目标 CPU 利用率 70%,单个任务 RT 100ms,其中 CPU 计算 10ms、等待下游 90ms:

text
8 × 0.7 × (1 + 90 / 10) = 56

这里的难点不是乘法,而是等待时间和计算时间从哪里来:

  • 用 APM 或链路追踪的 span 拆分:远程调用、数据库、锁等待都算等待;
  • 或者用 JFR 看线程在 RUNNABLE 与 WAITING / TIMED_WAITING 状态上的时间分布;
  • 注意 GC、序列化、日志格式化也吃 CPU,不要只算业务逻辑。

纯 CPU 密集型任务(等待比接近 0)代入后就是「核数 × 利用率」,这也是「核数 + 1」的来源:多出的一个线程用来弥补偶发的缺页等停顿。

2.2 按吞吐目标反推并发数 ​

Little's Law(利特尔法则)从另一个方向估算:

text
系统中同时在处理的任务数 L = 到达速率 λ × 平均处理时间 W

目标 400 QPS、平均 RT 100ms,稳态下同时在处理的请求约 400 × 0.1 = 40 个。线程数低于 40 就必然排队;高于 40 很多也用不上。

两个公式得到的数字应该互相印证:按等待比算出 56,按吞吐算出 40,那么 40—60 是合理的压测区间。差得很远时,通常说明等待比测得不准,或者瓶颈在别处。

2.3 别忘了下游天花板 ​

线程数再合理,也不能超过下游容量:

  • 任务要访问数据库,而连接池只有 20 个连接,超过 20 的线程只会在连接池上等待;
  • 第三方接口限制 50 QPS,线程数和 RT 决定的发送速率不能超过它;
  • 多个实例共享同一个下游时,要按实例数 × 单机并发计算总量。

因此实际取值是 min(公式估算, 下游可承受并发)。

三、队列与拒绝策略 ​

队列容量决定「最多让请求等多久」,可以反推:

text
最大排队时间 ≈ 队列容量 / 线程池吞吐量

接口超时是 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() 也应该带超时,超时之后的处理见超时了、取消了,任务其实还在跑。

固定线程池 · 2 个线程线程 1:父任务 Aget() 等子任务 a线程 2:父任务 Bget() 等子任务 b队列子任务 a子任务 b父任务占着线程等子任务,子任务没有线程可用子任务提交到500 ms 内完成同一个池0/2单独的池2/2父任务用虚拟线程2/2触发条件是同时运行的父任务数达到线程数:平时正常,高峰期才出现;调大线程数只是推迟触发点
图 2 · 实测(JDK 25.0.4):2 个线程的固定线程池里,2 个父任务各向同一个池提交一个子任务并 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 密集型任务可以一个任务一个虚拟线程。但线程池的第二个职责——限流——并没有消失,只是需要显式实现:

java
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(...) 可以直接把前几项暴露出来,排队时间通常需要自己包装任务来采集。

七、一套可执行的调参流程 ​

  1. 分类:列出服务中的异步任务,按依赖和重要性分组,决定拆几个池。
  2. 测等待比:用链路追踪或 JFR 拿到每类任务的计算时间与等待时间。
  3. 算起点:用等待比公式和 Little's Law 各算一次,与下游容量取较小值。
  4. 定队列:由接口超时和池子吞吐反推队列上限,选择拒绝策略并打点。
  5. 压测:以起点值为中心上下各取几档,记录 p99、CPU、排队时间、错误率。发压方式与计时起点会直接改变 p99,见 固定 10 个并发压测,服务卡了 2 秒,报告里只有 10 个慢请求。
  6. 选拐点:取 p99 开始明显上升之前的那一档,并留出余量。
  7. 上线观察:至少覆盖一个完整的业务高峰周期;参数最好支持动态调整(setCorePoolSize 等方法可在运行时调用)。
  8. 定期复核:下游变慢、流量结构变化都会让原来的最优值失效,下游容量或任务类型变化后重新走一遍。

多线程之间如何安全地共享状态,见 JMM 与 happens-before。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权