一、"线程数该设多少":一个没有标准答案、但有方法论的问题
前面几篇讲清楚了 corePoolSize、maximumPoolSize、workQueue 各自的行为,也讲清楚了 execute 内部的判断顺序。但真正落到实践里,几乎每个人都会卡在同一个问题上:这几个参数到底该填多少?
网上流传最广的答案是几个"经验公式":CPU 密集型任务设成"CPU 核数 + 1",I/O 密集型任务设成"CPU 核数 × 2",甚至有人直接给一个万能数字,比如 200。这些数字不是完全没有道理,但如果不理解它们背后的假设,套用到自己的场景里往往是错的——"CPU 核数 × 2"这个"2"是怎么来的?如果你的接口下游依赖一个平均要等 500 毫秒的慢查询,这个"2"还成立吗?
这篇要讲的不是"记住哪个数字",而是两套可以自己推导数字的方法:一套是从"任务本身在等什么、算什么"出发的阻塞系数模型,另一套是从"系统整体吞吐和延迟"出发的排队论模型(Little 定律)。最后再讲清楚,为什么无论用哪套公式算出来的数字,都必须经过压测校准才能真正上生产。
在深入每一步细节之前,先看一张全景图,把整个估算方法论串起来:先判断任务是 CPU 密集还是 I/O 密集从而选定对应公式,再用 Little 定律从吞吐目标反推一遍做交叉验证,把算出的总量拆分到 corePoolSize、maximumPoolSize、workQueue 三个参数上,最后一定要经过压测校准才能定稿。

二、CPU 密集型任务:线程数为什么应该接近核数
先从最简单的场景说起。如果一个任务从头到尾都在做纯计算(比如复杂的数值运算、图像处理、加密解密),几乎不会主动让出 CPU 去等待任何外部资源,这类任务就是CPU 密集型。
对于这类任务,线程数的合理区间是接近但略高于 CPU 核数(常见写法是 Ncpu 或 Ncpu + 1)。原因很直接:操作系统同一时刻能真正并行执行的计算,受物理核数(或者更准确地说,是操作系统调度器可用的逻辑核数)硬性限制。如果开了远超核数的线程去跑纯计算任务,这些线程会在极少数的物理核心上反复争抢,操作系统需要不断地做上下文切换——保存一个线程的寄存器状态、恢复另一个线程的状态,这个切换过程本身消耗 CPU 时间,却不产生任何有效的计算结果。线程数超得越多,花在"切换"上的时间占比越高,实际吞吐反而会下降,这是一种典型的"过度并发反而拖慢整体"的现象。
那为什么很多经验公式里写的是"Ncpu + 1"而不是精确的"Ncpu"?这个 +1 是一种保险:即使是纯计算任务,也可能因为等待一次内存页失效(page fault)、或者被垃圾回收短暂挂起而临时让出 CPU,多准备一个线程,能在某个线程因为这类偶发情况暂停时,让 CPU 不至于完全空闲。这个 +1 不是严谨推导出来的数学结果,而是工程实践里对"理论值稍微留一点余量"的经验做法。

三、I/O 密集型任务:阻塞系数公式怎么来的
大多数实际业务场景并不是纯计算,而是I/O 密集型:一个请求进来,大部分时间花在等待数据库返回、等待下游接口响应、等待磁盘读写完成,真正占用 CPU 做计算的时间反而很短。这类任务的特点是:线程在等待 I/O 的这段时间,并不占用 CPU,操作系统可以把这段"空闲"的 CPU 时间调度给别的线程去用。这意味着 I/O 密集型场景下,线程数可以(也应该)远超 CPU 核数,才能把 CPU 的空闲时间利用起来。
具体该开多少线程,有一个经典的阻塞系数公式:
线程数 = CPU核数 × (1 + WT / CT)
其中 WT(Wait Time)是任务平均的等待时间(比如等一次数据库查询返回花了多久),CT(Compute Time)是任务真正占用 CPU 做计算的时间。这个公式背后的直觉是:如果一个任务等待的时间是计算时间的 N 倍,那么在"计算"这一个线程占用 CPU 的同时,理论上可以有 N 个正在"等待"的线程同时存在而不互相抢占 CPU——所以线程数应该按 (1 + WT/CT) 这个倍数去放大。
举个具体例子:一个接口平均耗时 100 毫秒,其中真正占用 CPU 计算(比如序列化、业务逻辑判断)只花了 10 毫秒,剩下 90 毫秒都在等数据库返回,WT/CT = 90/10 = 9。如果机器是 8 核,按公式算出来的线程数是 8 × (1 + 9) = 80。对比一下如果这个接口是纯计算、WT = 0,公式会退化成 8 × (1 + 0) = 8,跟上一节 CPU 密集型的结论完全吻合——这说明阻塞系数公式其实是一个更通用的模型,CPU 密集型只是它在 WT = 0 时的特例。
这个公式最大的实践难点不在公式本身,而在于**WT 和 CT 怎么measure出来**。精确的做法是借助 APM(应用性能监控)工具或者分布式追踪系统,把一次请求拆解成"CPU 上真正执行的时间"和"阻塞等待的时间"两段分别统计;简化的做法是直接看接口的平均响应时间(近似等于 WT + CT)和该接口的 CPU 耗时(可以用性能分析工具采样得到),两者一减一除就能估算出比例。这个比例值一定是从真实的、有代表性的流量里统计出来的,而不是拍脑袋猜的——同一个接口在缓存命中和缓存未命中两种情况下,WT 可能相差几十倍,用哪种情况的数据去套公式,结果会天差地别。

四、Little 定律:从"目标吞吐"反推线程数的另一条路
阻塞系数公式是从"单个任务内部的时间构成"出发的,还有一条完全不同的路径:从系统整体应该达到的吞吐量出发反推。这条路径依赖的是排队论里一个非常简洁的结论,叫Little 定律:
L = λ × W
L 是系统中平均同时存在的任务数量(这里可以理解为"平均需要多少个线程/工位同时在处理任务"),λ(lambda)是任务的平均到达速率(比如每秒到来多少个请求,也就是 QPS),W 是每个任务在系统里平均停留的时间(从进入系统到处理完成,包含排队等待和真正处理)。这个公式的神奇之处在于:它对任务到达的具体分布、处理时间的具体分布几乎没有任何假设,只要系统达到稳定状态,这个关系就必然成立,这也是它在排队论、生产调度、交通流量分析里都被广泛使用的原因。
放到线程池场景里,L 近似对应"需要多少个线程才能撑住这个吞吐"。举个例子:某个接口的目标 QPS 是 500,也就是 λ = 500;这个接口平均处理一次请求需要 100 毫秒,也就是 W = 0.1 秒。代入公式:L = 500 × 0.1 = 50,意味着要撑住 500 QPS、平均耗时 100 毫秒这个目标,系统里需要平均同时有 50 个任务在处理中——对应到线程池,大致就需要 50 个线程同时工作。
对比一下这条路径和阻塞系数公式:阻塞系数公式回答的是"给定单个任务的时间构成,需要几倍于 CPU 核数的线程";Little 定律回答的是"给定目标吞吐和平均耗时,需要多少个并发处理单元"。两者其实是同一个问题的两个视角,互相印证效果最好:如果两条路径算出来的线程数差异很大(比如阻塞系数公式算出 80,Little 定律算出 20),往往说明某个输入数据(WT、CT、λ、W 之一)测量得不准,或者对"目标吞吐"和"当前实际吞吐"的假设不一致,值得回头再核对一遍数据来源。

五、把线程数估算值落到 corePoolSize、maximumPoolSize、workQueue 三个参数上
无论用阻塞系数公式还是 Little 定律算出一个"线程数",这个数字都不能直接原封不动地填进 corePoolSize 或者 maximumPoolSize,还需要结合前面文章讲过的参数语义做二次分配:
- 如果这个估算值是"日常稳定流量下就需要的常驻并发数",应该主要体现在
corePoolSize上,因为核心线程默认不会因为空闲而被回收,适合承载长期存在的基础负载。 - 如果估算依据的是"业务高峰期"的目标吞吐(比如大促、活动期间的 QPS),而日常流量远低于这个值,更合理的做法是把
corePoolSize设置得比这个估算值小(对应日常负载),把maximumPoolSize设置到估算值附近(对应高峰负载),中间用工作队列去吸收流量的短暂波动,这样既不会在日常时段浪费线程资源,又能在真正的高峰期把线程数弹到足够的水平。 - 工作队列的容量也需要配合估算:如果
corePoolSize已经按日常负载设定,短暂超出日常负载但还没到真正高峰的那部分流量,应该被工作队列先"接住"排队,而不是立刻触发创建临时线程——队列容量太小,稍微一波动就会把压力直接转嫁给maximumPoolSize和拒绝策略;队列容量太大,又会让"任务已经堆积很久"这个信号被长时间隐藏,拖慢问题发现的速度。一个常见的经验做法是:队列容量对应"系统愿意让任务排队等待的最长时间"乘以"稳定处理速率",比如愿意让任务多等 2 秒、稳定处理速率是每秒 200 个,队列容量大致设在 400 左右,而不是直接给一个无界队列图省事。
这一步说到底是在把一个"总量"上的数字,按照"常态 vs 峰值"这条时间维度,重新分配到三个各自语义不同的参数上——公式给出的是总量的参考起点,怎么分配依然需要结合具体业务的流量形态来判断。
六、压测校准:为什么公式算出来的数字只是起点,不是终点
无论阻塞系数公式还是 Little 定律,都建立在一些理想化假设之上:任务的等待时间和计算时间是稳定的、系统资源(CPU、内存、网络带宽、下游服务的处理能力)是充足的、任务之间没有相互干扰。而真实的生产环境里,这些假设几乎总会被打破:
- GC 停顿会周期性地让所有线程短暂暂停,公式没有把这部分时间计算进去;
- 锁竞争如果任务内部有共享资源的加锁逻辑,线程数一旦超过某个阈值,锁等待的时间会随着并发数上升而急剧增长,而不是线性增长;
- 下游服务的抖动,比如数据库连接池本身就有上限,线程池开得再大,能同时发出去的数据库请求也会被下游连接池限制住,此时线程数再大也不会带来更高的实际吞吐;
- 多个线程池共享同一台机器的 CPU 和内存,如果同一个进程里还有别的线程池,或者同台机器上还跑着别的服务,理论上"专属于这个线程池"的核数其实拿不到。
正因为这些因素公式都没有(也不可能完全)建模进去,任何公式算出来的数字都应该被当作压测的起点,而不是可以直接上生产的终点。合理的压测校准流程大致是:先按公式估算的值配置好线程池参数,然后用阶梯式加压的方式逐步提升请求速率(比如从 100 QPS 开始,每隔一段时间提高到 200、300、400……),同时观察几个关键指标:整体吞吐量(每秒实际处理完成的请求数)、平均和 P99 响应延迟、线程池当前活跃线程数、工作队列的排队长度。
真正需要关注的是这些指标随压力上升出现的拐点:如果吞吐量还在随着压力线性上升、延迟基本稳定,说明系统还有余量,线程池不是瓶颈;如果吞吐量已经不再随压力上升(曲线走平甚至下降),但延迟开始急剧攀升、队列排队长度不断增长,就说明已经到达了这套参数配置下的实际处理上限——这个上限对应的线程数、队列长度,才是真正应该写进生产配置的数字,而不是公式算出来的理论值。如果实测的拐点远低于公式估算值,大概率是某个瓶颈资源(数据库连接池、下游限流、锁竞争)在线程数还没达到理论值之前就已经先饱和了,这时候盲目调大线程池参数不但没用,反而可能因为过多线程同时抢占那个瓶颈资源而让情况更糟——这也是压测校准比单纯套公式更重要的核心原因:公式告诉你一个理论上限,压测告诉你真实世界里那个更早出现的瓶颈到底在哪。

七、面试追问链
从"线程数该设多少"这个问题出发,逐层追问:CPU 密集型和 I/O 密集型任务在线程数上为什么应该有本质区别、阻塞系数公式里的 WT 和 CT 在实际系统里该怎么测量、Little 定律里的 L、λ、W 分别对应线程池场景里的什么、为什么这两种估算思路算出的结果理应能互相印证、估算出的总线程数该如何拆分到 corePoolSize 和 maximumPoolSize 两个参数上、以及为什么任何公式算出来的数字都必须经过压测校准才能上生产,这条链基本能覆盖面试官对线程池参数调优这个话题的核心考察点。

延伸阅读
- Brian Goetz《Java Concurrency in Practice》中关于线程池大小估算的章节,是阻塞系数公式最早的经典出处之一
- 排队论中的 M/M/c 模型,是 Little 定律之外另一种更精细刻画"多服务台排队系统"的数学工具,适合想深入理解排队论背景的读者
- 下一篇预告:线程池的关闭流程——shutdown 与 shutdownNow 的区别、中断的传递范围与优雅停机实践
问答(不计入阅读时长)
Q1:网上流传的"CPU核数 × 2"这种万能公式,实际适用的场景到底有多窄?
A:非常窄,窄到几乎只是"I/O 等待时间恰好约等于计算时间"这一种特殊情况下才准确——把阻塞系数公式 CPU核数 × (1 + WT/CT) 代入 WT = CT 就会得到 CPU核数 × 2,也就是说"× 2"这个数字本质上假设了任务等待时间和计算时间正好相等。而现实中不同接口的 WT/CT 比例可能天差地别:一个纯本地缓存查询的接口 WT 几乎为 0,套用"× 2"会明显开多了线程;一个要跨几个下游服务、平均等待几百毫秒的接口,WT/CT 可能是 10 甚至 20,套用"× 2"又会开少了线程,导致本该被并发利用起来的等待时间被浪费。这类经验公式的问题不在于数字本身错了,而在于它把一个和具体业务特征强相关的比例值,包装成了一个看起来放之四海而皆准的常数,用的时候一定要先确认自己的场景是不是真的接近这个假设。
Q2:如果一个接口有缓存命中和缓存未命中两条路径,**WT**和**CT**应该按哪一条路径来测量?
A:应该按两条路径各自单独测量、再按真实流量占比加权,而不是简单取平均或者只看某一条路径。缓存命中时 WT 可能只有几毫秒(读一次本地缓存或者 Redis),缓存未命中时 WT 可能是几十到几百毫秒(要真正打到数据库或者下游服务),两者的阻塞系数结果可以相差一个量级。如果线上流量里缓存命中率是 90%,直接按未命中路径的 WT 去估算会明显开出过多线程;如果按命中路径的 WT 去估算,又会在缓存偶尔失效、命中率下降的时候顶不住压力。更稳妥的做法是分别算出两条路径各自需要的线程数,按实际命中率加权平均,或者更保守一点,直接按"命中率下降到某个可接受下限"时的加权结果去规划,给容量留出应对缓存异常的余地。
Q3:Little 定律里的**W**(平均停留时间),具体是指任务在线程池队列里排队的时间,还是包括真正执行的时间?
A:是两者相加的总时间——从任务被提交进入系统开始计时,到任务真正处理完成、离开系统为止,中间无论是"在工作队列里排队等待"还是"正在被某个线程执行",都算在 W 里面。这也是为什么用 Little 定律反推出来的 L(系统内平均任务数)不能简单等同于"正在执行任务的线程数",它其实是"正在排队 + 正在执行"这两部分任务的总数。如果线程池的排队现象比较明显(工作队列里经常有积压),用 L 直接当作"需要的线程数"会偏大,因为这个 L 里混进了一部分本来应该在排队、而不是需要额外线程去处理的任务。更准确的做法是分别统计"平均排队时长"和"平均执行时长",只用执行时长部分对应的那一部分 L 去指导线程数,排队时长部分反过来去指导队列容量的设置。
Q4:阻塞系数公式和 Little 定律算出来的线程数差异很大(比如一个算 80、一个算 20)时,具体应该先排查哪一部分数据?
A:优先排查流量口径是否一致,其次是**WT/CT 的测量样本是否有代表性**。两个公式差异大,最常见的原因是拿来代入的数字本身没对齐:比如阻塞系数公式用的 WT、CT 是从某次单独的性能剖析里测出来的高峰期数据,而 Little 定律用的 λ(目标 QPS)却是日常平均流量,两边其实在描述不同的负载场景,自然算不出一致的结果。排查顺序建议是:先确认 λ 和 W(或者 WT、CT)是不是来自同一个统计时间窗口、同一个流量水平;如果口径确实一致还是差异很大,再去看 WT、CT 的测量样本量是否足够(比如只抽样了几十次请求,容易被个别慢查询带偏均值),或者接口本身是否存在前面 Q2 提到的"多条路径耗时差异很大却被平均"的情况。两个公式的价值就在于能互相校验,出现分歧本身就是一个提示"数据源头有问题"的信号,不建议直接取两者的中间值敷衍过去。
Q5:估算出的总线程数该如何具体拆分到**corePoolSize**和**maximumPoolSize**上,有没有一个通用的比例?
A:没有一个通用比例,因为这个拆分依赖的是**"日常负载"和"峰值负载"之间的比例关系**,而这个比例每个业务都不一样。具体做法应该是:先分别估算"日常平均流量下需要的线程数"和"预期峰值流量下需要的线程数"(两者都可以用阻塞系数公式或者 Little 定律算,只是代入的 λ 或者任务到达率不同),前者对应 corePoolSize,后者对应 maximumPoolSize。如果一个业务日常流量和峰值流量差异不大(比如稳定的内部批处理系统),两者会很接近,甚至可以让 corePoolSize 等于 maximumPoolSize;如果一个业务有明显的流量洪峰(比如大促、活动),两者可能相差好几倍,这时候更需要依赖工作队列去缓冲中间地带的流量波动,而不是指望一个固定比例的经验公式来分配。
Q6:压测的时候,如果吞吐量曲线还没有完全走平,但响应延迟已经开始比较明显地上升,这算不算已经到了文中说的"拐点"?
A:算,而且这种"延迟先于吞吐量走平而抬头"的现象其实更值得警惕。延迟开始明显上升,说明任务在系统里的排队等待时间已经在增长,也就是说吞吐量即使还在往上走,增长的速度已经开始跟不上压力增长的速度(否则延迟应该维持稳定)。理想情况下,"拐点"应该在延迟开始明显抬头的同一时刻被识别出来,而不是非要等到吞吐量彻底走平才承认已经到了上限——如果一直加压到吞吐量完全走平才停下来,中间这段"延迟已经在涨、但还没引起足够重视"的区间,对线上用户来说可能已经是能感知到的体验下降了。实践中更推荐把延迟的某个分位数(比如 P99)设一个明确的阈值(比如不超过日常水平的 1.5 倍),一旦压测过程中触碰到这个阈值,就应该认定已经到达这次压测该止步的上限,而不是死等吞吐量曲线走平这个更迟钝的信号。
Q7:如果压测发现实际的拐点远低于公式估算值(比如公式算出能撑 80 个线程的量,实测 30 个线程左右吞吐量就已经走平),第一步应该排查什么?
A:第一步应该排查下游依赖是否存在比线程数更早触顶的瓶颈,最常见的是数据库连接池、下游服务的限流阈值,或者共享资源上的锁竞争。线程池本身的估算公式只考虑了"CPU 和等待时间"这一层,但一次请求往往还要经过数据库连接池(连接数通常是有限的,比如常见配置就是几十个)、下游接口自身的限流策略、甚至是本地代码里对某个共享资源加的锁——这些资源的容量如果本身就比公式估算出的线程数小,那么线程数刚涨到接近这些资源的上限,吞吐量就会提前走平,因为多出来的线程都在等着抢这些更早耗尽的资源,而不是在真正利用 CPU 或者等待网络 I/O。排查思路是在压测的同时监控这些下游资源的利用率(连接池活跃连接数、下游接口的限流拒绝率、锁等待时间),如果确实是某个资源先饱和,把线程池开得更大不但没用,反而会让更多线程堆在等锁或等连接上,让延迟变得更差。
Q8:如果同一台机器上跑着多个线程池(或者多个进程共享这台机器的 CPU),估算公式里的"CPU 核数"应该怎么取值?
A:不应该直接取机器的物理核数,而应该按这个线程池实际能分配到的 CPU 配额来估算——如果是在容器化环境里(比如 Kubernetes),这个配额通常是容器的 CPU limit(可能是一个小数,比如 2.5 核),而不是宿主机的总核数;如果是同一个进程里有多个用途不同的线程池(比如一个专门跑 CPU 密集任务、一个专门跑 I/O 密集任务),需要考虑这几个线程池是不是会同时处于繁忙状态,如果会,实际上是在共同瓜分同一份 CPU 配额,某种程度上需要把这份配额在多个线程池之间做二次分配,而不是让每个线程池都各自按"全部核数"去估算——那样加总起来很容易超出机器实际能提供的计算能力,导致所有线程池的实际表现都比单独估算时差。这也是为什么"CPU 核数"这个看起来最基础的输入变量,在真实的多租户、容器化环境里反而是最容易被想简单、算错的一环。