Java 面试追问实战:线程池满了以后,请求去哪了?
返回系列目录 · 下一场:Spring 事务追问
这是一场教学演练。我们不重新罗列线程池的所有参数,而是围绕同一段程序连续改变条件。先把每轮问题自己答一遍,再对照推理;答案最关键的部分往往是“在哪些条件下成立”。
开场:30秒参考回答
对运行中的标准 ThreadPoolExecutor,工作线程少于核心数时先尝试创建线程,达到核心数后先入队,入队失败才尝试扩到最大数,再无法接纳才走拒绝策略。因此最大数4不代表第3个任务就会新建线程。我还会确认队列容量、拒绝策略和关闭状态;过载时要让业务看到失败,并检查排队时间与下游承载能力。
本篇限定 JDK 21、平台线程、标准队列和正常创建线程的线程工厂;不套用到虚拟线程执行器、ForkJoinPool 或定时任务池。提交路径依据 JDK 21 ThreadPoolExecutor API,关闭与入队复查等细节可对照 OpenJDK 21u 源码的 execute 方法。
第一轮:先锁定题设,再推7次提交
面试官:核心线程数2、最大线程数4、ArrayBlockingQueue容量2,使用 AbortPolicy。一个线程连续 execute 提交7个任务,哪个被拒绝?
先追问题设:池处于运行状态,提交期间不关闭,没有其他提交者;每个任务都等待同一个初始值为1的门闩,全部提交完才放行,因此工作任务不会提前结束。线程能正常创建,配置不会中途变化。这正是本站 已有完整源码 的条件。
在这些条件下逐步推导:
| 提交任务 | 本次走哪条路径 | 提交后的工作线程数 | 队列项数 |
|---|---|---|---|
| 1、2 | 各创建一个核心工作线程承担任务 | 2 | 0 |
| 3、4 | 核心数已达到,先入队 | 2 | 2 |
| 5、6 | 入队失败,各增加一个非核心工作线程 | 4 | 2 |
| 7 | 队列满且线程数达到上限,AbortPolicy抛异常 | 4 | 2 |
参考追答:第7个任务被拒绝。这张表是按源码规则得到的状态推导;下载程序没有逐次打印这些指标。它的 已保存原始stdout 只有 rejected task 7,不能据此声称观察到了所有任务的开始顺序。
再追问:为什么第5个任务可能比第3个先开始? 第3个任务在队列里,第5个直接成为新线程的首个任务。队列内部FIFO不等于全池按提交顺序开始,更不等于按提交顺序完成。回答时应区分“队列顺序”和“执行顺序”。
第二轮:只把队列换大,结论怎样变
面试官:换成未指定容量的 LinkedBlockingQueue,第7个还拒绝吗?最大数改到100有用吗?
参考追答:保持本轮其余条件,并假定这7个任务的入队和内存分配成功,前两个占用核心线程,后五个排队,不会因为这7次提交触发容量拒绝。只有入队失败才会走非核心扩容分支,所以单独提高最大数不能解决此处积压。未指定容量也不是无限内存;持续积压仍可能拉长延迟并耗尽资源。
再追问:换成 SynchronousQueue 呢? 它不保存排队项;在门闩不放行、没有空闲工作线程接手的条件下,前4项占用4个线程,第5至7项被拒绝。这里改变的是交接能力,不是给每个任务增加了等待位置。依据仍是上述JDK队列与线程数契约;完整队列差异见 线程池过载深阅读。
第三轮:CallerRuns能否“一行解决拒绝”
面试官:把原程序的 AbortPolicy 直接换成 CallerRunsPolicy,是否会顺利完成全部7项?
参考追答:不能这样改。池未关闭时,第7项会在调用 execute 的主线程里运行,而任务会等待门闩。原程序释放门闩的代码在提交循环之后的 finally 中;主线程卡在第7次 execute,无法进入 finally,因而不能自行放行。这是由具体控制流推导出的等待闭环,本文没有实际运行这个会挂住的变体。
如果要验证 CallerRuns,先在独立练习副本中安排不依赖提交线程推进的放行者,确认其可以执行,并设置超时及清理;也可以把拒绝后执行的任务改成有界、可结束的操作。不要让放行任务排进同一个已满的线程池。记录提交线程名、任务线程名和 execute 返回时间,才能解释“降速”是怎样发生的。
用于HTTP请求时,这会把任务耗时转移给请求线程;用于事件循环,还可能拖住其他请求。另一个必须补充的条件是:执行器已关闭时,CallerRunsPolicy会丢弃任务,并不总是由调用者兜底执行。参见 CallerRunsPolicy官方API。
第四轮:接口超时,先扩线程还是先找瓶颈
场景题:一个批量查询接口使用有界线程池,队列持续增长,CPU却不高。有人建议把最大线程数翻倍,你怎样判断?
参考推理:先检查池是否关闭、当前队列能否继续接纳;若队列仍未满,提高最大数可能根本不触发扩容。再看线程栈、连接池等待、下游延迟及超时,而不是仅看CPU。假如下游只允许少量并发,再增加线程可能只增加连接争抢。
排查要把提交速率、完成速率、活跃线程、排队等待、拒绝数与请求失败率串起来。用相同环境分别制造短突发、持续超载和慢下游,观察队列能否恢复,而不是仅确认某次请求返回200。这里是排障练习建议,尚未执行这些负载实验。
再追问:有界队列就能守住300毫秒截止时间吗? 不一定。作简化推演:4个工作线程刚开始执行,每项固定80毫秒,队列又接纳12项,无其他开销。队尾一批约等待240毫秒,再执行80毫秒,已超过300毫秒。这个数值是理想化时序计算,不是测量结果;实际还要考虑长尾、连接等待与返回开销。
因此队列有界只约束数量,出队时还应检查剩余时间。调用方的 Future.get 超时不等于任务被停止,取消和中断也需要任务及下游客户端配合。预算练习可继续读 超时预算指南,已有 TimeoutBudgetDemo源码 与 原始输出 使用逻辑时钟验证预算判断,不能当作线程池压测数据。
三个容易说错的答案
| 错误回答 | 为什么不成立 | 可以怎样补全 |
|---|---|---|
| “最大数是4,第三项一定扩容” | 忽略了达到核心数后优先尝试入队 | 先给出队列及剩余容量,再推提交路径 |
| “捕获拒绝异常后返回成功,用户体验更好” | 调用者会把未接纳误认为已完成 | 映射为明确的繁忙或可恢复状态;必须完成的工作先可靠保存投递意图 |
| “改成丢弃策略,submit的Future总会结束” | 被静默丢弃的Future任务可能既未执行也未取消 | 等待设置超时,拒绝处理要让失败或取消可见;不要承诺每项结果都有回调 |
最后一项可对照 AbortPolicy、DiscardPolicy等处理器源码。拒绝处理、任务完成和业务持久化是三个不同的责任,不能只凭“不抛异常”判断成功。
可执行练习:先复现已有结果,再改变一个变量
下载 已有Java源码,另存为 ThreadPoolOverloadVerification.java,与public类名保持一致。在独立练习目录使用JDK 21执行:
java -version
javac -version
javac -encoding UTF-8 ThreadPoolOverloadVerification.java
java -cp . ThreadPoolOverloadVerification基线预期stdout为:
rejected task 7原指南记录了 Microsoft Build of OpenJDK 21 的一次验证;本篇整理时于 2026-11-06,Windows / Oracle JDK 21.0.12+7-LTS-205,将下载源码复制到临时目录原样编译、运行,退出码均为0,stdout与已保存文件一致。这里只验证固定饱和场景,不表示做过性能测试。
接着在自己的副本中完成以下练习;这些变体本篇未运行:
- 在每次提交后记录 poolSize、queue.size 与拒绝编号,对照第一轮表格;门闩保持关闭,直到提交结束。指标不能证明任务的实际开始顺序,要验证顺序还需给任务编号并在入口记录。
- 仅把队列容量从2改为1,先预测拒绝编号,再运行。按相同条件推导应拒绝第6、7项;保留自己的实际输出,若不同先复查是否放行过早或源码有其他变化。
- 单独创建一个已经 shutdown 的池,再提交有限耗时任务,比较 AbortPolicy 与 CallerRunsPolicy。确保程序会退出,不把原门闩任务直接用于这一步。
交付一份记录:JDK完整版本、源码副本、原始stdout、提交状态表,以及“实测了什么、仍是推导的是什么”。若只跑了基线,就把另外两项继续列为待办。
项目表达示例:把观察与结论对应起来
下面是完成基线后可按实际记录改写的教学表达:
我在本地练习中复现了核心2、最大4、队列2的饱和过程。保持任务未完成,第7次execute被拒绝,保存了源码、版本和stdout。我按提交规则解释了为什么先入队再扩容,也检查了CallerRuns直接替换会使提交线程等待门闩的问题。这个实验没有覆盖持续负载;排队等待和下游容量还需要另做测量。
如果面试官继续问“你优化了多少”,先给实际对照条件和记录;只有基线就明确回答尚无性能改善数据。可以进一步讨论拒绝后的业务状态、重试是否幂等、不同依赖是否需要隔离线程池,不把演练改写成未经发生的线上事故。
