← ReJava · 学习目录

REJAVA / LEARNING NOTES

thread pool(线程池)

沿着一次导出请求,理解任务接纳、排队、结果获取、拒绝和取消。

Java 17 / 21更新于 2026.10.12约 39 分钟阅读

根据课程与真实学习交流整理;不是逐字稿

本次更新说明
  • 补充 Executor(执行器接口)的中文释义;课后问答措辞微调。
  • 整理为公开学习笔记,保留技术内容与练习。
  • 清理个人分析,完善阅读导航。

这一课的验收目标

给定配置能解释接纳流程,区分提交、执行与等待结果;结合下游容量和时效说明配置理由。通过短代码判断和一次综合表达收束;需要时用可控实验验证具体缺口。

本课交付物: 一次应用判断和一次约 2–3 分钟的完整表达。三组独立实验作为可选深化材料,不是进入下一课的门槛。隔日用约 5–10 分钟验证关键缺口;完整内容保留,不逐项即时追问。

必会: 7 参数、接纳顺序、队列与拒绝、结果异常和关闭。 进阶: 用任务及下游约束解释配置,提出可检验的排障方案。选择题只检查基础识别,不能单独证明已经会应用。

本课讨论 ThreadPoolExecutor(线程池执行器) 管理的平台线程池;不把它的线程数配置直接套到虚拟线程。AQS、工作窃取和异步编排留给后续专题。

可选热身 · 第 4 课的三句话(最多 2 分钟)

先回忆:同步方法与同步块的字节码有何不同?什么叫可重入?同锁可见性保证了什么?

核对:静态和实例同步方法都用 ACC_SYNCHRONIZED(同步方法访问标志);同步代码块用监视器进入/退出指令。可重入是当前持锁线程再次获取同一监视器。一次解锁 happens-before(先行发生关系) 后续对同一监视器的加锁,保证先前写入可见,不是让拿锁更快。

这是延迟回忆,不是新课准入考试。卡住记一句即可继续。依据与例子见 第 4 课复盘卡。

一、先分清任务、线程和线程池

考虑一个审批记录导出场景:用户提交“导出审批记录”的请求。 任务 是“一次导出要完成的工作”; 线程 是执行代码的执行单元; 线程池 管理一组可复用的工作线程,并接收、调度任务。一个工作线程完成一个任务后,可以继续执行下一个任务。线程池因此可以减少反复创建线程的开销,也能限制和管理资源使用[1]。

这里的“异步”是指提交方和任务执行可以分开推进,不代表任务一定立即开始。任务可能正在排队;某些拒绝策略甚至会让提交方自己执行它。

先认识本课会用到的名字

名字负责什么
Executor(执行器接口)任务执行接口,入口是 execute(执行提交方法)。
ExecutorService(执行器服务接口)在执行基础上增加提交结果、取消和关闭管理。
ThreadPoolExecutor(线程池执行器)可配置线程数、队列和拒绝策略的具体实现。
Executors(执行器工厂工具类)提供创建执行器的工厂方法;不是线程池接口。
Runnable(无返回值的任务接口) / Callable(有返回值的任务接口)表示任务;前者的执行方法不返回结果,后者可以返回结果并抛出异常。
Future(异步结果句柄)一次异步任务的结果句柄,可以查询完成情况、尝试取消和获取结果。

这些关系与提交方式依据官方 API[2][3]。先记住“任务交给执行器”,不要求现在背接口继承图。

二、7 个参数:按职责记,不按单词背

把参数分成四组: 有多少线程 、 空闲多久回收 、 任务在哪里排队 、 线程怎么建及满了怎么办 。它们共同决定任务如何被接收[1]。

参数含义与边界
corePoolSize(核心线程数)核心线程数阈值。默认按任务到来创建,不是构造时就全部启动。默认不因空闲超时而减到这个数量以下。
maximumPoolSize(最大线程数)池内工作线程数量上限。达到核心数量后,通常先尝试排队;入队失败才尝试向这个上限扩容。
keepAliveTime(空闲回收时间)线程数超过核心数量时,多余空闲线程等待新任务的最长时间;不是任务执行超时。
unit(时间单位)上一个时间值的单位,例如秒。
workQueue(任务队列)保存等待执行任务的阻塞队列。注意:execute(执行提交方法) 使用非阻塞的 offer(尝试放入方法) 尝试入队,不会因为它叫“阻塞队列”就一直等空位。
threadFactory(线程工厂)创建工作线程;可统一线程名称,方便定位日志和线程栈。
handler(拒绝处理器)任务无法被线程池接纳时的拒绝处理器,包括饱和及关闭后的提交。

两个补充: 可以主动预启动核心线程;也可设置 allowCoreThreadTimeOut(true) 让核心数量范围内的空闲线程同样超时退出,此时空闲超时必须大于 0。不要把“核心线程默认保留”说成“核心线程永远不会销毁”[1]。

示例风险与边界: 下面的容量只用于推演,队列满会触发拒绝;实际业务要处理拒绝并管理线程池生命周期。构造器要求核心数非负、最大数大于 0 且不小于核心数、空闲时间非负,各对象参数必须有效。

ThreadPoolExecutor pool = new ThreadPoolExecutor(
    2,                                  // 核心线程数
    4,                                  // 最大线程数
    30, TimeUnit.SECONDS,                // 空闲回收时间与单位
    new ArrayBlockingQueue<>(2),        // 最多等待 2 个任务
    Executors.defaultThreadFactory(),   // 工作线程的创建方式
    new ThreadPoolExecutor.AbortPolicy() // 无法接纳时抛出异常
);

Demo/验证: 完整的导入、命名线程工厂、任务提交、异常检查和关闭流程在 ThreadPoolRoutingDemo.java。其有界队列场景使用同样的 2/4/2 容量,验证第 7 个任务被拒绝;本段不是可直接复制上线的配置建议。

三、执行流程:核心 → 队列 → 最大 → 拒绝

先假设线程池处于运行状态、线程创建成功且参数不动态变化。每提交一个新任务,按下面顺序判断[1]:

不足 core:新建线程 → 否则 → 尝试放进队列 → 失败 → 不足 max:扩容 → 仍失败 → 调用拒绝策略

判断的是池内工作线程数,不是正在忙的线程数。 不足核心数时,即使已有线程空闲,也会优先尝试新建;达到核心数后,先入队,空闲工作线程可以从队列取任务。不要背成“核心都忙了就直接开到最大”。

实现还会在入队后复查关闭状态,并在没有工作线程时尝试补一个来消费队列。上图是正常接纳路径;它不意味着成功入队后就不用再检查状态。源码摘录见官方存档 C 节[1]。

把 7 个任务逐个放进去

沿用 core = 2、max = 4、队列容量 2、AbortPolicy(抛异常拒绝策略)。任务由同一个提交者依次提交, 在第 7 个任务提交前,前面所有任务都尚未结束 。

新任务它的去向池内线程数排队数
1新建工作线程执行10
2新建工作线程执行20
3入队等待21
4入队等待22
5入队失败,新增线程直接执行它32
6入队失败,新增线程直接执行它42
7入队失败,也不能扩容,抛拒绝异常42

注意任务顺序: 5、6 可以比排队的 3、4 更早开始,不能把“队列先进先出”推成“整个线程池严格按提交顺序开始或结束任务”。新线程可能直接执行触发扩容的新任务。这个过程由本课 Demo 在 JDK 17/21 上验证[4]。

四、队列会改变最大线程数的实际作用

选择发生什么需要考虑什么
ArrayBlockingQueue(数组阻塞队列)显式指定容量;排满后才有机会向最大线程数扩容。队列和线程数一起限制容量;队列过大也会增加等待时间。
LinkedBlockingQueue(链式阻塞队列)可以指定容量;不指定时容量为 Integer.MAX_VALUE(整数类型最大值),通常称为无界队列。通常在队列满之前就会面临内存或等待时间问题;正常负载下最大线程数往往不起扩容作用。
SynchronousQueue(同步移交队列)不存储任务,直接交接给等待接收的线程;没有接收者时 offer(尝试放入方法) 失败。交接失败后会尝试增线程,达到上限则拒绝;不代表提交线程一定等待。

容量与队列交互依据官方文档[1][5]。

典型追问: “核心 2、最大 100,为什么一直只有 2 个线程?”先看是不是用了不指定容量的 LinkedBlockingQueue(链式阻塞队列)。队列一直能接纳任务,就不会走通常的扩容分支。

为什么不能只背 Executors(执行器工厂工具类) 的方法名

newFixedThreadPool(n) 的核心数和最大数都为 n,使用不指定容量的 LinkedBlockingQueue(链式阻塞队列);newSingleThreadExecutor() 也使用这种队列。newCachedThreadPool() 使用 SynchronousQueue(同步移交队列),最大线程数设为 Integer.MAX_VALUE(整数类型最大值),空闲回收时间为 60 秒[5]。

因此,前两者要关注 任务堆积 ,后者要关注 线程增长 。工厂方法并非一律不能用;面试时应说清默认配置能否满足任务量、内存、延迟和下游容量的约束。

五、4 种拒绝策略:业务结果分别是什么

拒绝不只发生在“满了”。 关闭后的新提交也会进入拒绝处理。下面是内置策略[1]:

策略行为对业务的影响
AbortPolicy(抛异常拒绝策略)抛出 RejectedExecutionException(任务被拒绝异常);默认策略。调用方知道提交失败,可以反馈“系统繁忙”或走有明确保障的重试/持久化流程。
CallerRunsPolicy(调用者执行策略)未关闭时,由调用 execute(执行提交方法) 的线程自己执行;已关闭则丢弃。提交速度被任务耗时拖慢,这叫反馈限速;如果提交方是请求线程,它会被占用。
DiscardPolicy(丢弃当前任务策略)直接丢掉本次任务。适用于明确允许丢失且可观测的场景;不能假定任务已执行。
DiscardOldestPolicy(丢弃队头任务策略)未关闭时丢掉队头任务,再重试提交当前任务;已关闭则丢弃。丢的是排队任务,不是正在运行的任务;队头含义取决于队列排序,重试也不保证成功。

CallerRunsPolicy(调用者执行策略) 不是“永不丢任务”的保证。 它在关闭后会丢弃任务;运行时也可能让 HTTP 请求线程执行很久。maximumPoolSize(最大线程数) 限制的是池内工作线程数,不包含在多个提交线程中执行的任务。因此它也不能独自保证下游并发绝不超过 max。

可靠性边界: 内存线程池不是持久化任务系统。审批导出若已对用户确认“受理成功”,还需要定义进程退出或任务失败后的状态、重试与恢复办法。拒绝策略本身不会把任务自动保存到数据库。这是本场景的设计要求,不是某个拒绝策略自带的能力。

六、execute(执行提交方法)、submit(提交并返回结果句柄的方法) 与“异常去哪了”

Runnable(无返回值的任务接口) 的 run() 不返回计算结果;Callable<T> 的 call() 可以返回 T 类型结果。这里讨论它们交给 ThreadPoolExecutor(线程池执行器) 后的常见用法[2]。

入口返回什么如何观察任务异常
execute(Runnable)不返回结果句柄。任务由工作线程运行时,未捕获的运行时异常可使该线程异常结束;应在任务或未捕获异常处理处记录。提交位置的 try/catch 不会自动捕获另一个线程后来抛出的异常。
submit(Runnable / Callable)返回 Future(异步结果句柄)。提交 Runnable(无返回值的任务接口) 而未指定结果时,成功获取结果为 null(空值)。任务异常保存在 Future(异步结果句柄) 中,调用 Future.get(获取结果方法) 时通常以 ExecutionException(任务执行异常) 抛出,原始异常在 cause 中;忽略 Future(异步结果句柄) 可能漏掉失败。

两者的 提交本身 都可能因 AbortPolicy(抛异常拒绝策略) 而抛出拒绝异常。submit(提交并返回结果句柄的方法) 也不保证任务立刻开始:在这个实现中,它把任务包装成可保存结果的任务对象,再走 execute(执行提交方法) 的接纳流程[2]。

Future.get(获取结果方法) 会等待: 需要限制调用方等待时间时,用带超时的 get(读取方法),分别处理任务失败、超时、中断与取消。等待超时不等于任务自动取消;取消也不等于一定能强制停止正在执行的代码[3]。对正常任务采用丢弃策略后,相关 Future(异步结果句柄) 可能一直没有完成,应主动设计失败或取消反馈。

把第 4 课的可见性接过来:提交任务也有规则吗?

有。提交到 ExecutorService(执行器服务接口) 前的动作 happens-before(先行发生关系) 该任务的执行动作;任务中的动作又 happens-before(先行发生关系) 通过 Future.get() 取得结果后的动作[2]。

提交前准备输入 → 提交任务 → 任务执行 → Future.get(获取结果方法) 取得结果 → 调用方使用结果

这不意味着提交后随意修改共享输入就是安全的。它仍然需要明确的访问规则。

七、参数怎么配:从任务和瓶颈开始

先量四件事: 任务到达速度、每个任务占用线程的时间、允许的排队时间、下游能承受的并发 。还要观察单个排队任务持有多少内存。增加线程数不能凭空增加数据库连接或远程接口的处理能力。

任务特点配置思路验证重点
主要做 CPU(处理器) 计算从接近可用 CPU(处理器) 核数的并行度开始试验,给应用其他工作留余量。吞吐量、CPU(处理器) 使用率、调度开销与响应时间。
主要等待数据库/网络可以有更多工作线程,但要受连接数、接口限额等限制。等待时间、连接占用、下游错误率和长尾延迟。
快任务与慢任务混在一起考虑按业务和资源瓶颈分池,防止慢任务占满所有处理能力。各类任务是否互相拖累,总线程数是否仍在可控范围内。

这是基于官方文档中队列、线程数与资源权衡的工程推导[1],不是通用公式或官方推荐的固定数值。

一个能解释清楚的估算

假设导出任务平均每秒到达 20 个,每个任务平均占用工作线程 0.2 秒。则平均需要的忙碌线程数约为 20 × 0.2 = 4 。这只是在稳定平均负载下的工作量估算, 4 个线程没有给波动留余量,不能据此宣布最优配置 。

再假设压力测试测得服务速率约为每秒 20 个任务,允许排队约 0.5 秒,那么约 10 个排队位置可以作为初始测试尺度。这个“20 × 0.5”是粗略估算,任务耗时差异和突发到达会让等待时间偏离平均值;不能把队列容量 10 当作 0.5 秒的硬保证。

假设本机实例允许导出占用的数据库并发最多为 8,而且每个任务同时最多占 1 个连接,可以先试核心 4、最大 8、队列 10,并选择能反馈失败的拒绝方式。再用真实流量分布压测,观察完成量、排队时长、任务失败与拒绝次数。多实例部署时还要核对总连接预算。

面试表达: “我先按任务的 CPU(处理器)/等待占比估算并行度,再受数据库连接和接口限额约束;队列按可接受等待时间与内存来定,最后用压测和监控验证,不直接套‘CPU(处理器) 核数乘二’。”

排查顺序: 队列上涨时,先看任务是否变慢、数据库或远程服务是否卡住,再看线程数与队列配置。监控至少包含池大小、活跃线程数、队列长度、完成速率、排队/执行时长和拒绝次数;拒绝计数与任务耗时通常需要额外埋点,线程池不会替你全部统计。

八、关闭:停止接收,不等于立即停完

方法实际含义
shutdown()发起有序关闭,不再接收新任务,已提交的任务继续处理;方法本身不等待全部结束。
shutdownNow()停止取出等待任务,返回尚未开始的任务,并尝试中断正在执行的任务;不保证它们立刻停止。
awaitTermination(timeout, unit)等待关闭完成、超时或当前线程被中断;返回 true 才表示池已经终止。它本身不发起关闭。

推荐的关闭过程是: 停止接收 → 限时等待 → 必要时尝试中断 → 再次确认终止 。对返回的未执行任务决定取消、记录失败或转入恢复流程。中断是协作信号,任务需要响应它;不能把 shutdownNow(尝试立即关闭) 当作强杀[2]。

本课 Demo 将线程池的创建和关闭放在同一生命周期内,始终在 finally(最终清理代码块) 中释放演示用等待信号并关闭线程池;如果调用方被中断,会恢复中断标记并继续向上抛出。服务端业务应由组件生命周期管理池,不要每个请求新建并销毁一个池。

九、代码演示:亲眼看到队列先满、线程再增

本实验只在本机创建短生命周期线程,不访问数据库和网络。它用 CountDownLatch(倒计时协调器) (一个等信号再继续的同步工具)确保观察时任务尚未结束;全部等待有超时,失败会明确报错。当前无需记这个工具的实现。

java reference/ThreadPoolRoutingDemo.java

Demo/验证: 从项目根目录执行。文件含 4 组内置检查:有界队列的扩容/拒绝及关闭后拒绝、无界队列下的排队、CallerRunsPolicy(调用者执行策略) 在饱和及关闭后的行为、submit(提交并返回结果句柄的方法) 的异常通过 Future(异步结果句柄) 暴露。任何断言失败都会以非零状态退出。查看完整 Java 文件 · 查看 JDK 17/21 实测记录(本地资料)。

第一组应看到线程数依次为 1、2、2、2、3、4 ,队列长度为 0、0、1、2、2、2 ,第 7 个任务被拒绝。换成无界队列后,7 个任务提交完是 2 个线程、5 个排队。

这些数值依赖明确的“任务尚未结束”前提。日常运行时任务会并发完成,监控快照不必呈现相同序列。本实验也不验证线程池参数是否适合某个真实业务。运行通过只说明演示在你的环境可复现;可选深化实验在第十一节。

十、练习:先判断,再展开答案

以下题目都只使用本课已讲过的内容。每题先说一句理由,再选择;答错后看解释即可,不必马上重做整套。选项状态在刷新后会重置。

  1. 核心 2、最大 4、队列容量 2;已有两个线程执行任务,队列为空,任务都未结束。第 3 个任务去哪?
  • A. 新建线程执行
  • B. 放入队列等待
  • C. 触发拒绝策略
查看答案与解释

答案:B

达到核心数后先尝试入队。队列还有空间,不会仅因为两个工作线程都忙就扩容。

  1. 核心 2、最大 4、不指定容量的 LinkedBlockingQueue(链式阻塞队列);依次提交 7 个未完成任务,线程正常创建。池内有几个线程?
  • A. 四个工作线程
  • B. 七个工作线程
  • C. 两个工作线程
查看答案与解释

答案:C

第 3~7 个任务都能入队,不触发扩容。最大值 4 不会让线程池跳过队列直接开到 4。

  1. 线程池已饱和但未关闭,HTTP 请求线程调用 execute(执行提交方法),策略为 CallerRunsPolicy(调用者执行策略)。谁执行这个任务?
  • A. 请求线程执行
  • B. 工作线程执行
  • C. 新建线程执行
查看答案与解释

答案:A

调用者就是提交任务的请求线程,因此本次 execute(执行提交方法) 可能被任务耗时拖住。若池已关闭,这个内置策略会丢弃任务。

  1. keepAliveTime(空闲回收时间) = 30 秒,未开启核心线程超时。它限制什么?
  • A. 单个任务的执行时间
  • B. 多余线程的空闲时间
  • C. 排队任务的等待时间
查看答案与解释

答案:B

它用于回收多余的空闲线程,不是任务执行或排队超时。核心线程超时可以另行启用。

  1. shutdownNow(尝试立即关闭) 对正在运行的任务提供什么保证?
  • A. 必然立即终止执行
  • B. 必然等待正常完成
  • C. 尝试通过中断停止
查看答案与解释

答案:C

它是尽力停止,任务不响应中断就可能继续运行。需要配合有超时的终止等待和任务恢复安排。

  1. 正常接纳的 Callable(有返回值的任务接口) 通过 submit(提交并返回结果句柄的方法) 运行,任务内部抛出异常。通常如何取得原始异常?
  • A. 获取结果检查异常
  • B. 等待一秒检查队列
  • C. 只在提交处抓异常
查看答案与解释

答案:A

检查 Future.get(获取结果方法) 抛出的 ExecutionException(任务执行异常) 的 cause。提交处可以捕获拒绝异常,但不等于能捕获异步任务后来发生的业务异常。

十一、可选深化:预测、改代码、验证

仅在需要验证具体实现缺口时选择下列任务,不要求一次完成三组。选做时新建 exercises/0005-thread-pool/MyThreadPoolLab.java,使用 Java 17 或 21。可以参考示例程序 的导入、带超时的信号等待、线程工厂和关闭辅助方法; 构造器、提交循环、拒绝捕获和结果统计由你独立写 ,不要直接复制整段验证方法。

在笔记中记录配置、预测、实际结果和结论。先写预测,再运行;预测错误也保留,记录你如何修正。以下核对答案默认折叠,完成尝试后再展开。

实验边界: 不访问真实数据库,不制造无限任务。每组使用新线程池,串行提交;任务先等释放信号,确保观察窗口内没有任务结束。所有等待必须有超时,异常路径也要释放信号并关闭池。不要用 sleep 猜“线程应该已经开始了”。信号和清理辅助代码允许参考,不把其实现作为本课考点。

任务 A · 独立配置与容量验证

配置: 核心 1、最大 3、空闲回收 30 秒、有界队列容量 2、可识别的线程名、AbortPolicy(抛异常拒绝策略)。连续尝试提交 6 个任务;在这 6 次尝试结束前,已开始的任务都不能结束。

  1. 先填 6 行预测:当前任务去向、提交尝试后的线程数、排队数,以及是否拒绝。
  2. 用 submit(提交并返回结果句柄的方法) 保存被接纳任务的 Future(异步结果句柄)。拒绝发生时记录任务编号和原因,继续完成统计。
  3. 所有提交尝试完成后释放等待信号,限时取得每个已接纳任务的结果。
  4. 关闭并确认终止。用显式检查验证预测和结果,不只打印“看起来没问题”的日志。

验收: 交配置代码、6 行实测、成功/拒绝数量及终止确认。解释为什么最大线程数不是一开始就用满。

做完任务 A 后核对

1 新建;2、3 排队;4、5 新建;6 拒绝。每次尝试后的“线程数/队列数”为 1/0、1/1、1/2、2/2、3/2、3/2 。释放后共 5 个成功、1 个拒绝,池正常终止。

扩容处理的是入队失败的新任务,所以第 4、5 个任务可以先于排队的第 2、3 个任务开始。不要把整池执行顺序当作验收条件。依据见 接纳流程及官方摘录 A–C[1]。

任务 B · 只改队列,解释结果为什么变了

保留任务 A 的线程数、拒绝策略和 6 个任务,只把队列换成 不指定容量的 LinkedBlockingQueue(链式阻塞队列) 。仍然保持观察前任务不结束。

  1. 运行前预测:线程数、排队数、拒绝数量和最终成功数量。
  2. 修改代码并验证;比较两组日志,指出哪一个流程分支不再触发。
  3. 解释:这种改动减少了本次拒绝,是否就说明业务容量问题解决了?持续积压时会付出什么代价?

验收: 交代码差异、两组结果和解释。开始信号的等待目标要与本组实际能启动的线程数匹配,不能保留上一组的目标导致自己等不到信号。

做完任务 B 后核对

6 次提交后是 1 个线程、5 个排队、0 个拒绝 ;释放后 6 个任务成功。队列能接纳,便没有走通常的扩容分支。任务只是被允许等待更久;持续进入速度超过完成速度时,等待和内存占用仍会增长[1]。

任务 C · 让失败有结果,让程序能够结束

恢复任务 A 的有界配置,仍尝试提交 6 个任务。所有已接纳任务在提交尝试结束后才放行;让 第 2 个任务在放行后抛出带说明的 IllegalStateException ,其他任务正常返回编号。

  1. 分别统计执行成功、执行失败、提交被拒绝;保留失败任务的原始异常类型和说明。
  2. 某个 Future(异步结果句柄) 失败后,仍然检查其余任务结果。不要只在 submit(提交并返回结果句柄的方法) 外捕获业务异常,也不要吞掉异常。
  3. 为结果获取设置超时;超时或中断时记录明确失败,必要时请求取消。保证 finally(最终清理代码块) 清理和限时终止等待。

验收: 三个数量之和等于 6,异常原因可见,已接纳任务结果均被处理,池已终止。若实际发生等待超时或中断,应将本次实验记为失败并排查,不能把未完成任务算成成功。

做完任务 C 后核对

正常实验结果为 4 个成功、1 个执行失败、1 个提交拒绝 。任务 2 的失败由 Future.get(获取结果方法) 抛出的 ExecutionException(任务执行异常) 的 cause 暴露;任务 6 的拒绝在提交处出现。两种失败发生在不同阶段,业务记录也要区分。关闭不替代结果检查[2]。

创建好自己的 Java 文件后,在项目根目录运行:

java exercises/0005-thread-pool/MyThreadPoolLab.java

Demo/单元测试思路: 将 A、B、C 写成三个独立验证方法,各自创建并关闭线程池;对线程/队列数量、结果分类和终止状态做检查,失败时抛异常。逐组检查任务计数,避免一组成功掩盖另一组失败。这条命令须在你创建文件后才能执行,当前不预填你的解答。

十二、故障排查:拿证据决定改什么

教学模拟数据,不是真实生产压测: 导出池核心 2、最大 8,使用不指定容量的 LinkedBlockingQueue(链式阻塞队列)。稳定观察阶段每秒提交约 40 个任务、完成约 20 个;任务平均占用线程约 100ms,未明显变化。服务没有重启,配置没有动态修改。

观察时刻线程/活跃数排队任务进程 CPU(处理器)
0 秒2/2约 0约 25%
30 秒2/2约 600约 25%
60 秒2/2约 1200约 25%

本实例数据库连接预算 12,当前导出占用约 2 个;业务允许本实例导出并发最多为 8。候选方案还必须满足:排队 p95 不超过 500ms(95% 的任务排队时间不超过该值)、对无法受理的任务明确反馈、已确认受理的任务不可悄悄丢失。

  1. 先给出最有依据的原因,说明哪些数据支持它,以及目前还不能判断什么。
  2. 只把最大线程数 8 改成 80,有没有针对这个原因?
  3. 比较“提高核心数”与“有界队列配合扩容”两种方案,说明你的选择及下游约束。
  4. 给出拒绝反馈与受理后失败的处理方案,并列出压测验收指标和撤回改动的条件。

写完排障判断后核对

证据: 进入速度约为完成速度的两倍,队列每秒约增 20;队列一直可入队,线程停在核心数 2。仅提高最大数不能让任务跳过入队分支。现有数据支持“处理能力低于到达量且通常的扩容路径未触发”,不能据此证明 CPU(处理器)、数据库或远程接口在更高并发下仍有余量。

方案: 提高核心数可以增加处理能力,但保留无界队列仍缺乏积压上限;有界队列能够明确接纳边界,并在入队失败时尝试扩容。两者也可以组合。40 × 0.1 = 4 只是平均忙碌线程需求,不含波动余量;候选并发必须受业务上限 8 和总连接预算约束,无法单凭此表确定最优值。

验证: 检查完成速率能否跟上目标提交速率、队列是否持续增长、排队 p95 是否达标,并观察任务失败、拒绝、CPU(处理器) 和数据库等待。候选方案出现持续积压、延迟超预算或下游错误恶化时,停止增加负载并撤回;拒绝阈值需与业务约定,不能自称所有拒绝都可接受。

业务结果: 无法受理时明确返回繁忙或进入有保障的恢复流程;已确认受理的任务要有可查询的状态和失败恢复安排。仅使用 AbortPolicy(抛异常拒绝策略)、CallerRunsPolicy(调用者执行策略) 或内存队列,都不能证明进程退出后任务仍不会丢失。上述是方案分析,尚未实际压测的部分须明确标为待验证。

十三、用完整回答检验理解

先独立完成一项应用判断,再合上提纲,用 2–3 分钟回答“接纳流程是什么、短信场景怎样配置、拒绝与执行失败怎样反馈”。集中核对最重要的两三个问题。代码和实验记录仅在选做时提交;保留原始回答,不用标准答案覆盖。

反馈方式: 一轮完整提交后集中指出最重要的两三个问题,再安排变式或隔日复测;不因一个措辞不稳无限重复同一道题。

整理一次完整回答

这里保留完整口述、应用判断和仍不确定的地方;选做实验时再补结果。

口述与实验摘要

练习:请在自己的笔记中写出答案,再展开参考内容核对。

  • 说清关键参数职责及正常接纳顺序
  • 区分队列容量与执行者数量、提交成功与执行成功
  • 能解释改变队列后为何不再扩容,并指出积压风险
  • 能从调用位置区分提交拒绝、执行失败与等待超时
  • 能解释 CallerRunsPolicy(调用者执行策略)、Future.get(获取结果方法) 超时和 shutdownNow(尝试立即关闭) 的边界,适应条件变化
  • 排障结论对应监控证据,比较方案并遵守并发、等待和任务可靠性约束
  • 明确验收指标、失败恢复与撤回条件,区分已实测结果和待验证假设

十四、隔日复测:换条件再判断

下一次学习时用约 10 分钟,不看前文完成三题,把日期、原始答案和核对结果记在实验模板中。答错用于定位要补的点,不阻挡后续课程;当场自评和隔日保持分别记录。

  1. 核心 2、最大 3、有界队列容量 1、AbortPolicy(抛异常拒绝策略);运行正常、无并发关闭,连续提交 5 个任务,提交完成前任务都未结束。逐个说出去向,最后有多少线程、排队和拒绝?
  2. 一个最大线程数为 3 的池已饱和但未关闭,使用 CallerRunsPolicy(调用者执行策略);另有两个请求线程同时提交长任务。能保证整个应用同时执行这类任务的线程最多为 3 吗?
  3. 已接纳任务的 Future.get(获取结果方法) 等待超时。能据此判断任务已停止吗?接着调用 shutdownNow(尝试立即关闭) 又能保证什么?

隔日作答后核对

① 1、2 新建;3 排队;4 扩容;5 拒绝。最后 3 个工作线程、1 个排队、1 个拒绝。

② 不能。两个提交线程也可能各自执行任务,池内最大线程数不限制这些调用者线程;给定长任务重叠的条件,可以同时有 5 个线程执行任务。

③ Future.get(获取结果方法) 超时只说明没有在等待期限内拿到结果,不会自动取消任务;shutdownNow(尝试立即关闭) 只是尝试中断执行中的任务,仍需任务响应并确认终止。依据见第 5、6、8 节及官方摘录[1][2][3]。

课后复习:第4、5课对话复盘(34个去重问答),含易混点与待复测项。

主来源与复习入口

主读: JDK 21 ThreadPoolExecutor(线程池执行器) 官方说明,优先看 Core and maximum pool sizes、Queuing、Rejected tasks。与本课流程一一对应,暂时不要求读内部同步源码。

  1. ThreadPoolExecutor(线程池执行器):参数、创建时机、排队、扩容、空闲回收与四种拒绝策略。来自本机 Temurin 21.0.10;存档 A–D 节。

  2. ExecutorService(执行器服务接口),以及 ThreadPoolExecutor.afterExecute(执行后回调方法)/AbstractExecutorService.submit(提交并返回结果句柄的方法) 的说明与实现:提交、异常、可见性和关闭。存档 E、F、I、J、K 节。

  3. Future(异步结果句柄):结果、异常、超时与取消。存档 G、K 节。

  4. ThreadPoolRoutingDemo(线程池接纳演示):包含接纳、扩容、拒绝和异常检查。历史验证版本为 JDK 17/21;下载后可按本课命令复现。

  5. Executors(执行器工厂工具类) 与 LinkedBlockingQueue(链式阻塞队列)/SynchronousQueue(同步移交队列):工厂配置与队列容量。存档 H、L 节。

下一课: 第 6 课:ConcurrentHashMap(并发哈希映射)——安全到哪一步?

线程池速查表 · 上一课:synchronized(内置同步关键字) · 学习路线速查

学完后,记下判断理由、运行结果和仍不确定的地方。隔日回看时先尝试回答,再核对课程里的解释。

课后问答:理解是怎样变化的

问题与结论默认可见。展开后可看理解过程;以下根据真实交流整理,并非逐字稿。

两个正在执行、三个排队,等于五个执行线程吗?

最终结论: 不等于。task(任务)是工作内容,worker thread(工作线程)是执行它的资源。排队中的工作不各自占用一个执行线程;完成一项后,执行者可继续处理后续工作。

展开理解过程与易混点

纠正与解释: 把等待的工作数量加到线程数上,是混淆两种对象。

最终结论: 不等于。task(任务)是工作内容,worker thread(工作线程)是执行它的资源。排队中的工作不各自占用一个执行线程;完成一项后,执行者可继续处理后续工作。

现有复盘未逐条保留中间追问,本页不补写对话。

正常运行时,完整接纳顺序是什么?

最终结论: 低于 corePoolSize(核心线程数)时优先尝试创建;达到核心数时尝试入队;入队失败时再尝试在 maximumPoolSize(最大线程数)范围内创建;仍不能接纳时交给 rejection handler(拒绝处理器)。实际实现还会复查运行状态及处理创建失败。

展开理解过程与易混点

最初理解(归纳): 曾认为低于核心数时,应先找空闲执行者。

纠正与解释: 不是先找空闲执行者。当前总数包含空闲与忙碌;即使当前唯一执行者空闲,核心数为2,也会优先尝试创建第2个。

最终结论: 低于 corePoolSize(核心线程数)时优先尝试创建;达到核心数时尝试入队;入队失败时再尝试在 maximumPoolSize(最大线程数)范围内创建;仍不能接纳时交给 rejection handler(拒绝处理器)。实际实现还会复查运行状态及处理创建失败。

现有复盘未逐条保留中间追问,本页不补写对话。

核心2、最大4、队列容量3,连续8次提交且无人完成,去向如何?

最终结论: 前两项触发创建;第3至5项排队;第6至7项入队失败后触发扩容并直接开始执行新提交的工作;第8项交给拒绝处理。前提是运行正常且创建成功。

展开理解过程与易混点

纠正与解释: 新执行者可先执行新来的工作,所以开始执行顺序不必等于提交顺序;“触发拒绝处理”也不必然等于抛异常。

最终结论: 前两项触发创建;第3至5项排队;第6至7项入队失败后触发扩容并直接开始执行新提交的工作;第8项交给拒绝处理。前提是运行正常且创建成功。

现有复盘未逐条保留中间追问,本页不补写对话。

队列刚好满了,会自动扩容吗?提高最大值会立即扩容吗?

最终结论: 不会仅因队列刚好填满就自动创建。通常是在后续提交入队失败时,再尝试扩容。仅提高 maximumPoolSize(最大线程数)也不意味着立刻补足执行者。

展开理解过程与易混点

纠正与解释: 足够大的队列会让新工作持续入队,即使当前数远低于最大值。

最终结论: 不会仅因队列刚好填满就自动创建。通常是在后续提交入队失败时,再尝试扩容。仅提高 maximumPoolSize(最大线程数)也不意味着立刻补足执行者。

现有复盘未逐条保留中间追问,本页不补写对话。

空闲回收时间会中止执行很久的工作吗?谁负责创建线程?

最终结论: keepAliveTime(空闲回收时间)约束空闲等待,默认主要用于超过核心数量的执行者,不是执行超时。thread pool(线程池)决定何时尝试创建,threadFactory(线程工厂)负责创建。

展开理解过程与易混点

纠正与解释: 配置还需要 unit(时间单位);核心执行者也可通过配置允许空闲超时,所以“永远常驻”不精确。

最终结论: keepAliveTime(空闲回收时间)约束空闲等待,默认主要用于超过核心数量的执行者,不是执行超时。thread pool(线程池)决定何时尝试创建,threadFactory(线程工厂)负责创建。

现有复盘未逐条保留中间追问,本页不补写对话。

四种常见 rejection policies(拒绝策略)分别做什么?

最终结论: AbortPolicy(抛异常拒绝策略)抛出拒绝异常;CallerRunsPolicy(调用者执行策略)在池未关闭时由提交者执行,关闭后丢弃;DiscardPolicy(丢弃当前任务策略)丢新提交的工作;DiscardOldestPolicy(丢弃队头任务策略)在池未关闭时移除队头后重试新工作,关闭后丢弃。

展开理解过程与易混点

最初理解(归纳): 曾混淆两种丢弃策略的对象,也曾认为关闭后仍由提交者执行。

纠正与解释: DiscardOldestPolicy(丢弃队头任务策略)丢的是等待队列头部,不是中止最早开始执行的工作;自定义队列下不要想当然等同业务上最老的一项。

最终结论: AbortPolicy(抛异常拒绝策略)抛出拒绝异常;CallerRunsPolicy(调用者执行策略)在池未关闭时由提交者执行,关闭后丢弃;DiscardPolicy(丢弃当前任务策略)丢新提交的工作;DiscardOldestPolicy(丢弃队头任务策略)在池未关闭时移除队头后重试新工作,关闭后丢弃。

现有复盘未逐条保留中间追问,本页不补写对话。

CallerRunsPolicy(调用者执行策略)会让异步变同步吗?

最终结论: 在池未关闭且发生拒绝处理时,本次工作在 submitting thread(提交线程)中直接执行。提交调用要等这段工作执行完才能返回,因此本次调用路径表现为同步执行。

展开理解过程与易混点

纠正与解释: 不是整个池变成同步模式,也不是 synchronized(内置同步关键字)加锁。30秒导出放到请求方执行,就无法保证1秒内返回。

最终结论: 在池未关闭且发生拒绝处理时,本次工作在 submitting thread(提交线程)中直接执行。提交调用要等这段工作执行完才能返回,因此本次调用路径表现为同步执行。

现有复盘未逐条保留中间追问,本页不补写对话。

静默丢弃与“已受理”的返回,为什么危险?

最终结论: 如果采用 DiscardPolicy(丢弃当前任务策略),应用却留下 Pending(等待中)并返回编号,状态可能永远不前进。使用 submit(提交并返回结果句柄的方法)时,被静默丢掉的包装工作对应的句柄也可能一直不完成。

展开理解过程与易混点

纠正与解释: 拿到 task ID(任务编号)或 Future(异步结果句柄)不单独证明一定会执行;对需要可靠反馈的工作不应悄悄丢弃。

最终结论: 如果采用 DiscardPolicy(丢弃当前任务策略),应用却留下 Pending(等待中)并返回编号,状态可能永远不前进。使用 submit(提交并返回结果句柄的方法)时,被静默丢掉的包装工作对应的句柄也可能一直不完成。

现有复盘未逐条保留中间追问,本页不补写对话。

Runnable(无返回值的任务接口)能获得 Future(异步结果句柄)吗?

最终结论: 能。决定是否返回句柄的是提交方式:submit(提交并返回结果句柄的方法)支持两类工作;execute(执行提交方法)不返回句柄。无另行指定结果的无返回值提交,成功完成后的获取结果为 null(空值)。

展开理解过程与易混点

最初理解(归纳): 曾把 Future(异步结果句柄)的来源限定为 Callable(有返回值的任务接口)。

纠正与解释: Callable(有返回值的任务接口)不是句柄的唯一来源。

最终结论: 能。决定是否返回句柄的是提交方式:submit(提交并返回结果句柄的方法)支持两类工作;execute(执行提交方法)不返回句柄。无另行指定结果的无返回值提交,成功完成后的获取结果为 null(空值)。

现有复盘未逐条保留中间追问,本页不补写对话。

拿到句柄后,下载地址在哪里?立即获取结果还是异步吗?

最终结论: Callable(有返回值的任务接口)可以返回下载地址,通过 Future.get(获取结果方法)取出。若立即获取而工作尚未完成,调用方会等待;计算换到另一个执行者,并不保证请求立即返回。

展开理解过程与易混点

纠正与解释: 要求快速响应时,可以先受理并返回应用管理的编号,之后查询状态;初次返回不能宣称文件已经生成。

最终结论: Callable(有返回值的任务接口)可以返回下载地址,通过 Future.get(获取结果方法)取出。若立即获取而工作尚未完成,调用方会等待;计算换到另一个执行者,并不保证请求立即返回。

现有复盘未逐条保留中间追问,本页不补写对话。

等待超时、执行失败和提交拒绝,有何区别?

最终结论: TimeoutException(等待超时异常):这次等待没及时得到结果,工作可能排队或执行中。ExecutionException(任务执行异常):执行过程中失败,通过 getCause(获取原始原因方法)看原因。RejectedExecutionException(任务被拒绝异常):接纳阶段拒绝,不能向用户误报已受理。

展开理解过程与易混点

纠正与解释: 等待超时不自动取消;提交成功也不等于执行成功。界面若统称 Failed(已失败),后台仍应区分失败阶段。

最终结论: TimeoutException(等待超时异常):这次等待没及时得到结果,工作可能排队或执行中。ExecutionException(任务执行异常):执行过程中失败,通过 getCause(获取原始原因方法)看原因。RejectedExecutionException(任务被拒绝异常):接纳阶段拒绝,不能向用户误报已受理。

现有复盘未逐条保留中间追问,本页不补写对话。

shutdown(有序关闭)会做什么?会等现有工作都完成吗?

最终结论: 停止接纳新工作,已经接纳的仍继续处理;调用本身不等待全部终止。awaitTermination(限时等待终止的方法)用于等待,返回 false(假)只表示等待到期而尚未终止。

展开理解过程与易混点

纠正与解释: 有序关闭不是取消正在执行的工作;关闭后即使还有旧工作,新的提交仍进入拒绝处理。

最终结论: 停止接纳新工作,已经接纳的仍继续处理;调用本身不等待全部终止。awaitTermination(限时等待终止的方法)用于等待,返回 false(假)只表示等待到期而尚未终止。

现有复盘未逐条保留中间追问,本页不补写对话。

shutdownNow(尝试立即关闭)与 cancel(true)(尝试取消并请求中断)能保证立即停吗?

最终结论: 不能。前者尝试停止正在执行的工作、停止处理等待队列,并返回尚未开始的工作列表;后者针对对应句柄尝试取消,若允许且执行者可知,尝试发出中断。实际执行是否及时结束依赖代码响应。

展开理解过程与易混点

纠正与解释: 句柄取消状态不等于执行已经停止,返回的未开始工作也不能被假定全部已自动处理好结果状态。

最终结论: 不能。前者尝试停止正在执行的工作、停止处理等待队列,并返回尚未开始的工作列表;后者针对对应句柄尝试取消,若允许且执行者可知,尝试发出中断。实际执行是否及时结束依赖代码响应。

现有复盘未逐条保留中间追问,本页不补写对话。

取消导出时,请求方和执行方怎样配合?

最终结论: 请求可以立即发出;执行方在安全位置检查 interrupt status(中断状态),决定退出后停止写入,关闭资源并处理不完整文件。已成功完成的工作不能因为迟到的取消请求无条件删文件或改状态。

展开理解过程与易混点

纠正与解释: 不能由请求方先删除文件、让执行方继续写;“取消请求已收到”和“确认已停止并清理”要分开。

最终结论: 请求可以立即发出;执行方在安全位置检查 interrupt status(中断状态),决定退出后停止写入,关闭资源并处理不完整文件。已成功完成的工作不能因为迟到的取消请求无条件删文件或改状态。

现有复盘未逐条保留中间追问,本页不补写对话。

return(退出方法语句)、break(跳出循环语句)与 finally(最终清理代码块)在取消中有何区别?

最终结论: 示例里退出整个方法会跳过循环后面的成功提示,但仍执行最终清理;仅跳出循环会继续执行其后的成功提示,因此可能把取消误报为完成。

展开理解过程与易混点

纠正与解释: 关闭资源和停止业务流程是两件都要做的事。这里说的是正常语言执行路径,不涵盖进程强制终止等情况。

最终结论: 示例里退出整个方法会跳过循环后面的成功提示,但仍执行最终清理;仅跳出循环会继续执行其后的成功提示,因此可能把取消误报为完成。

现有复盘未逐条保留中间追问,本页不补写对话。

休眠收到 InterruptedException(中断异常)后,只打印错误继续行吗?

最终结论: 在取消导出场景不行。Thread.sleep(线程休眠方法)被中断后抛异常并清除中断状态。应按取消约定退出并清理;可向上抛出,或在当前层处理时按协议恢复中断状态并退出。

展开理解过程与易混点

纠正与解释: isInterrupted(检查中断状态的方法)本身不清除标记;捕获异常后继续循环可能把取消信号消耗掉。中断的业务含义依赖协议,不是一切异常都必须无条件停止。

最终结论: 在取消导出场景不行。Thread.sleep(线程休眠方法)被中断后抛异常并清除中断状态。应按取消约定退出并清理;可向上抛出,或在当前层处理时按协议恢复中断状态并退出。

现有复盘未逐条保留中间追问,本页不补写对话。

报表和短信隔离后,就能保证短信100毫秒完成吗?

最终结论: 不能。pool isolation(线程池隔离)避免报表直接占满短信专用执行名额;短信仍可能在自己的队列等待,或遇到网络、下游和共享资源变慢。总耗时要考虑排队与实际执行。

展开理解过程与易混点

最初理解(归纳): 最初认为隔离后可以保证短信在100毫秒内完成。

纠正与解释: 它不是 CPU isolation(处理器资源隔离),有足够核心数也不能保证下游响应。

最终结论: 不能。pool isolation(线程池隔离)避免报表直接占满短信专用执行名额;短信仍可能在自己的队列等待,或遇到网络、下游和共享资源变慢。总耗时要考虑排队与实际执行。

现有复盘未逐条保留中间追问,本页不补写对话。

下游允许并发10个,每条100毫秒,理想处理速率是多少?

最终结论: 忽略其他开销并假设服务时间稳定,约100条/秒。持续到达150条/秒,理想情况下平均积压50条/秒;有界队列最终填满。

展开理解过程与易混点

纠正与解释: 队列缓冲短时波动,不能长期弥补到达速率超过处理速率。计算是理想估算,不是实际容量测试。

最终结论: 忽略其他开销并假设服务时间稳定,约100条/秒。持续到达150条/秒,理想情况下平均积压50条/秒;有界队列最终填满。

现有复盘未逐条保留中间追问,本页不补写对话。

允许等待2秒,按100条/秒估算队列容量是多少?

最终结论: 约200条等待中的工作,不包括正在执行的数量。它是起点估算,要考虑波动并通过 load testing(负载测试)验证时延。

展开理解过程与易混点

纠正与解释: 不是配置200就保证每条都不超过2秒。

最终结论: 约200条等待中的工作,不包括正在执行的数量。它是起点估算,要考虑波动并通过 load testing(负载测试)验证时延。

现有复盘未逐条保留中间追问,本页不补写对话。

下游只有10个并发名额,本地增加到20个有什么问题?

最终结论: 如果因此发出20个 concurrent requests(并发请求),可能被限流、拒绝或在下游等待。先明确下游容量与本地资源,再决定限流、排队或拒绝。

展开理解过程与易混点

最初理解(归纳): 最初从排队顺序和创建开销解释,没有先核对下游并发约束。

纠正与解释: 不能只凭工作很短就断言创建开销更高;执行者会复用。增加本地执行者不代表下游容量增加。

最终结论: 如果因此发出20个 concurrent requests(并发请求),可能被限流、拒绝或在下游等待。先明确下游容量与本地资源,再决定限流、排队或拒绝。

现有复盘未逐条保留中间追问,本页不补写对话。

验证码满载时,为何可以明确拒绝而非不断扩大队列?

最终结论: 验证码有时效要求,长时间排队可能让其失去业务价值。无法接纳时明确告知繁忙并让调用方按约定稍后重试,比静默丢弃后误报成功更合适。

展开理解过程与易混点

纠正与解释: “队列满”不是必然立即拒绝,还可能尝试扩容;准确触发条件是仍无法接纳。重试也需要节制,避免放大拥堵。

最终结论: 验证码有时效要求,长时间排队可能让其失去业务价值。无法接纳时明确告知繁忙并让调用方按约定稍后重试,比静默丢弃后误报成功更合适。

现有复盘未逐条保留中间追问,本页不补写对话。

两个应用各允许10个,同时满足系统共享上限10吗?

最终结论: 不满足,合计可能20个。是否共享配额由服务商按密钥、账号等定义;不能只凭密钥不同认定额度独立。固定各分5个可守住总量,但空闲一方的余量不能自动被忙碌一方使用。

展开理解过程与易混点

最初理解(归纳): 最初认为两个应用各允许10个,也能满足共享上限10;后来注意到固定分配会产生负载不均。

纠正与解释: local limit(本地限制)只约束自身;共享限制需要系统范围内共同协调,本课不要求实现。

最终结论: 不满足,合计可能20个。是否共享配额由服务商按密钥、账号等定义;不能只凭密钥不同认定额度独立。固定各分5个可守住总量,但空闲一方的余量不能自动被忙碌一方使用。

现有复盘未逐条保留中间追问,本页不补写对话。

怎样完整说短信配置与结果确认?

最终结论: 单独分配执行资源;并发量参考下游共享额度和本地资源;等待容量参考处理速率与时效;无法接纳时明确反馈。执行结果要单独观察,服务商受理成功也不一定等于用户已收到,可能还需 delivery receipt(送达回执)。

展开理解过程与易混点

纠正与解释: 不要只报参数名称,也不要把提交、下游受理、最终送达合成一个“成功”。

最终结论: 单独分配执行资源;并发量参考下游共享额度和本地资源;等待容量参考处理速率与时效;无法接纳时明确反馈。执行结果要单独观察,服务商受理成功也不一定等于用户已收到,可能还需 delivery receipt(送达回执)。

现有复盘未逐条保留中间追问,本页不补写对话。