Skip to content

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

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

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

一、先说结论 ​

  • 线程池有两个职责:复用昂贵的平台线程,以及用有限的并发保护本服务和下游。后者在虚拟线程时代依然存在。
  • 参数要成组看:corePoolSize、maximumPoolSize、队列类型与容量、拒绝策略、任务超时,单独调一个没有意义。
  • 公式只给压测起点:CPU 密集型从核数附近开始;I/O 密集型按等待时间与计算时间之比放大,同时受下游容量封顶。
  • 最终值由指标决定:p99 延迟、排队时间、CPU 利用率、拒绝次数和下游错误率。
  • 最危险的组合:无界队列、无超时、多种任务共用一个池、没有拒绝指标。

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

很多人以为线程数会先涨到 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。
  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 做重要任务时,要显式配置队列容量或并发上限。

三、估算起点:两个公式怎么用 ​

3.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」的来源:多出的一个线程用来弥补偶发的缺页等停顿。

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

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

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

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

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

3.3 别忘了下游天花板 ​

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

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

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

四、队列与拒绝策略 ​

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

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

接口超时是 1 秒、池子吞吐 200 个/秒,那么队列超过 200 时,排在后面的任务即使执行了,调用方也早就超时放弃了,这些执行纯属浪费。队列容量应该由延迟预算推出来,而不是随手写 1000。

四种内置拒绝策略的取舍:

策略行为适合风险
AbortPolicy(默认)抛出 RejectedExecutionException在线请求,快速失败并返回降级结果调用方必须处理异常
CallerRunsPolicy提交线程自己执行批处理、离线任务,形成天然反压拖慢 Tomcat 请求线程或 MQ 消费线程,可能级联
DiscardPolicy静默丢弃几乎不推荐丢了都不知道
DiscardOldestPolicy丢弃队头最老的任务再重试提交只关心最新值的场景,如刷新状态同样静默,且与优先级队列一起用会丢掉最重要的任务

无论选哪种,都要给拒绝计数打点。可以包装一个自定义 RejectedExecutionHandler,先记录指标再委托给内置策略。

五、按任务类型隔离 ​

下面这些任务不应该共用一个池:

  • 毫秒级的缓存查询与秒级的第三方 HTTP 调用;
  • 在线请求与批量导出、报表;
  • 核心链路与非核心链路(如下单与推荐)。

共用的问题是队头阻塞:某个第三方接口变慢,占满了所有线程,本来很健康的缓存查询也排不上队。拆分之后,每个池可以单独设置容量、超时和熔断,一个依赖出问题只影响自己那部分功能。这就是舱壁隔离(Bulkhead)模式。

拆分也有代价:池越多,总线程数越多,闲置越多。实践中按「依赖 × 重要性」分出几个池就够了,不必一个接口一个池。

六、虚拟线程之后还要线程池吗 ​

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();
    }
}

CPU 密集型任务仍然适合用固定大小的平台线程池,虚拟线程在这里没有收益。迁移的完整讨论见 虚拟线程迁移。

七、线上要观察什么 ​

只看「活跃线程数」无法判断池子是否健康,至少要有下面这些指标:

指标获取方式说明问题
活跃线程数 / 最大线程数getActiveCount() / getMaximumPoolSize()长期接近 1 说明容量不足
队列长度getQueue().size()持续增长说明处理速度跟不上
排队时间提交时记录时间戳,执行前计算差值比队列长度更直接地反映用户感知的延迟
拒绝次数自定义 RejectedExecutionHandler 计数非零就需要关注来源
任务执行 p99包装 Runnable 计时区分「排队慢」和「执行慢」
下游连接池等待HikariCP 的 pending 指标等线程池瓶颈可能其实在连接池

使用 Micrometer 时,ExecutorServiceMetrics.monitor(...) 可以直接把前几项暴露出来,排队时间通常需要自己包装任务来采集。

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

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

九、常见误区 ​

  • 「最大线程数设大一点更安全」:队列无界时它根本不生效;队列有界时,过多线程会增加上下文切换并把压力转嫁给下游。
  • 「CPU 利用率低说明线程不够」:也可能是线程都在等锁或连接池,加线程只会更糟。
  • 「用 Executors 工厂方法很方便」:newFixedThreadPool 和 newSingleThreadExecutor 的队列无界,newCachedThreadPool 的线程数没有上限,在线服务中都要慎用。
  • 「参数定一次就不用管」:下游变慢、流量结构变化都会让原来的最优值失效。

小结 ​

线程池参数没有通用的正确数字。可靠的做法是:理解「核心线程 → 队列 → 最大线程 → 拒绝」的接收顺序,用等待比和吞吐目标算出起点,用下游容量封顶,用延迟预算约束队列,最后用压测和线上指标确认。把这条链路走通,比记住任何公式都可靠。

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


配套实验

参考资料

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