ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

线程池参数与队列选择:核心数、最大数、拒绝策略的边界推演与可复现压测

线程池参数与队列选择:核心数、最大数、拒绝策略的边界推演与可复现压测

线程池参数与队列选择:核心数、最大数、拒绝策略的边界推演与可复现压测

一、读者要解决的问题

假设你负责一个订单查询接口,正常情况下每秒 200 个请求,每个请求要调用一次下游服务,平均耗时 30 毫秒。某天流量翻了三倍,接口开始变慢,接着开始报错,日志里出现RejectedExecutionException。你把线程池的最大线程数从 16 调到 64,重启之后错误没少,反而更慢。

这类问题的困惑点通常集中在三句话:

  1. 我设了maximumPoolSize=64,为什么线程数一直是 16?
  2. 我把队列设成LinkedBlockingQueue,为什么拒绝策略从来不触发?
  3. 我把corePoolSize调大,为什么第一批请求还是慢?

这三句话分别对应三件不同的事:线程什么时候被创建、任务在哪里排队、什么条件下新任务会被拒绝。如果只记住“核心线程数、最大线程数、队列、拒绝策略”这几个名词,而不理解它们的触发顺序,参数调来调去只是在猜。

本文沿一条主线展开:先建立ThreadPoolExecutor的任务接纳链路,再用可自行运行的观察示例理解预热、排队、扩容、拒绝四个节点,然后讨论监控指标和适用条件。文中不声称任何实验已经由作者实际运行测得,给出的预期现象用于帮助你判断自己的运行结果;所有具体数字都是示意值。

二、环境与兼容版本

本文的结论针对以下范围,其他版本需要单独确认。

项目版本说明
JDKJava 21本文机制表述以 Java 21 官方ThreadPoolExecutor文档为准
构建工具Maven 或 Gradle 均可示例不依赖具体构建工具,直接用javac即可
操作系统Linux 或 macOS线程数观察依赖 JVM 自身,不依赖外部工具
Spring Boot3.5仅作为背景环境提及,本文不覆盖ThreadPoolTaskExecutor等封装行为

需要明确的边界:

  • ThreadPoolExecutor的行为由 JDK 保证,属于官方 API。文中的关键机制表述以 Java 21 官方文档为准,涉及具体语义时会在对应位置注明。
  • 本文给出的线程数、队列长度、耗时数字均为示意,用来观察链路顺序,不代表任何生产环境的实测基线。
  • “预热能减少首波延迟”这一判断的前提是:创建线程确实是首波延迟的主要来源。如果首波延迟来自连接池初始化或类加载,预热线程池只是解决其中一部分。
  • Spring Boot 3.5 的ThreadPoolTaskExecutor并未在本文示例中使用;它是对ThreadPoolExecutor的包装,其对外行为需要单独查阅对应版本文档确认,不能直接假定与本文推演完全一致。本文的结论不自动延伸到 Spring Boot 3.5 的封装线程池。

三、核心机制:任务进入线程池之后发生了什么

3.1 先解释它解决什么问题

线程池要解决的问题是:如果每个任务都新建一个线程,线程创建和销毁的成本会摊到每次任务上,而且线程数量无法限制,可能把内存和 CPU 拖垮。线程池把“线程的创建”和“任务的执行”分开:线程可以复用,任务进入一个等待区域,由一组线程取走执行。

正式名称是ThreadPoolExecutor,它是 JDK 提供的线程池实现。任务等待区域由BlockingQueue承担。

3.2 接纳一个任务的顺序

当调用execute(Runnable)提交任务时,ThreadPoolExecutor按下面的顺序判断。这个顺序是理解所有参数的关键,也是很多困惑的来源。

提交任务 execute | v +-------------------------------+ | 当前线程数 < corePoolSize ? | +---------------+---------------+ | 是 v 创建或复用核心线程执行 | | 否 v +-------------------------------+ | 能否入队? | +---------------+---------------+ | 能 v 任务进入队列等待 | | 不能 v +-------------------------------+ | 当前线程数 < maximumPoolSize ?| +---------------+---------------+ | 是 v 创建非核心线程执行 | | 否 v 执行拒绝策略

用白话复述这个顺序:

  1. 如果现在线程数还没到corePoolSize,那就直接开一个线程跑,哪怕队列是空的。
  2. 如果线程数已经到corePoolSize,先尝试把任务放进队列。
  3. 只有队列放不进去,才考虑把线程数从corePoolSize扩到maximumPoolSize。
  4. 如果线程数已经到maximumPoolSize,而且队列也满了,才触发拒绝策略。

这里最容易踩的误区是第 2 步和第 3 步的顺序。很多人以为“先扩容,扩容到最大了再排队”,实际情况相反:先排队,排不进去才扩容。这一语义在 Java 21ThreadPoolExecutor官方文档中有对应描述:如果运行线程数少于maximumPoolSize,ThreadPoolExecutor会在队列无法接纳任务时创建新线程;这段文档节选支持的是接纳顺序中的关键一环,完整语义仍以官方文档原文为准。所以如果队列容量很大,线程数可能长期停在corePoolSize,难以扩到maximumPoolSize。

3.3 核心线程数:谁决定第一批任务快不快

corePoolSize决定两件事:常规情况下常驻的线程数量,以及新任务在没有线程可用时是否直接触发建线程。

Java 21ThreadPoolExecutor官方文档明确指出:默认情况下,即使核心线程也是初始按需创建和启动的,当有新任务到达时才创建,除非调用了prestartCoreThread或prestartAllCoreThreads。也就是说,第一次提交任务时,线程池可能从 0 个线程开始,边提交边建。如果任务本身很快,这个建线程的成本可能不明显;如果任务耗时短、提交量集中,建线程的成本会体现在首批任务的延迟上。本文不使用外部实测数据支撑具体毫秒收益。

prestartAllCoreThreads()和prestartCoreThread()用来提前把核心线程建好。它们解决的问题是:把建线程的成本从“第一批真实请求”挪到“应用启动阶段”。这是否值得做,取决于建线程成本在首波延迟中的占比。

3.4 最大线程数:它只在队列满之后才起作用

maximumPoolSize是线程数的上限。但如上所述,触发扩容的条件是队列放不下。因此有一句工程上的经验判断:

  • 队列容量越小,越容易触发扩容,maximumPoolSize越容易生效。
  • 队列容量越大,maximumPoolSize越难生效,容易变成“名义配置”。

这不是 JDK 的隐藏规则,而是接纳顺序的直接结果。

3.5 有界队列与无界队列

队列分为容量有上限和没有上限两类。

  • 有界队列:如new ArrayBlockingQueue<>(1000),容量固定。放满之后再提交任务,会尝试扩容,扩容到上限后触发拒绝。它的好处是让积压有上限,让拒绝成为明确的信号。
  • 无界队列:如new LinkedBlockingQueue<>(),其默认构造器使用的容量在 JDK 中是一个很大的常数,通常被描述为接近Integer.MAX_VALUE。更准确的说法是“默认容量极大、实际可视为不设有效上限”,而不是数学意义上的无限。以这种队列配合默认参数时,任务会持续入队,线程数很容易长期停在corePoolSize,拒绝策略很难触发。LinkedBlockingQueue的默认容量以及ThreadPoolExecutor在该队列下的接纳行为,应以其对应版本官方文档为准;本文不展开逐字引用,相关表述有待进一步核对。

无界队列带来的问题不是立刻报错,而是错误被推迟。任务越堆越多,内存上升,延迟上升,直到某个环节崩溃。崩溃时的现象可能是 OutOfMemoryError,也可能是下游超时引发的连锁失败,但根因是积压没有被及时表达出来。

还有一种常见写法是Executors.newFixedThreadPool(n)。按照该工厂方法在 Java 21 官方文档中的说明,它使用无界队列,因此上面的判断同样适用;具体默认容量数值以对应版本文档为准,本文不展开逐字引用。

3.6 拒绝策略的四种行为

当队列满且线程数达到上限时,RejectedExecutionHandler决定怎么处理这个任务。JDK 提供四种,具体语义以 Java 21 官方文档中ThreadPoolExecutor与RejectedExecutionHandler的原文为准。下表是对四种策略行为的概括,个别结论标注为待核对以提醒读者自行确认。

策略行为失败语义
AbortPolicy抛出RejectedExecutionException提交方立刻感知失败;ThreadPoolExecutor构造函数未指定处理器时使用它(以 Java 21 文档为准)
CallerRunsPolicy由提交任务的线程自己执行提交方被拖慢,形成天然的反压
DiscardPolicy静默丢弃提交方没有任何感知,风险最高
DiscardOldestPolicy丢弃队列头部最旧的任务,再尝试提交当前任务旧任务被牺牲,当前任务在队列仍满时可能再次被拒

选择哪一种,取决于业务能否接受“任务丢失”以及“谁承担变慢的代价”。

  • 对账、扣款这类不能丢的任务,不适合DiscardPolicy。
  • 对外接口如果希望快速失败而不是无限等待,AbortPolicy配合上游超时是合理组合。
  • CallerRunsPolicy在提交线程是 Web 容器线程时要谨慎,它会把排队压力转移到容器线程上,可能影响其他请求。
  • DiscardOldestPolicy只适合任务有“新数据覆盖旧数据”语义的场景。

四、一个贯穿示例:观察预热、排队、扩容与拒绝

下面用三个最小程序把几个节点串起来。目标是让现象在控制台上可观察,而不是停留在概念描述。这些示例是教学与观察代码,本文不声称已在本机运行并得到固定输出;你需要在自己的环境下运行,并以实际输出判断。

4.1 前置环境与依赖

  • JDK 21。
  • 不依赖第三方库,只用java.util.concurrent。
  • 为了观察延迟,使用System.nanoTime()测量。

4.2 实验一:预热与不预热的首波启动时刻对比

先看核心线程预热的效果。这里刻意把任务做得很快,让建线程成本在对比中更明显。

importjava.util.concurrent.ArrayBlockingQueue;importjava.util.concurrent.ThreadPoolExecutor;importjava.util.concurrent.TimeUnit;publicclassPrestartDemo{publicstaticvoidmain(String[]args)throwsException{System.out.println("=== 不预热 ===");run(false);System.out.println("=== 预热 ===");run(true);}staticvoidrun(booleanprestart)throwsException{ThreadPoolExecutorexecutor=newThreadPoolExecutor(8,8,60L,TimeUnit.SECONDS,newArrayBlockingQueue<>(1000),newThreadPoolExecutor.AbortPolicy());if(prestart){executor.prestartAllCoreThreads();}longstart=System.nanoTime();for(inti=0;i<8;i++){finalintid=i;executor.execute(()->{// 任务本身很快,让建线程成本成为主要变量longt=System.nanoTime();System.out.println("task "+id+" started at "+(t-start)/1_000_000+" ms");});}executor.shutdown();executor.awaitTermination(5,TimeUnit.SECONDS);}}

输入:8 个短任务,核心线程数 8,队列容量 1000。

状态变化:

  • 不预热时,线程池初始线程数为 0。每提交一个任务,如果线程数未达corePoolSize,就创建一个新线程。因此前 8 个任务的启动时刻会分散在建线程的过程中。
  • 预热时,prestartAllCoreThreads()在提交前就创建核心线程。按照ThreadPoolExecutor的工作方式,这些线程会等待从队列取任务;提交任务后,线程可以直接取走执行。

可观察结果与验证方法:不预热时,前面几个任务的启动时间戳通常会分散一些;预热时,8 个任务的时间戳通常更接近。这只是可能的方向性差异,不是固定结论。为了判断是否真的成立,建议重复运行若干次,记录每个任务的时间戳,并比较“首个任务启动到最后一个任务启动”的跨度,而不是只看单次输出。如果两种情况下跨度差别很小,说明建线程成本在本机不是主要变量,需要调整任务耗时或提交方式再观察。本文没有声称已实施该重复运行。

边界说明:这个实验放大的是建线程成本。如果任务耗时长,比如每个任务几百毫秒,建线程成本占比就会下降,预热带来的差别会变小。所以“预热有没有用”取决于任务耗时与建线程耗时的比例。

4.3 实验二:观察排队、扩容与拒绝的触发顺序

这个实验使用小队列,让扩容和拒绝在可控范围内发生。

importjava.util.concurrent.ArrayBlockingQueue;importjava.util.concurrent.ThreadPoolExecutor;importjava.util.concurrent.TimeUnit;importjava.util.concurrent.atomic.AtomicInteger;publicclassSaturationDemo{publicstaticvoidmain(String[]args)throwsException{AtomicIntegerrejected=newAtomicInteger();ThreadPoolExecutorexecutor=newThreadPoolExecutor(2,// corePoolSize4,// maximumPoolSize10L,TimeUnit.SECONDS,newArrayBlockingQueue<>(2),// 队列容量 2(r,e)->{// 自定义拒绝策略rejected.incrementAndGet();System.out.println("rejected: "+r);});Runnabletask=()->{System.out.println(Thread.currentThread().getName()+" running");try{Thread.sleep(2000);}catch(InterruptedExceptionex){Thread.currentThread().interrupt();}};// 提交 10 个任务,前几个会占据线程和队列,后面会触发拒绝for(inti=0;i<10;i++){executor.execute(task);System.out.println("submitted, poolSize="+executor.getPoolSize()+", queueSize="+executor.getQueue().size());}System.out.println("total rejected="+rejected.get());executor.shutdown();executor.awaitTermination(10,TimeUnit.SECONDS);}}

输入:核心线程 2,最大线程 4,队列容量 2,共提交 10 个任务,每个任务睡眠 2 秒。任务会阻塞约 2 秒,因此提交期间工作线程不会被立刻释放,这为观察接纳顺序提供了前提。

状态变化(基于接纳顺序的推演):

  1. 提交第 1、2 个任务:线程数小于corePoolSize=2,会创建核心线程执行。任务至少睡眠 2 秒,因此在随后打印的getPoolSize()中,poolSize 通常会反映为 1、2,但具体打印值受线程启动与主线程打印的时序影响。
  2. 提交第 3、4 个任务:线程数达到核心数,先入队。队列容量 2,可以放下这两个任务。getQueue().size()通常反映为 1、2。
  3. 提交第 5、6 个任务:队列已满,在未达到maximumPoolSize时会创建新线程。线程数从 2 扩到 4。getPoolSize()通常反映为 3、4。
  4. 提交第 7 到第 10 个任务:线程数达到maximumPoolSize=4,队列也满,触发自定义拒绝逻辑。

执行命令:在保存源码的目录下,用javac SaturationDemo.java编译,再java SaturationDemo运行。

可观察结果与验证方法:控制台会打印每次提交后的 poolSize 与 queueSize,最后打印拒绝总数。由于getPoolSize()、getActiveCount()与getQueue().size()都是采样瞬间值,打印结果可能受线程调度影响,total rejected也可能因主线程在任务大量释放前就执行完毕而不同。要确认拒绝次数是否符合推演,可以在submit循环结束后再等待一小段时间、或在任务中显式阻塞后再采样;不要仅凭一次运行的单个打印值下结论。

结果原因:这正是 3.2 节的接纳顺序在起作用。关键点在于第 5、6 个任务触发扩容的前提是队列已满,而不是线程数到达核心数就扩容。

边界说明:这里的数字是刻意选小的示意值,目的是让顺序在少量提交中就能观察。真实业务中队列容量会大得多,此时扩容更难触发,拒绝也更难触发。

4.4 实验三:记录拒绝量作为观测信号

上面已经用自定义拒绝策略累加了拒绝计数。把这个计数接入监控,就得到“拒绝次数”这个指标。它可以回答一个具体问题:当前线程池是否已经无法接纳新任务。

一个可观察的组合是:

  • getPoolSize():当前线程数,用于判断实际扩到了多少。
  • getActiveCount():正在执行任务的线程数,按 Java 21 官方 API 说明为近似值,可能随采样时机波动,不应单独作为扩容或缩容的决策依据。
  • getQueue().size():当前队列中的任务数,用于判断积压程度;具体含义以BlockingQueue对应实现文档为准。
  • 拒绝计数:判断是否已经出现过接纳失败。

这几个指标的采集方式因监控系统而异,本文不绑定具体框架。重点是它们要和 3.2 节的链路对应:队列长度对应“排队”,线程数对应“扩容”,拒绝计数对应“拒绝”。在判断是否需要调整参数时,建议同时观察队列长度趋势、拒绝计数与任务实际耗时,避免只凭getActiveCount()的瞬时值做决策。

五、失败路径与容易踩错的地方

5.1 无界队列让拒绝策略难以触发

如果队列使用new LinkedBlockingQueue<>(),其默认容量非常大,接纳顺序中的第 2 步几乎总能成功,于是第 3 步的扩容和第 4 步的拒绝都很难发生。结果是拒绝计数长期为 0,看起来“很健康”,实际问题只是被堆到了队列里。

判断方法:把队列换成有界队列,或者至少显式设置一个容量,让积压有一个上限。如果一个线程池配置了这种默认大队列,那么maximumPoolSize与拒绝策略基本不参与实际行为。以上结论依赖LinkedBlockingQueue默认容量极大的前提,具体数值与行为以 Java 21 官方文档为准,本文不逐字引用。

5.2 用CallerRunsPolicy时没考虑调用方是谁

CallerRunsPolicy让提交任务的线程执行任务。如果提交线程是 Web 容器的请求线程,那么在下游变慢、队列填满时,这个请求线程会被当前任务占用,无法及时处理其他请求。这会把线程池的压力转移到 Web 容器线程池上。

这不是说CallerRunsPolicy不能用,而是要先明确“提交方能不能承受被拖慢”。如果提交方本身就是批量任务,这种反压是合理的;如果提交方是面向用户的请求线程,要评估连锁影响。

5.3 把maximumPoolSize当成“并发上限”

并发执行的任务数上限与maximumPoolSize相关,但这个上限真正起作用的前提是队列已经满。如果队列容量为 10000,那么并发数长期接近corePoolSize,此时真正的“缓冲上限”是队列容量,而不是最大线程数。所以调参时要同时看这两个值,而不是单独调整其中一个。

5.4 拒绝异常被吞掉

如果提交任务的地方捕获了RejectedExecutionException却没有记录、没有告警、没有回退逻辑,那么任务会静默消失。这类问题在日志里往往只表现为“某些请求没有结果”,难以定位。建议在提交点对拒绝异常做明确处理,并把拒绝计数暴露为监控指标。

5.5 预热之后线程又被回收

如果设置了allowCoreThreadTimeOut(true),核心线程在空闲超过keepAliveTime后也可能被回收。此时预热只能解决启动阶段的问题,长时间空闲后重新来流量,仍可能重新经历建线程过程。这一行为的具体条件以 Java 21 官方文档为准,本文不展开逐字引用;建议在启用前对照官方 API 说明确认交互条件。

六、容量评估与参数选择的思路

参数没有通用最优值,但可以从几个问题出发推导。

6.1 先确定任务的类型

  • CPU 密集型任务:线程数不宜超过 CPU 核数太多,否则线程切换成本上升。
  • IO 密集型任务:线程大部分时间在等待,线程数可以适当高于 CPU 核数,但要看下游能承受多少并发。

6.2 再确定队列的作用

队列的功能是吸收短时抖动,而不是无限缓存。可以问:

  • 我希望系统在积压多少任务后开始明确拒绝?
  • 队列里的任务最长可以等待多久,才会超过上游超时?

如果任务在队列里等待的时间已经超过上游超时,那么它即使被执行也没有意义。此时队列容量应该对应这个超时窗口,而不是越大越好。

6.3 最后确定拒绝语义

拒绝不是异常,而是一个明确的决策点。需要回答:

  • 这个任务可以丢吗?如果不可以,拒绝时要不要落库重试?
  • 谁应该感知到慢?是调用方快速失败,还是让调用方自己执行?

把这三个问题回答清楚,参数选择就有了依据,而不是从网上抄一组数字。

七、结论与适用条件

回到开头的三个困惑。

  1. 为什么线程数停在核心数附近?因为扩容的前提是队列满。队列越大,扩容越难。要观察线程是否真的扩到最大,使用有界小队列做验证,或对getPoolSize()做持续采集。
  2. 为什么拒绝策略不触发?因为队列几乎没有满。检查队列是否使用了默认大容量构造,或者容量是否远大于实际积压。
  3. 为什么首批请求仍然慢?因为核心线程默认是懒创建的。可以用prestartAllCoreThreads()在启动阶段预热,但前提是建线程确实是瓶颈。

本文的结论适用于默认的ThreadPoolExecutor接纳逻辑,并用 Java 21 官方文档作为机制依据。如果使用ForkJoinPool、ScheduledThreadPoolExecutor或框架封装的线程池(如 Spring Boot 3.5 的ThreadPoolTaskExecutor),行为需要单独确认,不能直接套用。文中所有数字均为示意,用于观察链路顺序,生产参数应结合任务耗时、下游容量和超时窗口重新评估。

最后,工具能帮你观察,但不能替你决定。你需要先明确任务能否丢失、谁承担变慢的代价,再选择队列容量与拒绝策略。参数是这些决定的表达,不是可以脱离场景套用的模板。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进