面试知识

线程池与异步编排:从执行路径到容量、背压与故障恢复

10-Java并发与锁体系 面试知识整理。

线程池与异步编排:从执行路径到容量、背压与故障恢复

本章迁移并扩展旧主文档的 4.5、7.2、11.4 与 11.5。你学完后应能把一次任务从 execute(执行)提交、排队、扩线程、拒绝、运行到关闭恢复讲成完整链路;也应能用到达率、服务时间、队列等待和尾延迟配置容量,而不是只把线程数或队列长度调大。

版本口径:线程池主体以 JDK(Java 开发工具包)8 的 ThreadPoolExecutor(线程池执行器)实现解释;CompletableFuture(异步编排)的超时方法以 JDK(Java 开发工具包)9 及以上为前提;虚拟线程以 JDK(Java 开发工具包)21 为边界。线程池只控制本进程的执行资源,不能替代下游连接池、数据库、MQ(消息队列)或外部 API(应用程序接口)的容量治理。

1. 简历关联点与面试主线

WMS(仓储管理系统)的库存冻结、跨境物流面单拉取、支付回调补偿、异步导出、Runner(执行器)调度和 IoT(物联网)报警聚合都需要后台执行,但它们的失败代价不同:支付和库存任务不能静默丢失,导出可延迟,报警可以聚合降级。面试时先给出任务分级和最终幂等边界,再解释线程池隔离、队列、拒绝和恢复,最后用指标与 jstack(线程栈工具)闭环。

任务线程池边界可接受结果不能接受的设计
库存冻结与支付补偿核心交易工作池,短超时。快速失败后持久化补偿。DiscardPolicy(丢弃策略)无记录丢失。
面单与轨迹查询渠道独立 I/O(输入输出)池。超时、部分成功、稍后重试。与支付共享无界队列。
异步导出有界导出池与存储限额。排队、限速、断点续跑。全量读入内存再异步化。
IoT(物联网)报警聚合与推送分池。去重、采样、摘要通知。每条报警立即创建任务并同步重试。

2. ThreadPoolExecutor(线程池执行器)核心:任务如何被接纳

2.1 ctl(控制状态)与 execute(执行)的三步决策

ThreadPoolExecutor(线程池执行器)用一个 ctl(控制状态)整型同时保存 runState(运行状态)和 workerCount(工作线程数):高 3 位表示状态,低 29 位表示已创建的 Worker(工作线程)数量。这样状态切换和线程计数能通过 CAS(比较并交换)协调,避免“刚判断可建线程、另一线程已关闭”的分裂视图;它不是任务数量,排队任务由 workQueue(任务队列)保存。

execute(执行)不是“先塞队列,满了才建核心线程”,而是三步:第一步,workerCount(工作线程数)小于 corePoolSize(核心线程数)则尝试 addWorker(增加工作线程)直接执行;第二步,核心已满则尝试 workQueue.offer(任务队列尝试入队),成功后必须二次检查仍处于 RUNNING(运行中)且至少有一个工作线程;第三步,入队失败才按 maximumPoolSize(最大线程数)创建非核心 Worker(工作线程),仍失败才调用拒绝处理器。二次检查是关键:任务入队后若刚好关闭,必须移出并拒绝,不能留下无人消费的任务。

flowchart TD
    A["execute(执行)提交任务"] --> B{"workerCount(工作线程数) < corePoolSize(核心线程数)?"}
    B -->|是| C["addWorker(增加工作线程),以核心资格执行"]
    C -->|失败| D{"workQueue(任务队列)可 offer(尝试入队)?"}
    B -->|否| D
    D -->|是| E["二次检查 runState(运行状态)"]
    E -->|RUNNING(运行中)且有 Worker(工作线程)| F["等待 getTask(获取任务)"]
    E -->|已关闭| G["remove(移除)任务并拒绝"]
    E -->|无 Worker(工作线程)| H["补一个非核心 Worker(工作线程)"]
    D -->|否| I{"workerCount(工作线程数) < maximumPoolSize(最大线程数)?"}
    I -->|是| J["addWorker(增加工作线程),以非核心资格执行"]
    I -->|否或失败| K["RejectedExecutionHandler(拒绝处理器)"]

**图解。**正常路径优先用核心线程,再用队列削峰,最后才扩到最大线程;失败路径是队列入队与关闭并发,或线程和队列都满;结论是 maximumPoolSize(最大线程数)只有队列拒绝入队时才有机会生效,不能只盯一个参数。

字段或参数它约束什么常见误解生产检查点
ctl(控制状态)状态与工作线程数的一致快照。等同于队列长度。关闭时是否仍接受任务。
corePoolSize(核心线程数)优先直接创建的常驻目标。永远不会回收。是否允许核心超时。
maximumPoolSize(最大线程数)队列无法接纳后的硬上限。使用无界队列仍会扩到它。队列类型与容量。
keepAliveTime(空闲存活时间)可超时工作线程的回收等待。只影响任务执行超时。是否会导致突发冷启动。
allowCoreThreadTimeOut(允许核心线程超时)是否让核心线程也按空闲时间退出。开启后立即销毁核心线程。低流量和预热需求。

**数据演绎一:有界队列下的三步。**设 corePoolSize(核心线程数)为 4、maximumPoolSize(最大线程数)为 8、ArrayBlockingQueue(数组阻塞队列)容量为 6。第 1~4 个 500 ms(毫秒)任务分别创建核心 Worker(工作线程);第 5~10 个入队;第 11~14 个因队列满而扩到 8 个工作线程;第 15 个触发拒绝。若每秒进入 20 个、每个执行 500 ms(毫秒),稳态需要约 10 个并发执行,配置最多只能同时运行 8 个,积压或拒绝是确定结果,不是偶发现象。

热门面试题

  1. 问题(基础题)ThreadPoolExecutor(线程池执行器)的 execute(执行)为什么先建核心线程、再入队、最后扩最大线程?
    • 考点:三步决策、队列位置、资源上限。
    • 回答思路:按直接执行、入队二次检查、非核心扩张和拒绝四段说明。
    • 详细答案:先建核心线程可避免低负载下任务额外等待;核心满后先入队可削平短暂尖峰;队列明确无法接纳时才扩非核心线程,防止把瞬时流量直接放大成大量线程。入队成功后还要复查 runState(运行状态),关闭则移除并拒绝,确保不会把任务遗留在没有消费者的队列中。
    • 进阶追问:为什么无界队列会弱化 maximumPoolSize(最大线程数)?
    • 进阶回答:无界队列几乎总能 offer(尝试入队)成功,第三步永远不触发;线程数通常停在 corePoolSize(核心线程数),压力转化为无限排队、内存占用和越来越长的任务年龄。
  2. 问题(原理题)ctl(控制状态)为什么把 runState(运行状态)和 workerCount(工作线程数)放在一起?
    • 考点:CAS(比较并交换)、状态竞争、原子快照。
    • 回答思路:指出提交、建线程和关闭并发发生,单独变量会产生检查后失效。
    • 详细答案:提交线程可能刚看到线程数未满,关闭线程就已进入停止;把状态和数量编码进同一个 ctl(控制状态)后,CAS(比较并交换)可在同一版本上验证并更新,失败就重读。它不消灭所有循环重试,却把“在错误状态新增工作线程”缩小为可校验的竞态路径。
    • 进阶追问ctl(控制状态)能否用来统计积压?
    • 进阶回答:不能。它只代表状态和工作线程数;积压还要看 getQueue().size(获取队列大小)、任务提交速率、任务年龄和完成速率,单看一个长度无法判断会不会自行消退。
  3. 问题(项目题):支付补偿任务被拒绝时,你怎样保证不静默丢失?
    • 考点:拒绝处理、持久化、幂等、告警。
    • 回答思路:先区分同步主链与异步补偿,再给出落库、幂等和可观测动作。
    • 详细答案:拒绝处理器会记录业务任务标识、订单号、尝试次数和拒绝原因,并在同一事务或可靠状态机内把待补偿状态持久化;调度器按幂等键重新领取,而不是在请求线程无限重试。同步主链快速返回可追踪结果,监控拒绝数、任务年龄和补偿延迟,超过阈值暂停非核心工作并告警。
    • 进阶追问:拒绝处理器直接写 MySQL(关系型数据库)也满了怎么办?
    • 进阶回答:关键路径要有独立的持久化容量和超时预算;若连状态不可持久化,就明确返回可感知失败并触发人工或上游重投,绝不能假装已受理。数据正确性不能依赖进程内队列的幸存。

2.2 Worker(工作线程)、getTask(获取任务)与 runWorker(运行工作线程)

每个 Worker(工作线程)封装一个真实线程、可能的首个任务和一个独占锁。addWorker(增加工作线程)先以 CAS(比较并交换)预留 workerCount(工作线程数),再创建并启动线程;若线程工厂失败或启动前关闭,必须回滚计数。runWorker(运行工作线程)先执行首任务,随后不断调用 getTask(获取任务);执行前锁住 Worker(工作线程)以标识“忙”,执行后调用 afterExecute(执行后回调),最后累计完成数并进入退出处理。若任务抛出未捕获异常,当前工作线程会退出,池会依规则补足,业务异常不能靠吞掉来伪装成功。

getTask(获取任务)决定缩容:当 allowCoreThreadTimeOut(允许核心线程超时)为真,或当前数大于 corePoolSize(核心线程数)时,使用带 keepAliveTime(空闲存活时间)的 poll(限时获取);否则用 take(阻塞获取)。在 SHUTDOWN(平滑关闭)且队列为空、或 STOP(停止)及以后状态时返回空并退出。超时不是“任务超时”,只是空闲线程的回收条件。

stateDiagram-v2
    [*] --> RUNNING: 构造完成
    RUNNING --> SHUTDOWN: shutdown(平滑关闭)
    RUNNING --> STOP: shutdownNow(立即关闭)
    SHUTDOWN --> TIDYING: 队列为空且 Worker(工作线程)为零
    STOP --> TIDYING: Worker(工作线程)为零
    TIDYING --> TERMINATED: terminated(终止回调)结束
    SHUTDOWN --> STOP: shutdownNow(立即关闭)

**图解。**正常关闭从 RUNNING(运行中)进入 SHUTDOWN(平滑关闭),不再接纳新任务但会消费已入队任务;失败或紧急止血可进入 STOP(停止),中断工作线程并返回队列中尚未开始的任务;结论是两个关闭方法都不能强杀忽略中断的用户代码,任务必须配合中断、超时与幂等恢复。

flowchart LR
    A["addWorker(增加工作线程)"] --> B["Worker(工作线程)保存 firstTask(首任务)"] --> C["runWorker(运行工作线程)"]
    C --> D["执行任务前加 Worker(工作线程)锁"] --> E["task.run(任务运行)"] --> F["afterExecute(执行后回调)"]
    F --> G["getTask(获取任务)"]
    G -->|取到| D
    G -->|队列空、超时或关闭| H["processWorkerExit(处理工作线程退出)"] --> I["必要时补充 Worker(工作线程)"]

**图解。**正常路径让一个 Worker(工作线程)循环复用,减少频繁创建线程;失败路径包括线程工厂失败、任务未捕获异常、空闲超时和关闭;结论是“线程池有 16 个线程”不等于“有 16 个正在执行”,还要区分忙、空闲、阻塞和已退出。

关闭方法是否接纳新任务已入队任务对运行中任务调用方必须做什么
shutdown(平滑关闭)否。继续执行。不主动中断。等待 awaitTermination(等待终止)并保留任务状态。
shutdownNow(立即关闭)否。从队列移出并返回。发出中断请求。将返回任务重新持久化或交给恢复器。
进程崩溃否。内存队列直接消失。线程消失。依赖持久化任务、租约和幂等重放。

**数据演绎二:核心超时与突发冷启动。**导出池 corePoolSize(核心线程数)为 4、keepAliveTime(空闲存活时间)为 60 秒(秒)。默认情况下,连续 10 分钟(分钟)空闲后仍保留 4 个线程;若开启 allowCoreThreadTimeOut(允许核心线程超时),60 秒(秒)后四个工作线程都可退出。随后 100 个任务同时到来,前 4 个需重新建线程,剩余 96 个排队;若线程创建与预热各耗 40 ms(毫秒),首批任务的额外排队可见。低频批处理可接受这一节省,低延迟交易池通常保留核心线程或在发布后预热。

热门面试题

  1. 问题(基础题)getTask(获取任务)何时让工作线程退出?
    • 考点:空闲超时、核心线程、关闭状态。
    • 回答思路:区分队列为空、可超时资格与停止状态,避免把空闲超时说成任务超时。
    • 详细答案:处于 STOP(停止)及以后,或 SHUTDOWN(平滑关闭)且队列已空时,getTask(获取任务)返回空;运行中则只有非核心线程,或开启 allowCoreThreadTimeOut(允许核心线程超时)的核心线程,在空闲 keepAliveTime(空闲存活时间)后可以退出。若核心线程不可超时且池仍运行,它会阻塞等待新任务。
    • 进阶追问:为什么缩容不能只看 activeCount(活跃线程数)?
    • 进阶回答:活跃数只反映瞬时执行状态,不能表达排队年龄、下游阻塞、即将到来的周期任务和线程创建成本。缩容要结合到达率曲线、服务时间分位数、预热时间和业务延迟预算。
  2. 问题(原理题)shutdownNow(立即关闭)为什么不能保证任务已经停止?
    • 考点:中断协作、不可抢占、半完成副作用。
    • 回答思路:说明它中断线程、返回未开始任务,但用户代码可忽略中断。
    • 详细答案shutdownNow(立即关闭)会把运行状态推进到 STOP(停止)、中断工作线程并返回队列中尚未执行的任务;Java(编程语言)的中断是协作信号,不会安全地抢占任意代码。正在进行的数据库写入、文件写入或外部调用可能完成、部分完成或忽略中断,因此任务必须有检查点、超时、幂等键和可重入恢复逻辑。
    • 进阶追问:何时可以直接重启服务止血?
    • 进阶回答:仅在任务状态已持久化、重复执行被幂等保护、正在执行任务有租约超时接管且关键事务可对账时。否则重启只会把“线程耗尽”变成“任务状态不明”。
  3. 问题(项目题):Runner(执行器)调度服务怎样处理工作线程异常退出?
    • 考点:租约、检查点、任务领取、恢复。
    • 回答思路:把线程生命周期和业务任务生命周期分开,强调持久化状态。
    • 详细答案:Runner(执行器)领取任务时写入运行实例、开始时间、租约到期和幂等键;执行过程按阶段提交检查点。Worker(工作线程)因异常退出只影响当前进程内执行,监控器发现租约过期后由其他实例重新领取未完成阶段;外部副作用按幂等键查询或补偿,不能仅凭线程池的完成计数宣布任务成功。
    • 进阶追问:长任务租约如何避免被误接管?
    • 进阶回答:执行者定期心跳续租,续租失败立刻停止后续副作用;接管者在原子条件更新成功后才执行。租约期限覆盖正常心跳抖动但短于可接受恢复时间,并对续租延迟告警。

3. BlockingQueue(阻塞队列)选型:队列就是延迟和内存的边界

3.1 五类 BlockingQueue(阻塞队列)及 maximumPoolSize(最大线程数)的真实作用

BlockingQueue(阻塞队列)决定任务在“排队、扩线程、拒绝”之间如何取舍。ArrayBlockingQueue(数组阻塞队列)是固定容量数组,内存边界清楚;LinkedBlockingQueue(链表阻塞队列)可设容量但默认近似无界,节点对象更多;SynchronousQueue(同步移交队列)没有存储容量,提交者必须与接收者配对;PriorityBlockingQueue(优先级阻塞队列)按优先级取出但本身无界;DelayQueue(延迟队列)只在到期后可取,也无固定容量上限。无界队列通常让 maximumPoolSize(最大线程数)失去扩线程机会,因此它不是“更安全”,而是把过载从拒绝延后为内存和延迟事故。

flowchart LR
    A["任务到达"] --> B{"队列可接纳?"}
    B -->|ArrayBlockingQueue(数组阻塞队列)满| C["尝试扩到 maximumPoolSize(最大线程数)"]
    B -->|LinkedBlockingQueue(链表阻塞队列)无界| D["持续排队,通常不扩线程"]
    B -->|SynchronousQueue(同步移交队列)无缓存| E["必须立即有 Worker(工作线程)接手"]
    C --> F{"仍无容量?"}
    E --> F
    F -->|是| G["拒绝、背压或持久化转移"]

**图解。**正常路径由有界队列吸收短尖峰,再有限扩线程;失败路径是无界队列掩盖过载直到任务对象占满堆,或直接移交队列在突发下快速拒绝;结论是队列容量应来自延迟和内存预算,不是随手填 10000。

| 队列 | 排队特征 | 对 maximumPoolSize(最大线程数)的影响 | 适用边界 | 风险 | | --- | --- | --- | --- | | ArrayBlockingQueue(数组阻塞队列) | 固定容量、FIFO(先进先出)。 | 满后会进入扩线程路径。 | 需明确内存与延迟上限的服务。 | 单锁竞争与容量过小导致拒绝。 | | LinkedBlockingQueue(链表阻塞队列) | 可有界;默认容量很大。 | 无界时通常不会扩到最大。 | 已严格设容量的缓冲场景。 | 节点开销、延迟隐藏、OOM(内存溢出)。 | | SynchronousQueue(同步移交队列) | 零容量直接交接。 | 每次都可能扩线程或拒绝。 | 短任务、弹性并发、明确上限。 | 突发时线程膨胀或拒绝。 | | PriorityBlockingQueue(优先级阻塞队列) | 优先级出队,默认无界。 | 通常不触发最大线程扩张。 | 可延后低优先级且容量另控。 | 低优先级饥饿、内存无界。 | | DelayQueue(延迟队列) | 到期才可出队,默认无界。 | 不能把它当容量阀门。 | 延迟重试与定时触发。 | 到期同时释放形成洪峰。 |

**数据演绎三:队列容量由等待预算反推。**面单查询到达率为 80 次/秒(每秒),允许排队等待最多 250 ms(毫秒)。可容忍队列量约为 80 × 0.25 = 20 个任务;再给 2 倍尖峰余量,队列可设 40,而不是 4000。若每个任务含 300 KiB(千字节)请求、响应和上下文,40 个约占 12 MiB(兆字节),4000 个约占 1.17 GiB(吉字节),还未计算链表节点和对象引用。容量越大不等于吞吐越高,只是让失败更晚被发现。

热门面试题

  1. 问题(基础题):为什么无界 LinkedBlockingQueue(链表阻塞队列)会让 maximumPoolSize(最大线程数)形同虚设?
    • 考点execute(执行)顺序、入队成功、扩线程条件。
    • 回答思路:指出最大线程扩张发生在入队失败之后。
    • 详细答案:核心线程已满时,execute(执行)会先尝试入队;无界 LinkedBlockingQueue(链表阻塞队列)几乎不会拒绝,任务不断进入队列,第三步创建非核心 Worker(工作线程)的条件不成立。因此线程数往往停在核心数,服务时间一变慢就形成大量排队、堆内对象和尾延迟。
    • 进阶追问:无界队列是否永远不能用?
    • 进阶回答:不是绝对不能,但必须由上游总量控制、单任务内存极小、持久化或外部排队承担积压,并有独立的任务年龄阈值;对在线关键路径,它通常缺少明确的过载反馈。
  2. 问题(原理题)SynchronousQueue(同步移交队列)为什么适合又危险?
    • 考点:零容量、直接移交、线程增长。
    • 回答思路:从没有缓冲这一事实推导低等待和高拒绝敏感性。
    • 详细答案:它不保存元素,提交必须与正在等待的消费者直接配对,因此正常时任务几乎不排队;没有空闲 Worker(工作线程)时,线程池会尝试建线程直到 maximumPoolSize(最大线程数),再失败就拒绝。它适合短小、可并行、上限明确的任务,不适合慢 I/O(输入输出)或突发不可控的业务。
    • 进阶追问:为什么 DelayQueue(延迟队列)不等于限流?
    • 进阶回答:它只控制“何时可取”,不限制总入队量;大量任务在同一时间到期会一起涌出。延迟重试仍要有领取速率、并发上限、抖动和持久化容量。
  3. 问题(项目题):IoT(物联网)报警为什么不直接放进 PriorityBlockingQueue(优先级阻塞队列)?
    • 考点:优先级、饥饿、容量、聚合。
    • 回答思路:认可高优先级价值,再指出它不是容量治理。
    • 详细答案:严重报警可获得优先处理,但 PriorityBlockingQueue(优先级阻塞队列)默认无界,持续高优先级还会让普通报警饥饿。我会先按设备和规则做窗口聚合、去重与限流,再将少量可执行通知放入有界队列;不同优先级有独立配额和最大等待时间,超时后写入可查询的待处理记录。
    • 进阶追问:优先级相同如何避免顺序混乱?
    • 进阶回答:为任务增加单调序号并在比较器中以“优先级、到达序号”排序;但跨设备的全局顺序通常没有业务价值,不能为了顺序牺牲可用性和吞吐。

4. 容量、拒绝与 Backpressure(背压):让过载可见而可恢复

4.1 Little’s Law(利特尔定律)与线程池参数实测

容量先从工作量而非机器核数开始。Little’s Law(利特尔定律)是 L = λW:稳态系统中的平均在制任务 L,等于到达率 λ 乘平均停留时间 W。在线程池中,W 包含队列等待和执行;若只用平均执行时间算线程数,却忽略排队,就会在 P95(95 分位响应时间)恶化后得出错误结论。测量至少拆分提交率、开始率、完成率、队列等待、执行时长、任务年龄和拒绝数。

CPU(中央处理器)密集任务的线程数通常从可用核数附近开始,保留少量给 GC(垃圾回收)、网络和系统线程;I/O(输入输出)密集任务可按“核数 × (1 + 等待时间/计算时间)”做初始假设,但连接池、远端限额和上下文切换会先成为边界,必须用压力测试校正。每一种下游依赖都要独立池,不能把“总线程很多”误当作“每个下游都有余量”。

flowchart TD
    A["采样 λ(到达率)、排队等待、服务时间"] --> B["Little's Law(利特尔定律):L = λW"]
    B --> C["确定并发执行需求"]
    C --> D["按等待预算反推有界队列"]
    D --> E["设置 corePoolSize(核心线程数)与 maximumPoolSize(最大线程数)"]
    E --> F["压测 P95(95 分位响应时间)、拒绝数、任务年龄"]
    F -->|未达标| A
    F -->|达标| G["上线阈值与降级策略"]

**图解。**正常路径是“测量—推导—压测—回调”,每一步都约束下一步;失败路径是只按 CPU(中央处理器)核数或经验公式拍参数,忽略下游等待与内存;结论是公式给初始点,不给生产承诺。

指标计算或含义观察到它恶化时先问什么
λ(到达率)每秒提交任务数。是正常流量上升,还是重试放大?
服务时间从开始执行到结束。卡在 CPU(中央处理器)、I/O(输入输出)、锁还是下游?
队列等待开始时间减入队时间。为什么工作线程未能及时领取?
任务年龄当前时间减首次受理时间。是否已经超过业务时效而应丢弃或转人工?
P99(99 分位响应时间)最慢 1% 的尾部。是否被少量慢依赖或重试拖长?
rejectCount(拒绝次数)无容量时的受理失败。是否已按业务语义持久化或快速失败?

**数据演绎四:Little’s Law(利特尔定律)与队列预算。**订单聚合调用到达 λ 为 120 次/秒(每秒),正常平均服务时间为 200 ms(毫秒),仅执行中的平均并发 L 为 120 × 0.2 = 24。若端到端目标为 500 ms(毫秒),则系统总在制任务最多约为 120 × 0.5 = 60,允许排队约 36 个;超过 36 表明即使不再增加请求,平均目标也会破。设 24 个有效并发、队列 40、最大 32 个线程并不保证成功:还要验证下游连接池至少能承载这 24~32 个并发,且 P99(99 分位响应时间)没有将服务时间拉高。

**数据演绎五:CPU(中央处理器)与 I/O(输入输出)两种起点。**8 核实例上,一个纯计算任务平均计算 18 ms(毫秒)、几乎不等待,先用 8~10 个线程压测;若开到 80 个线程,只会让上下文切换和缓存竞争上升。另一个渠道查询每次计算 5 ms(毫秒)、等待远端 45 ms(毫秒),公式起点为 8 × (1 + 45/5) = 80;但渠道连接池只有 30、对方配额每秒 60 次(每秒),因此实际池应先设 24~30、配合每秒 60 的限流,剩余等待不能靠再加线程消除。

热门面试题

  1. 问题(基础题):Little’s Law(利特尔定律)怎样用于线程池容量?
    • 考点:到达率、停留时间、在制任务、等待预算。
    • 回答思路:先写 L = λW,再明确 W 含等待和执行,最后反推队列边界。
    • 详细答案:先从监控获得稳定时段的 λ(到达率)和服务时间,再用 L = λW 得到需要容纳的平均在制任务。若业务给出端到端时延目标,则用总 W 计算允许在制量,减去执行并发后得到可接受队列量;随后压测验证 P95(95 分位响应时间)与 P99(99 分位响应时间),因为平均值会掩盖慢任务。
    • 进阶追问:系统不稳定、队列持续增长时还能直接用吗?
    • 进阶回答:不能把增长期的瞬时均值当稳态结论。提交率大于完成率时 L 会持续上升,公式只能帮助量化当前压力,首要动作是减少到达、恢复服务率或明确拒绝,而不是继续外推容量。
  2. 问题(原理题):I/O(输入输出)密集型为什么不能无限增加线程?
    • 考点:连接池、下游配额、上下文切换、尾延迟。
    • 回答思路:说明等待不占 CPU(中央处理器)不等于不占资源。
    • 详细答案:每个线程仍占栈内存、调度开销和请求对象,也会占用连接、限流令牌与下游并发槽位。超过连接池或供应商容量后,更多线程只是在本地或对方排队,超时和重试又放大尾部;因此线程数必须与连接池、超时、每渠道配额和服务端背压一起设计。
    • 进阶追问:如何证明瓶颈不在本地线程池?
    • 进阶回答:若队列不高、活跃线程未满但任务执行时间拉长,线程栈显示大多卡在同一 HTTP(超文本传输协议)或数据库调用,且下游 RT(响应时间)和连接等待升高,证据指向依赖。此时扩线程不会提高完成率。
  3. 问题(项目题):异步导出如何把容量约束写进产品行为?
    • 考点:排队预算、用户反馈、任务年龄、降级。
    • 回答思路:用可见状态替代无限等待,把导出从同步请求转为可查询任务。
    • 详细答案:导出请求创建持久化任务并立即返回任务号;工作池的并发和队列按存储写入能力、分页速度及内存预算设置,超过受理上限时返回“稍后重试”或预约时间,而不是接收后无限排队。页面显示队列位置、预计时间和过期时间,后台按任务年龄丢弃过时请求并记录原因,用户可重新提交。
    • 进阶追问:用户投诉排队太久,是否先把队列翻十倍?
    • 进阶回答:不先翻。先看完成率、单任务行数分布、存储吞吐、失败重试和任务年龄;队列翻倍只把同样的服务能力分给更多等待者。可按租户限额、拆分大导出、异步通知和离线批处理改善体验。

4.2 四类拒绝策略、CallerRunsPolicy(调用方运行策略)与业务拒绝

内置拒绝策略有四类:AbortPolicy(中止策略)抛出异常,强制调用方处理;CallerRunsPolicy(调用方运行策略)让提交线程自己运行任务;DiscardPolicy(丢弃策略)直接丢弃;DiscardOldestPolicy(丢弃最旧策略)丢掉队首任务后重试提交。后两者对关键业务默认不可用,因为“没有异常”并不表示“已完成”。业务自定义拒绝处理器必须做三件事:记录容量快照和任务标识、按业务语义转持久化补偿或快速失败、递增指标并告警;它不能吞异常后仅写一条难以关联的日志。

CallerRunsPolicy(调用方运行策略)是一种局部 Backpressure(背压):提交方被迫消耗自己的时间,提交速率会下降。它适合任务短小、调用线程可阻塞、失败可感知的同步边缘;不适合 HTTP(超文本传输协议)事件循环、持有关键锁的线程、主交易线程或可能再次向同池提交任务的代码。否则会把排队延迟传播到用户请求,甚至形成锁等待、递归提交或线程饥饿。

flowchart TD
    A["队列满且线程到 maximumPoolSize(最大线程数)"] --> B["拒绝处理器"]
    B --> C["AbortPolicy(中止策略):显式失败"]
    B --> D["CallerRunsPolicy(调用方运行策略):提交方执行"]
    B --> E["DiscardPolicy(丢弃策略):静默丢弃"]
    B --> F["DiscardOldestPolicy(丢弃最旧策略):牺牲队首"]
    B --> G["业务处理:记录、持久化、告警、可重试状态"]
    D --> H["调用方延迟上升"]
    H --> I{"调用方持锁或事件循环?"}
    I -->|是| J["死锁、级联超时或吞吐下降"]
    I -->|否| K["形成受控 Backpressure(背压)"]

**图解。**正常路径是在拒绝点把过载变成明确业务决定;失败路径是静默丢弃或请求线程在错误位置执行慢任务;结论是拒绝不是异常边角,而是容量契约的一部分。

策略调用方看到什么适合什么核心风险
AbortPolicy(中止策略)RejectedExecutionException(拒绝执行异常)。必须显式决定失败或转移的任务。未捕获会变成 5xx(服务器错误)。
CallerRunsPolicy(调用方运行策略)本线程同步变慢。短任务、可阻塞提交者。延迟传播、死锁边界、事件循环污染。
DiscardPolicy(丢弃策略)看似成功。明确可丢且已有独立采样统计。关键任务静默丢失。
DiscardOldestPolicy(丢弃最旧策略)新任务优先。最新状态覆盖旧状态且旧任务确实过期。丢掉最早且可能最重要任务。
自定义拒绝明确状态或持久化回退。支付、库存、调度等有恢复需求。处理器自身阻塞或重复投递。

**数据演绎六:重试放大与背压。**某渠道原始请求为 100 次/秒(每秒),超时后每个请求立即重试 2 次,则最坏尝试量为 300 次/秒(每秒)。若其中 40% 又因线程池拒绝而由上游重试一次,新增 120 次/秒(每秒),总量达 420 次/秒(每秒),已是原始流量的 4.2 倍。若改为最多 2 次、指数退避 200 ms(毫秒)和 800 ms(毫秒)、加入 0~100 ms(毫秒)抖动,并只让持久化恢复器按每秒 60 次领取,峰值不再同时回灌,完成率才有机会追上到达率。

热门面试题

  1. 问题(基础题):四类内置拒绝策略怎样选择?
    • 考点:显式失败、背压、静默丢失、过期语义。
    • 回答思路:按任务是否可丢、提交方能否阻塞、是否有持久化恢复逐项筛选。
    • 详细答案:关键任务优先 AbortPolicy(中止策略)或自定义拒绝,让调用方显式落库、转 MQ(消息队列)或返回失败;短小且提交线程可安全阻塞时可用 CallerRunsPolicy(调用方运行策略)制造背压;只有业务已定义“可丢”时才考虑 DiscardPolicy(丢弃策略),只有新状态确实覆盖旧状态且可审计时才考虑 DiscardOldestPolicy(丢弃最旧策略)。
    • 进阶追问:为什么 DiscardOldestPolicy(丢弃最旧策略)对支付很危险?
    • 进阶回答:队首任务不一定过期,可能是最早的扣款确认或补偿;它被丢弃后新任务仍可能继续,导致状态机缺步骤。支付必须保留可对账的失败记录,而不是由队列顺序决定业务丢弃。
  2. 问题(原理题)CallerRunsPolicy(调用方运行策略)为什么既能背压又可能死锁?
    • 考点:同步执行、锁顺序、延迟传播。
    • 回答思路:先讲提交者变成消费者,再说明提交上下文不能假定安全。
    • 详细答案:它让提交线程直接运行任务,提交动作不再快速返回,所以自然降低新的提交速率。但若提交线程持有另一把锁,而任务需要等待别的线程释放该锁,或任务阻塞 I/O(输入输出)占住事件循环,原本隔离在工作池的等待会传播回调用链;若任务再提交同池任务并同步等待,也可能形成自我饥饿。
    • 进阶追问:怎样安全使用它?
    • 进阶回答:只用于无锁、短、可中断、无递归提交的任务,并记录调用方执行次数和额外耗时;对网络框架事件循环、数据库事务持锁区和核心交易线程,用显式失败加持久化恢复替代。
  3. 问题(项目题):WMS(仓储管理系统)库存补偿池拒绝后怎样恢复?
    • 考点:状态机、幂等键、持久化、重放速率。
    • 回答思路:将“提交成功”与“业务已可靠受理”区分,拒绝后保存可领取状态。
    • 详细答案:拒绝处理器把库存预占号、补偿原因、版本号和幂等键写入补偿表,状态由待执行转为待重试;恢复 Runner(执行器)按租约和限速领取,调用数据库条件更新或幂等接口。每次失败记录下一次执行时间和错误分类,永久失败进入人工队列;不能在拒绝回调中循环提交同一个线程池。
    • 进阶追问:如何避免恢复器再次压垮下游?
    • 进阶回答:恢复任务与在线交易隔离,设置独立并发、令牌限流和渠道熔断;当在线错误率升高时自动降低恢复领取速率。恢复目标是可持续追平,不是瞬间清空积压。

5. CompletableFuture(异步编排):并行而不失去错误语义

5.1 显式线程池、组合算子与异常、超时、取消

不显式传入执行器的 CompletableFuture(异步编排)异步方法通常使用公共 ForkJoinPool(分治线程池)。公共池被日志导出、图片处理或慢 HTTP(超文本传输协议)阻塞后,完全无关的业务也会等待;因此远程 I/O(输入输出)、导出和 CPU(中央处理器)计算应使用命名、限额不同的显式线程池。thenApply(同步转换)在前一阶段完成它的线程中执行转换;thenCompose(异步扁平化)用于返回另一个 CompletableFuture(异步编排)而不制造嵌套;thenCombine(并行合并)等待两个独立结果后合并。带 Async(异步)后缀的方法同样必须明确指定执行器,否则又回到公共池。

异常会沿依赖链以 CompletionException(完成异常)包装传播。用 handle(处理成功或异常)可把单个渠道转为带状态的结果,exceptionally(异常兜底)可提供替代值,whenComplete(完成观察)适合记录但不天然吞掉异常。JDK(Java 开发工具包)9 的 orTimeout(超时失败)和 completeOnTimeout(超时默认完成)只结束 CompletableFuture(异步编排)的等待语义,不能自动终止底层 HTTP(超文本传输协议)或数据库操作;cancel(取消)也不保证中断正在运行的供应函数。真正的资源释放仍靠下游客户端超时、取消句柄和任务协作检查。

flowchart LR
    A["订单履约请求"] --> B["订单池:查订单"]
    A --> C["库存池:查库存"]
    A --> D["物流池:查轨迹"]
    A --> E["支付池:查支付"]
    B --> F["thenCombine(并行合并)"]
    C --> F
    D --> G["handle(处理成功或异常):渠道结果"]
    E --> G
    F --> H["thenCombine(并行合并)"]
    G --> H
    H --> I{"核心数据是否齐全?"}
    I -->|是| J["完整履约视图"]
    I -->|否| K["部分成功 + 失败原因 + 异步补偿"]

**图解。**正常路径让互不依赖的查询并行,最后按业务契约合并;失败路径不是任意一个渠道异常就让全部信息丢失,而是按核心与可降级字段决定部分成功;结论是异步编排的难点是结果语义和资源隔离,不是链式 API(应用程序接口)数量。

算子何时执行适合什么常见错误
thenApply(同步转换)前序完成线程。轻量映射。在其中做慢 I/O(输入输出)。
thenCompose(异步扁平化)前序结果决定下一异步步骤。依赖调用。返回嵌套未来结果且未展平。
thenCombine(并行合并)两个阶段都完成后。独立查询聚合。用于本有先后依赖的调用。
handle(处理成功或异常)成功或失败都执行。单渠道部分成功。把所有异常伪装成正常空值。
orTimeout(超时失败)到达超时后异常完成。明确失败边界。误以为已取消底层调用。

**数据演绎七:并行收益和部分成功。**订单、库存、物流、支付查询分别耗时 40、60、180、90 ms(毫秒),顺序执行为 370 ms(毫秒);四个独立调用并行后,理想聚合时间约为最长的 180 ms(毫秒)加 10 ms(毫秒)合并,约 190 ms(毫秒)。若物流 200 ms(毫秒)超时且物流不是下单判定字段,handle(处理成功或异常)将其转为“轨迹暂不可用”,结果仍在约 210 ms(毫秒)返回;若库存查询失败,则整体快速失败或走安全降级,绝不能拿空库存继续履约。

热门面试题

  1. 问题(基础题):为什么生产环境不应默认把 CompletableFuture(异步编排)放入公共 ForkJoinPool(分治线程池)?
    • 考点:公共资源、阻塞、业务隔离。
    • 回答思路:说明公共池共享,再区分计算任务和阻塞 I/O(输入输出)。
    • 详细答案:公共 ForkJoinPool(分治线程池)由进程中未指定执行器的异步任务共享,慢渠道调用或导出阻塞会占住工作线程,使无关计算和请求聚合排队。生产中按下游、任务类型和失败成本分出显式线程池,并让线程名、队列、拒绝数和执行时间可观测,才能定位并限制故障传播。
    • 进阶追问thenApply(同步转换)也会污染公共池吗?
    • 进阶回答:会。它通常由前一阶段完成线程执行;如果前序在公共池,重转换也占公共工作线程。轻量转换可保留,重 CPU(中央处理器)或阻塞操作应改为显式执行器的异步阶段。
  2. 问题(原理题)thenApply(同步转换)、thenCompose(异步扁平化)和 thenCombine(并行合并)怎样区分?
    • 考点:数据依赖、嵌套未来结果、并行。
    • 回答思路:按“一值变一值、一值触发下一异步、两独立结果合并”回答。
    • 详细答案thenApply(同步转换)把已有结果映射成另一个普通值;thenCompose(异步扁平化)在拿到结果后发起依赖的异步调用并展平;thenCombine(并行合并)要求两个阶段独立启动,等二者完成后合并。把依赖调用写成并行会用到尚未产生的数据,把独立调用串行则白白增加总时延。
    • 进阶追问:如何保留多渠道失败原因?
    • 进阶回答:每个可降级分支用 handle(处理成功或异常)封装为“值、错误分类、耗时、是否可重试”的结果,再统一合并;不要只在最终 exceptionally(异常兜底)返回空对象,否则无法区分缺失、超时和业务不存在。
  3. 问题(项目题):订单履约怎样设计部分成功?
    • 考点:关键字段、降级、补偿、可观测。
    • 回答思路:先列出不能降级的数据,再说明可降级渠道的状态表达。
    • 详细答案:订单状态和库存预占是关键字段,失败必须阻断或走一致性补偿;物流轨迹、推荐信息等可降级字段独立异步查询,设置短超时,失败返回明确的渠道状态和重试时间。聚合结果携带 TraceId(链路标识)、每渠道耗时和错误码,后台任务按幂等键补拉,避免用户刷新触发风暴。
    • 进阶追问cancel(取消)后为何仍要做幂等?
    • 进阶回答:取消的是本地未来结果的交付,不保证远端已停止;请求可能已到达并成功。下次补偿或用户重试必须带同一幂等键,通过查询或条件更新判断真实结果。

5.2 阻塞任务与 ForkJoinPool(分治线程池)的边界

ForkJoinPool(分治线程池)擅长短小、可拆分、以 CPU(中央处理器)计算为主的任务;它依赖工作窃取保持工作线程繁忙。把长时间阻塞的 HTTP(超文本传输协议)、JDBC(Java 数据库连接)或文件 I/O(输入输出)直接放进去,会让窃取没有可运行任务,公共池并行度被耗尽。少量不可避免的受控阻塞可评估 ManagedBlocker(托管阻塞器),但这不是给所有远程调用的通行证;通常更清晰的方案是专用有界 I/O(输入输出)池和客户端超时。

flowchart TD
    A["异步任务"] --> B{"主要耗时是什么?"}
    B -->|短 CPU(中央处理器)计算| C["专用或公共 ForkJoinPool(分治线程池)"]
    B -->|HTTP(超文本传输协议)/JDBC(Java 数据库连接)/文件 I/O(输入输出)| D["显式有界 I/O(输入输出)线程池"]
    B -->|长等待且可挂起| E["JDK(Java 开发工具包)21 virtual thread(虚拟线程)"]
    D --> F["客户端超时、连接池、限流"]
    E --> F

**图解。**正常路径按阻塞性质选择执行模型;失败路径是把所有 CompletableFuture(异步编排)任务扔进公共池,或以虚拟线程绕过下游连接和配额;结论是线程模型不改变外部资源的并发上限。

热门面试题

  1. 问题(基础题):阻塞 HTTP(超文本传输协议)调用能放进 ForkJoinPool(分治线程池)吗?
    • 考点:工作窃取、阻塞、公共池污染。
    • 回答思路:先给“不建议作为常规做法”,再给专用池与超时方案。
    • 详细答案:不应把它作为默认方案。ForkJoinPool(分治线程池)适合可运行的计算任务,长 I/O(输入输出)阻塞会占住工作线程并影响同池的其他任务。远程调用用有界专用池、连接池、客户端超时和限流更可观测;只有受控且经过测量的特殊阻塞才评估 ManagedBlocker(托管阻塞器)。
    • 进阶追问:公共池污染如何取证?
    • 进阶回答:查看线程转储中公共池工作线程长期卡在同一网络或数据库调用,结合公共池活跃度、排队任务、请求 P99(99 分位响应时间)和业务线程名。修复后应比较被隔离业务的尾延迟是否恢复,而非只看某一次成功率。
  2. 问题(原理题):为什么 CompletableFuture(异步编排)超时不等于任务资源已释放?
    • 考点:未来结果、底层操作、协作取消。
    • 回答思路:拆开“调用方不等”和“底层不做”的两层语义。
    • 详细答案orTimeout(超时失败)让未来结果以超时异常完成,调用方可继续降级;底层供应函数可能仍在执行,HTTP(超文本传输协议)连接和线程也可能继续占用。必须同时配置客户端连接、读写和总超时,并让任务检查中断或取消标记,才能避免超时任务继续拖住容量。
    • 进阶追问:超时后应该立刻重试吗?
    • 进阶回答:先判断操作是否幂等、下游是否过载及剩余请求预算。立即重试常与未退出的原调用重叠,增加并发;应有限次数、退避、抖动和熔断,并优先查询已知结果。
  3. 问题(项目题):异步导出为什么不适合公共 ForkJoinPool(分治线程池)?
    • 考点:长任务、阻塞写入、隔离、资源预算。
    • 回答思路:描述分页读取、流式写入的持续占用,而非只说“任务很慢”。
    • 详细答案:导出任务会持续分页查询、序列化和写对象存储,包含长 I/O(输入输出)等待与大对象分配;放入公共 ForkJoinPool(分治线程池)会挤占订单聚合等短任务。我会使用低并发、有界导出池,按租户和文件大小限额,分页流式写入并在每页记录检查点,失败后从检查点恢复。
    • 进阶追问:导出池线程数设大能提高写文件速度吗?
    • 进阶回答:未必。数据库扫描、对象存储带宽、磁盘和 GC(垃圾回收)会成为瓶颈;并发过高反而放大连接占用和内存峰值。以端到端吞吐、失败率和内存曲线压测后决定并发。

6. 吞吐的真相:异步、MQ(消息队列)与虚拟线程的边界

6.1 并行、流水线与 Amdahl’s Law(阿姆达尔定律)

异步和 MQ(消息队列)可以提高吞吐或缩短前台等待,是因为并行、流水线、批处理、解耦和分治让独立工作重叠;它们不会凭空减少总工作量。一个订单仍要校验、扣库存、调用渠道、写库和发消息。若下游每秒只能处理 60 次,前面把任务并行成每秒 600 次只会把瓶颈转移为队列积压、超时和重试。

Amdahl’s Law(阿姆达尔定律)说明可并行比例限制加速上限:加速比 S = 1 / ((1 - P) + P/N)。当 20% 工作必须串行、80% 可由 4 个并行单元处理时,理论上限为 1 / (0.2 + 0.8/4) = 2.5 倍,而不是 4 倍。真实系统还要扣除序列化、排队、锁竞争、网络和合并成本。

flowchart LR
    A["请求受理"] --> B["串行校验与幂等"] --> C["可并行:库存/物流/支付查询"] --> D["串行合并与状态提交"] --> E["MQ(消息队列)异步下游"]
    E --> F["批处理或流水线消费"]
    F --> G{"下游容量足够?"}
    G -->|是| H["吞吐提高、前台更快"]
    G -->|否| I["lag(积压延迟)、超时与 Backpressure(背压)"]

**图解。**正常路径将独立阶段重叠,并以队列解耦短尖峰;失败路径是串行数据库提交或渠道配额成为瓶颈后仍盲目扩并发;结论是异步改变调度与等待位置,不自动消除串行工作和下游限制。

手段能改善什么不能保证什么必配能力
并行缩短独立任务的关键路径。减少总 CPU(中央处理器)或 I/O(输入输出)工作量。超时、合并语义、隔离池。
MQ(消息队列)削峰、解耦、可恢复消费。下游无限吞吐或恰好一次。幂等、积压监控、死信与限速。
批处理降低单条固定开销。任意延迟都更低。批大小、超时冲刷、失败拆分。
流水线重叠不同阶段。最慢阶段不再是瓶颈。每阶段容量和 Backpressure(背压)。

**数据演绎八:Amdahl’s Law(阿姆达尔定律)与下游瓶颈。**履约请求的签名校验和状态提交共 20 ms(毫秒)且必须串行,库存、物流、支付查询总计 80 ms(毫秒)可并行。四路并发的理想时间为 20 + 80/4 = 40 ms(毫秒),理论加速 100/40 = 2.5 倍。若支付渠道只允许 50 次/秒(每秒),而入口 100 次/秒(每秒),即使本地 40 ms(毫秒)完成,支付阶段仍排队;必须限流或异步化非关键查询,不能宣称并行已经解决吞吐。

热门面试题

  1. 问题(基础题):异步和 MQ(消息队列)为什么能提高吞吐却不能减少总工作量?
    • 考点:并行、解耦、资源守恒、下游瓶颈。
    • 回答思路:分别说明重叠等待和移动工作位置,再指出每个业务副作用仍要执行。
    • 详细答案:异步让调用方不等待,MQ(消息队列)把生产和消费解耦,批处理降低每条固定成本,因而可提高资源利用率和前台响应;但数据库写入、网络传输、渠道调用和序列化仍然存在。若最慢下游没有更多容量,进入速度超过消费速度只会增加积压和恢复时间。
    • 进阶追问:怎样判断异步化是否真的有收益?
    • 进阶回答:比较前后端到端完成率、前台 P99(99 分位响应时间)、下游并发、队列任务年龄、错误率和恢复时间;若前台变快但积压持续增长,只是把用户等待变成后台债务。
  2. 问题(原理题):Amdahl’s Law(阿姆达尔定律)对线程池配置有什么启发?
    • 考点:串行比例、加速上限、并行开销。
    • 回答思路:用串行段限制解释为何线程数翻倍不等于吞吐翻倍。
    • 详细答案:先找不可并行的锁、单库写入、全局限流或合并步骤,它们给出速度上限;只有并行段有剩余资源时增加线程才有收益。线程数过大还会增加竞争和上下文切换,所以容量优化优先缩短串行段、降低下游等待或分片,而非无边界扩线程。
    • 进阶追问:批处理会违反实时性吗?
    • 进阶回答:可能。批处理以等待时间换吞吐,必须设置最大批大小和最大等待时间,区分实时交易与离线任务;交易不能为了凑批超出用户或一致性预算。
  3. 问题(项目题):IoT(物联网)报警风暴中怎样用 MQ(消息队列)但避免积压失控?
    • 考点:聚合、限流、消费者容量、过期语义。
    • 回答思路:从源头降噪,再按优先级消费和观测任务年龄。
    • 详细答案:设备侧或入口先按设备、规则和时间窗口去重聚合,严重报警走独立主题和消费者,普通报警按采样或摘要处理;消费者并发由通知渠道配额确定,超过任务年龄的普通提醒可标记为过期而非继续推送。监控积压、最老消息年龄、每类报警丢弃原因和渠道错误率,按证据调节入口限流。
    • 进阶追问:消费者扩容为何可能无效?
    • 进阶回答:若瓶颈是通知供应商配额、数据库热点或单分区顺序,更多消费者只会争同一资源。先检查分区、外部限额和每条处理时间,再决定是否扩容。

6.2 JDK(Java 开发工具包)21 virtual thread(虚拟线程)与传统线程池

JDK(Java 开发工具包)21 的 virtual thread(虚拟线程)适合大量以阻塞 I/O(输入输出)为主、可用同步直观代码表达的独立任务;其等待时通常可从 carrier thread(载体线程)卸载,因此不需要为每个等待请求长期占一个 platform thread(平台线程)。但它不代表资源无限:数据库连接、HTTP(超文本传输协议)连接、远端配额、内存、CPU(中央处理器)和文件句柄仍有限,仍需要信号量、连接池、限流和超时。

在 JDK(Java 开发工具包)21,virtual thread(虚拟线程)在 synchronized(同步锁)监视器内发生阻塞 I/O(输入输出),或执行 native(本地代码)调用时,可能产生 pinning(固定载体线程),使 carrier thread(载体线程)不能卸载。应缩短监视器持有时间,避免锁内慢调用,用 JFR(Java 飞行记录器)观察固定事件;这是 JDK(Java 开发工具包)21 的版本化说明,不应泛化为“虚拟线程不能用同步锁”。CPU(中央处理器)密集、需要有界队列和明确背压的批处理,传统 ThreadPoolExecutor(线程池执行器)仍更直接。

场景优先模型原因仍需限制
大量独立阻塞 I/O(输入输出)请求virtual thread(虚拟线程)。降低平台线程等待占用。连接、远端配额、超时。
CPU(中央处理器)密集图像或报表计算ThreadPoolExecutor(线程池执行器)。需贴近核数并明确排队。CPU(中央处理器)并发、内存。
有界异步导出ThreadPoolExecutor(线程池执行器)。需限制文件、数据库与对象存储压力。任务数、页大小、存储带宽。
顺序化 Runner(执行器)任务传统池或 virtual thread(虚拟线程)配信号量。调度取决于租约和下游,不取决于线程轻重。领取速率、租约、幂等。

热门面试题

  1. 问题(基础题):virtual thread(虚拟线程)能替代所有线程池吗?
    • 考点:调度资源、外部资源、背压。
    • 回答思路:先给否定结论,再区分线程等待与连接、CPU(中央处理器)等真实瓶颈。
    • 详细答案:不能。virtual thread(虚拟线程)主要降低阻塞等待占用 platform thread(平台线程)的成本,不能增加数据库连接、渠道配额、CPU(中央处理器)核数或内存。需要限制并发、排队和拒绝的批处理、CPU(中央处理器)任务仍适合传统线程池;虚拟线程请求也应有超时、信号量和限流。
    • 进阶追问:何时应排查 pinning(固定载体线程)?
    • 进阶回答:JDK(Java 开发工具包)21 下,虚拟线程吞吐异常下降且线程转储或 JFR(Java 飞行记录器)显示在监视器内阻塞 I/O(输入输出)或 native(本地代码)调用时,应排查。修复重点是缩短锁内区、移走慢调用或改用适当并发结构。
  2. 问题(原理题):为什么虚拟线程也要做限流?
    • 考点:下游槽位、排队位置、过载传播。
    • 回答思路:指出线程便宜不等于请求和连接免费。
    • 详细答案:大量虚拟线程可同时发起请求,若每个都去竞争 20 个数据库连接或供应商每秒 50 次(每秒)的额度,等待只会转移到连接池和下游。没有限流时,超时、重试和对象积压仍会形成正反馈;限流让系统在源头控制并发并保留恢复空间。
    • 进阶追问:虚拟线程能改善 CPU(中央处理器)密集任务吗?
    • 进阶回答:通常不能显著改善。计算持续占用 CPU(中央处理器)时不会卸载,过多线程反而增加调度竞争;应按核数限制并行度,先优化算法、批量和数据局部性。
  3. 问题(项目题):跨境渠道查询迁移 virtual thread(虚拟线程)前要验证什么?
    • 考点:兼容性、连接池、超时、观测。
    • 回答思路:不要只做代码替换,列出下游容量与故障演练。
    • 详细答案:先验证 JDK(Java 开发工具包)21、HTTP(超文本传输协议)客户端和监控组件兼容,测量各渠道连接池、每秒配额、P99(99 分位响应时间)和取消行为;仍用每渠道信号量限制并发,保留超时、熔断、抖动重试和任务幂等。压测要覆盖慢渠道与连接耗尽,而不是只跑正常吞吐。
    • 进阶追问:迁移后线程数指标还重要吗?
    • 进阶回答:重要但含义改变。除 platform thread(平台线程)外,还要看活动 virtual thread(虚拟线程)、固定事件、连接等待、外部配额拒绝和任务年龄;最关键仍是业务完成率与尾延迟。

7. 超时、重试、隔离与熔断:阻止正反馈事故

7.1 超时预算、退避、抖动、舱壁与熔断

稳定性措施必须按调用链协作:入口限流先限制到达率;舱壁隔离把渠道、导出和交易拆到不同线程池、连接池与配额;超时为每一跳分配预算;熔断在错误率或慢调用持续时快速失败;重试只用于暂态错误且必须幂等、限次、指数退避和抖动;Backpressure(背压)在队列或信号量满时把压力显式反馈。它们不是叠加越多越好:总超时必须小于上游预算,重试次数必须算入容量,熔断打开时不能继续让恢复任务洪峰回灌。

flowchart TD
    A["下游慢或错误"] --> B["执行时间变长"] --> C["工作线程与连接占满"] --> D["队列积压、任务年龄升高"]
    D --> E["超时或拒绝"] --> F["立即重试"] --> G["到达率放大"] --> C
    D --> H["限流 + Backpressure(背压)"]
    E --> I["退避 + 抖动 + 最大次数"]
    A --> J["熔断与舱壁隔离"]
    H --> K["恢复器按限速追赶"]
    I --> K
    J --> K

**图解。**失败路径左侧是队列积压与无脑重试形成的正反馈;正常恢复路径在入口减载、隔离故障、错峰重试后再按受控速率追赶;结论是只增加线程或只增加重试,会让慢下游更慢。

措施解决的直接问题必须避免的误用
超时防止一次调用无限占资源。所有层用同一超时,导致上游先放弃。
指数退避与抖动错开恢复流量。对非幂等写操作盲目重试。
舱壁隔离防止一个渠道耗尽全局资源。只拆线程池,不拆连接和限额。
限流控制到达率。拒绝后让客户端立即无限重试。
熔断下游明显不可用时快速失败。打开后丢失失败状态与恢复验证。

热门面试题

  1. 问题(基础题):为什么“超时加重试”会让队列积压更严重?
    • 考点:未释放任务、重叠尝试、到达率放大。
    • 回答思路:说明超时只结束等待,原任务可能仍占资源,再叠加重试。
    • 详细答案:若客户端超时早于底层连接释放,第一次调用仍占线程和连接,重试又产生第二个并发尝试;慢下游下这两个请求都可能失败,队列和拒绝数继续上升。重试必须有总预算、幂等性、退避和抖动,并观察原调用是否真正被取消。
    • 进阶追问:哪些错误不应自动重试?
    • 进阶回答:参数校验、权限、余额不足、明确业务拒绝和非幂等副作用未知的错误不应自动重试;它们需要修复输入、人工处理或查询真实状态。
  2. 问题(原理题):舱壁隔离为什么要同时隔离线程池和连接池?
    • 考点:共享资源、故障传播、容量边界。
    • 回答思路:指出线程空闲不代表连接空闲,反之亦然。
    • 详细答案:慢物流渠道即使只占用其线程池,若与支付共享 HTTP(超文本传输协议)连接池或数据库连接,也能耗尽共同槽位并阻塞支付。隔离要覆盖执行线程、连接、令牌、队列和告警指标,才能使一个依赖故障停在自己的舱室内。
    • 进阶追问:熔断打开后为什么仍要保留少量探测?
    • 进阶回答:没有探测就不知道下游何时恢复;以受限半开探测验证成功率和延迟,成功后逐步放量,失败则再次打开,避免瞬间全量回流。
  3. 问题(项目题):IoT(物联网)报警风暴如何设计重试?
    • 考点:去重、分级、任务年龄、渠道保护。
    • 回答思路:先减少无价值报警,再把可重试通知写成持久化、错峰任务。
    • 详细答案:入口按设备和规则窗口去重,严重报警保留、普通报警聚合摘要;失败通知以事件标识为幂等键落库,按错误分类和指数退避领取,并加入随机抖动。任务超过告警时效就结束为“未发送但已记录”,而不是灾后仍推送过期噪声;渠道错误率触发熔断和降级。
    • 进阶追问:怎样验证没有重试风暴?
    • 进阶回答:比较原始事件率、尝试率、每任务尝试分布、队列年龄和渠道并发;异常时尝试率不应超过设定倍数,恢复后积压应单调下降而不是周期性反弹。

8. 项目案例与线上排障:从线程栈到业务恢复

8.1 异步导出 OOM(内存溢出)、Runner(执行器)租约与履约聚合

异步导出 OOM(内存溢出)的典型根因不是“异步不够快”,而是多个导出任务把全量查询结果、工作簿行对象和字节数组同时保留在 Heap(堆)。改为分页游标或主键范围读取、每页流式写入对象存储、页后释放引用并记录检查点;导出池并发按数据库与存储带宽限额设为 2~4,不能与交易池共用。若每行常驻对象约 1 KiB(千字节),100 万行全量保留约 0.95 GiB(吉字节);每页 2000 行仅约 2 MiB(兆字节)工作集,四个并发任务约 8 MiB(兆字节),加上序列化缓冲仍远低于全量方案。

Runner(执行器)不能把“已提交到线程池”当成已完成。任务表记录幂等键、阶段、检查点、租约所有者、到期时间和尝试次数;工作线程执行前领取租约,执行中续租,成功后条件更新完成。服务重启或线程崩溃时,过期租约由其他实例接管;外部副作用先查幂等结果,无法确认则对账而非盲重做。订单履约聚合将库存、支付、物流放入隔离池,关键字段失败即安全失败,非关键字段部分返回并入补偿队列。

案例容量约束失败恢复幂等与观测
异步导出页大小、导出并发、对象存储带宽。从页检查点继续。文件任务号、页耗时、Heap(堆)曲线。
Runner(执行器)租约领取速率与下游配额。租约过期接管。业务幂等键、续租延迟、重试次数。
订单履约每渠道池、连接池、总超时。关键失败补偿,非关键部分成功。TraceId(链路标识)、渠道结果、P99(99 分位响应时间)。
IoT(物联网)报警聚合窗口、通知配额、消费者速率。延迟重试、过期丢弃有记录。事件标识、最老任务年龄、丢弃原因。

热门面试题

8.2 线程池耗尽、任务丢失、泄漏、死锁与公共池污染

线程池耗尽的证据链是:活跃线程接近上限、队列持续增长、完成速率低于提交速率、任务年龄与 P99(99 分位响应时间)增长;再用多份 jstack(线程栈工具)确认线程卡在同一网络、数据库、锁或文件位置。任务丢失要查拒绝处理器、关闭返回的未开始任务、进程崩溃前内存队列和任务状态机;线程泄漏看线程名数量是否持续增长、是否存在每任务新建执行器未关闭;死锁看 jstack(线程栈工具)的等待环;公共池污染看 ForkJoinPool(分治线程池)工作线程长期阻塞及无关业务尾延迟同步恶化。

flowchart TD
    A["告警:P99(99 分位响应时间)和任务年龄升高"] --> B["采集线程池:activeCount(活跃线程数)、队列、拒绝、完成率"]
    B --> C{"完成率 < 提交率且队列增长?"}
    C -->|是| D["抓取多份 jstack(线程栈工具)"]
    C -->|否| E["检查任务丢失、年龄、业务状态机"]
    D --> F{"卡在同一依赖?"}
    F -->|是| G["限流、熔断、隔离与下游排查"]
    F -->|锁等待环| H["死锁证据、按幂等恢复后止血"]
    F -->|公共池线程| I["迁出 ForkJoinPool(分治线程池)阻塞任务"]
    E --> J["核对拒绝、shutdownNow(立即关闭)返回任务、崩溃恢复"]

**图解。**正常排障从指标确认“是否真的积压”再到线程栈定位等待点;失败路径是看到队列大就盲扩容、看到超时就无脑重试;结论是任务年龄与完成率比单个线程数更接近用户真实损失。

热门面试题

  1. 问题(基础题):如何区分线程池耗尽和任务丢失?
    • 考点:队列、完成率、拒绝、业务状态。
    • 回答思路:耗尽看仍在系统内的年龄和积压,丢失看状态机没有后续归宿。
    • 详细答案:耗尽时任务通常仍在队列、运行中或持久化待领取,队列和任务年龄增长、完成率下降;丢失则任务在拒绝、关闭、进程崩溃或异常吞掉后既未完成也未进入失败/补偿状态。必须以任务标识贯穿提交、开始、成功、失败和重试,不能只靠线程池计数判断。
    • 进阶追问:怎样监控任务年龄?
    • 进阶回答:保存首次受理时间和当前阶段开始时间,监控等待年龄与端到端年龄的分位数;超过业务时效触发降级、过期或人工,而不是让旧任务无限占队列。
  2. 问题(原理题):为什么线程泄漏会伪装成线程池问题?
    • 考点:线程工厂、执行器关闭、资源耗尽。
    • 回答思路:说明每次创建执行器会留下线程,最终与正常池竞争资源。
    • 详细答案:若请求路径不断创建 ThreadPoolExecutor(线程池执行器)或调度器却不关闭,线程数和栈内存持续增长,调度开销上升,最终表现为 CPU(中央处理器)高、创建线程失败或正常池获得更少时间片。用统一线程名前缀、线程数趋势、创建调用栈和执行器生命周期审计定位,服务关闭时有序终止。
    • 进阶追问:死锁与耗尽如何在 jstack(线程栈工具)中区分?
    • 进阶回答:死锁会出现明确的锁等待环,参与线程彼此等待;耗尽通常是大量同名工作线程等待同一外部调用、连接或锁,不一定形成环。需多份线程栈和指标确认稳定性。
  3. 问题(项目题):异步导出 OOM(内存溢出)后怎样止血和根治?
    • 考点:并发限制、流式写入、检查点、恢复。
    • 回答思路:先限制新导出与保留核心业务,再从堆证据修复全量对象保留。
    • 详细答案:止血时暂停或限速新导出、降低导出池并发、保留交易池,抓取 Heap(堆)证据确认大对象来自结果列表或工作簿;根治改为分页范围查询与流式写入,页后清理引用并记录检查点,失败任务按任务号从检查点续跑。导出文件和外部上传以幂等键命名,避免恢复产生重复文件。
    • 进阶追问:为什么不直接增大 Xmx(最大堆内存)?
    • 进阶回答:增大堆只推迟爆炸并可能拉长 GC(垃圾回收)停顿;若全量保留和并发无界不变,流量上升仍会耗尽内存。必须先收紧工作集和并发。

热门面试题

8.3 综合题入口

9. 综合口述题与追问树

  1. 问题(架构题):请完整说明你如何为订单履约服务设计线程池、容量和拒绝恢复。

    • 口述答案:我会先按失败成本拆池,而不是为整个服务建一个大池:订单和库存的本地计算、支付、物流、异步补偿各自有独立的 ThreadPoolExecutor(线程池执行器)、连接池和超时。参数从实测到达率和服务时间反推;例如每秒 120 个聚合请求、平均执行 200 ms(毫秒)需要约 24 个执行并发,若端到端预算 500 ms(毫秒),总在制约 60 个,因此在线队列只允许几十个而不是数千个。核心线程承担稳定负载,队列满后才有限扩到最大线程,拒绝时支付和库存任务写入持久化补偿状态并返回可追踪结果,物流等可降级字段则部分成功。每项调用有客户端超时、限流、熔断和有限退避;任务以订单号和业务阶段做幂等键。上线后我同时看提交率、完成率、队列等待、最老任务年龄、拒绝数和各渠道 P99(99 分位响应时间)。当完成率低于提交率时,先削减入口和暂停非核心恢复,再用 jstack(线程栈工具)确认是渠道、连接还是锁,不能仅把线程数调大。 我还会把线程池参数、连接上限、渠道配额和告警阈值写为同一份容量契约;发布前注入慢调用和拒绝故障,确认关键订单进入补偿、非关键查询降级,并以完成率回升和任务年龄下降作为验收证据。 在事故窗口内还会冻结参数热更新,保全任务状态和线程栈证据,避免临时扩容掩盖真实下游瓶颈;恢复后复盘阈值、容量余量和自动降级动作。
    • 详细章节:容量与拒绝
    • 追问树:为什么不共用线程池?答:故障与配额必须隔离;拒绝后能否直接重试?答:先持久化并按限速恢复;最大线程怎么算?答:由队列、下游并发与压测共同约束。
  2. 问题(原理题):请从源码路径解释 ThreadPoolExecutor(线程池执行器)一次 execute(执行)提交到任务完成的过程。

    • 口述答案:提交时,ThreadPoolExecutor(线程池执行器)先读取 ctl(控制状态),其中高位是 runState(运行状态)、低位是 workerCount(工作线程数)。execute(执行)第一步在当前工作线程少于 corePoolSize(核心线程数)时尝试 addWorker(增加工作线程)直接运行首任务;第二步核心已满则尝试进入 workQueue(任务队列),成功后要再次确认池仍在 RUNNING(运行中),已关闭就移除并拒绝,若池里没有工作线程则补一个;第三步队列拒绝入队才尝试以非核心资格扩到 maximumPoolSize(最大线程数),失败才交给拒绝处理器。Worker(工作线程)启动后由 runWorker(运行工作线程)执行首任务,再循环 getTask(获取任务);可超时线程用 keepAliveTime(空闲存活时间)轮询,其他线程阻塞获取。任务完成会经过执行后钩子和完成计数,异常退出会进入工作线程退出逻辑并按需补充线程。关闭时 shutdown(平滑关闭)只消费已入队任务,shutdownNow(立即关闭)中断工作线程并返回未开始任务,业务仍要自己保证中断、检查点和幂等恢复。 我还会把提交、开始、结束和拒绝按同一任务标识关联,才能区分排队、运行、退回与补偿状态,避免只根据线程状态误判业务结果。
    • 详细章节:核心执行路径
    • 追问树:为什么二次检查?答:处理入队与关闭竞态;线程异常会怎样?答:退出并可能补充;关闭能强杀任务吗?答:不能,依赖协作中断。

9.1 延展口述训练

  1. 问题(容量题):请用 Little’s Law(利特尔定律)说明线程池、队列和下游连接池怎样一起定容量。

    • 口述答案:我不会从“机器有多少核”直接写线程数,而是先在稳定负载下测每秒提交率、开始率、完成率、队列等待和服务时间。Little’s Law(利特尔定律)是 L = λW;例如订单聚合每秒 120 个、执行平均 200 ms(毫秒),执行中的平均并发约为 24。若用户允许端到端 500 ms(毫秒),总在制任务约为 60,所以队列等待最多只能承载约 36 个,而不是几千个。接着看任务是 CPU(中央处理器)还是 I/O(输入输出)主导:计算任务从核数附近起步,远程调用可按等待与计算比例估初值,但线程数不能超过数据库连接、HTTP(超文本传输协议)连接和渠道配额可承受的并发。最后做压力和慢依赖演练,验证 P95(95 分位响应时间)、P99(99 分位响应时间)、拒绝数和最老任务年龄;完成率低于到达率时,优先限流和恢复下游,不用无限队列掩盖问题。
    • 详细章节:容量实测
    • 追问树:平均值够吗?答:不够,要看尾延迟;队列能设很大吗?答:由等待和内存预算反推;连接池小于线程数怎么办?答:连接池才是实际并发上限。
  2. 问题(选型题):有界 ArrayBlockingQueue(数组阻塞队列)、无界 LinkedBlockingQueue(链表阻塞队列)和 SynchronousQueue(同步移交队列)如何选?

    • 口述答案:我先问业务希望在过载时发生什么:在线关键路径要尽早暴露压力,就用有界 ArrayBlockingQueue(数组阻塞队列)或明确容量的 LinkedBlockingQueue(链表阻塞队列),让短尖峰排队、长期过载触发扩线程和拒绝;容量由允许等待乘到达率及单任务内存反推。无界 LinkedBlockingQueue(链表阻塞队列)几乎总能入队,导致 maximumPoolSize(最大线程数)通常不起作用,风险从拒绝转成 Heap(堆)膨胀和任务年龄无限增长。SynchronousQueue(同步移交队列)没有缓冲,每个任务必须立即交给工作线程,适合短小且上限明确的弹性并发任务,但突发时会快速扩线程或拒绝。优先级和延迟队列也不是容量阀门,仍要有总量上限、限流和任务过期策略。选择后我会监控队列等待、拒绝、内存和下游并发,按事实调整。
    • 详细章节:队列选型
    • 追问树:无界队列能否使用?答:只有外部已控总量时谨慎使用;优先级能解决报警风暴吗?答:不能替代聚合和限流;队列满后怎么办?答:执行明确拒绝和恢复语义。
  3. 问题(稳定性题):你如何解释 CallerRunsPolicy(调用方运行策略)的背压价值与死锁边界?

    • 口述答案CallerRunsPolicy(调用方运行策略)不是万能兜底,它的价值是队列和线程都满时不再把任务交给池,而让提交线程自己运行,于是提交者的耗时上升、继续提交的速率自然下降,这就是局部 Backpressure(背压)。它仅适用于任务短小、可中断、无递归提交,且调用线程不持有关键锁、不属于网络事件循环或核心交易链路的情况。若提交线程在数据库事务或锁内,任务又要等待其他线程释放同类资源,等待会传播甚至形成自我饥饿;若事件循环去执行慢 HTTP(超文本传输协议)调用,所有连接都会被拖慢。支付、库存和调度这类任务被拒绝时,我会把任务标识、幂等键、原因和下一次执行时间持久化,由受限恢复器领取,并增加拒绝指标和告警。原则是宁可显式失败或延迟,也不能静默丢失或把用户请求无限挂住。
    • 详细章节:拒绝与背压
    • 追问树:何时适合它?答:安全的短任务提交边缘;DiscardPolicy(丢弃策略)能用于支付吗?答:不能;恢复器为什么限速?答:避免再次压垮下游。
  4. 问题(异步题):请讲清 CompletableFuture(异步编排)默认公共池风险,以及 thenApply(同步转换)、thenCompose(异步扁平化)和 thenCombine(并行合并)的边界。

    • 口述答案:我会先把异步编排和线程池隔离一起讲。没有显式执行器的 CompletableFuture(异步编排)异步阶段通常共享公共 ForkJoinPool(分治线程池);如果导出、慢渠道或文件 I/O(输入输出)塞进去,公共工作线程被阻塞,无关的订单聚合也会尾延迟升高。生产中我按订单、库存、支付、物流分别传入命名的有界执行器和超时。语义上,thenApply(同步转换)是前序结果到普通值的轻量映射,不能在里面做阻塞调用;thenCompose(异步扁平化)用于依赖前一结果再发起异步操作并展平;thenCombine(并行合并)用于两个已经独立启动的结果汇总。渠道异常要在分支用 handle(处理成功或异常)保留错误分类和耗时,核心库存失败安全失败,物流失败则部分返回并补偿;不能在最终统一吞掉异常后返回模糊空对象。
    • 详细章节:异步编排
    • 追问树:带 Async(异步)后缀就隔离了吗?答:仍应显式传执行器;超时会取消 HTTP(超文本传输协议)吗?答:不一定;部分成功怎样观测?答:记录每渠道结果和耗时。
  5. 问题(故障题):为什么超时、立即重试和无界队列会构成重试风暴?你的治理顺序是什么?

    • 口述答案:下游变慢时,任务执行时间拉长,工作线程和连接先被占满;新任务进入无界队列后看似没有失败,实际上等待时间持续增加。调用方先超时并立即重试,但原请求可能仍占用线程和连接,新的尝试把到达率继续放大,完成率更低,于是形成正反馈。以原始 100 次/秒(每秒)为例,每个请求重试两次已达 300 次/秒(每秒),再叠加拒绝后的重投很快超过下游能力。治理顺序是先保留核心交易、入口限流和暂停非核心恢复,再以舱壁隔离渠道资源并按错误率熔断;可重试任务带幂等键、最大次数、指数退避和随机抖动,状态持久化后由限速恢复器领取。恢复阶段监控尝试率与原始率之比、队列年龄、下游并发和成功率,确认积压单调下降后才逐步放量。
    • 详细章节:正反馈治理
    • 追问树:哪些错误不重试?答:参数、权限和明确业务失败;为何要抖动?答:避免同刻回灌;何时人工介入?答:超过最大次数或状态不确定时。
  6. 问题(项目题):异步导出发生 OOM(内存溢出)时,你如何用线程池和流式处理完成止血、恢复与根治?

    • 口述答案:我会先隔离影响范围:暂停新导出或按租户限速,降低专用导出池并发,绝不挤占订单和支付池;同时采集 Heap(堆)和任务数据,确认是否是全量结果列表、工作簿行对象、字节数组或重试副本长期存活。根治不是把 Xmx(最大堆内存)调大,而是按主键范围或游标分页读取,每页立即流式写入文件或对象存储,写完释放引用并提交检查点。若每行对象约 1 KiB(千字节),一百万行全量常驻接近 0.95 GiB(吉字节),每页 2000 行仅约 2 MiB(兆字节)工作集;导出池只保留数据库、存储带宽可承受的 2~4 个并发。任务表保存文件任务号、页范围、版本和幂等命名,进程重启后从最后完成页续跑;监控页耗时、内存曲线、失败分类和任务年龄,避免恢复时再次形成洪峰。
    • 详细章节:导出与恢复
    • 追问树:为什么不增加导出线程?答:存储和数据库可能已是瓶颈;检查点如何幂等?答:页范围和文件版本唯一;何时丢弃?答:超过业务时效且用户可重新提交时。
  7. 问题(调度题):Runner(执行器)调度怎样防止“线程池提交成功但任务实际丢失”?

    • 口述答案:我会把线程池提交当作一次本地尝试,而不当作业务完成。Runner(执行器)任务先持久化业务幂等键、阶段、尝试次数、下一次执行时间和可领取状态;领取时通过条件更新写入执行者和租约到期时间,只有抢到租约才提交到工作池。工作线程每完成一个可恢复阶段就写检查点并续租,外部调用先以幂等键查询或写入,避免线程异常后重复副作用。线程池拒绝、shutdownNow(立即关闭)返回未开始任务、进程崩溃和工作线程异常都只会让租约最终过期,其他实例按限速重新领取;若状态不确定,进入对账而不是直接重做。监控要覆盖待领取量、运行租约年龄、续租失败、最大尝试数和完成率,并把线程名与任务号、TraceId(链路标识)关联。这样即使池耗尽或服务重启,任务也有可追踪的业务归宿。
    • 详细章节:Runner(执行器)恢复
    • 追问树:租约为何可能误接管?答:心跳或时钟异常;如何避免双执行?答:条件更新和幂等副作用;恢复为何限速?答:保护在线流量与下游。
  8. 问题(排障题):线程池耗尽时,你怎样用指标和 jstack(线程栈工具)给出根因而不是猜测?

  • 口述答案:我先确认事实而不是只看“活跃线程为满”。采集每秒提交、开始和完成数,activeCount(活跃线程数)、队列长度、拒绝数、任务等待年龄及 P99(99 分位响应时间);若完成率长期低于提交率且最老任务年龄增长,才认定存在持续积压。随后连续采集多份 jstack(线程栈工具),按自定义线程名前缀聚合:大量线程稳定卡在同一 HTTP(超文本传输协议)客户端说明渠道慢或连接耗尽,卡在数据库获取连接说明连接池或 SQL(结构化查询语言)问题,出现锁等待环才是死锁,公共 ForkJoinPool(分治线程池)工作线程阻塞则是公共池污染。止血动作随证据而变:渠道慢就限流、熔断和隔离,死锁在确保幂等恢复后重启并修锁顺序,任务丢失则核查拒绝、关闭返回任务和状态机。最后用完成率恢复、任务年龄下降和尾延迟改善验证,而不是凭一次接口成功宣告解决。
  • 详细章节:排障证据
  • 追问树:一份线程栈够吗?答:不够,要看稳定重复;队列不大仍会慢吗?答:会,任务可能都卡在运行中;何时扩容?答:确认下游有容量且只是本地并发不足时。
  1. 问题(源码题):请从 ctl(控制状态)状态位解释线程池为什么能在提交与关闭并发时保持正确性。
  • 口述答案:我会把 ctl(控制状态)定位为 ThreadPoolExecutor(线程池执行器)协调并发的核心快照:一个整型的高 3 位保存 runState(运行状态),低 29 位保存 workerCount(工作线程数)。状态从 RUNNING(运行中)到 SHUTDOWN(平滑关闭)、STOP(停止)、TIDYING(整理中)再到 TERMINATED(已终止)单向推进;提交、建 Worker(工作线程)和关闭同时发生时,代码通过 CAS(比较并交换)在同一个 ctl(控制状态)版本上验证并递增或递减工作线程数,失败后重新读取,而不是分别读两个易失字段后盲目更新。最容易遗漏的是任务入队后的二次检查:任务在 workQueue(任务队列)成功入队后,可能恰好有另一线程调用 shutdown(平滑关闭),此时必须确认仍是 RUNNING(运行中);若已关闭就移除该任务并执行明确拒绝,否则它会留在没有消费者的内存队列。工程上这解释了为什么关闭不是简单的布尔开关,也说明拒绝、未开始任务和持久化恢复必须在业务层有归宿。
  • 详细章节:ctl(控制状态)与执行决策
  • 追问树:ctl(控制状态)是否保存队列长度?答:不保存,队列是独立结构;为什么不能先读状态再读线程数?答:两次读取间可能关闭或扩缩容;SHUTDOWN(平滑关闭)与 STOP(停止)区别?答:前者消费已入队任务,后者中断工作线程并退回未开始任务;能否用状态位保证业务任务不丢?答:不能,进程崩溃仍会丢内存队列,关键任务必须持久化。
  1. 问题(生命周期题)Worker(工作线程)执行任务抛出异常后会发生什么?怎样避免业务任务既失败又无人恢复?
  • 口述答案Worker(工作线程)由首任务和循环领取任务两部分组成。runWorker(运行工作线程)在每次执行前标记工作线程忙碌,任务结束后走执行后钩子;如果用户任务抛出未捕获异常,当前工作线程会退出,线程池随后进入 processWorkerExit(处理工作线程退出)路径,扣减工作线程计数,并在仍有排队任务或需要维持最小工作线程数时尝试补充新的 Worker(工作线程)。这只恢复执行能力,不等于恢复那条失败业务:异常发生在数据库提交前、提交后或外部渠道调用后,含义完全不同。我的做法是任务开始时写入任务标识、尝试号和租约,阶段完成时持久化检查点;异常统一分类为可重试、不可重试和状态未知。可重试任务带幂等键和退避时间进入恢复表,状态未知的支付或履约任务先查单或对账,不能因为新工作线程已补上就直接重做。还要给线程工厂设置异常处理和线程名,监控异常退出次数、补线程次数、任务失败率与最老任务年龄,防止连续异常被“自动补线程”掩盖。
  • 详细章节:Worker(工作线程)生命周期
  • 追问树:任务异常会杀掉整个线程池吗?答:不会,通常只退出当前 Worker(工作线程);afterExecute(执行后回调)能替代业务失败状态吗?答:不能,它只适合观测和统一记录;如何避免异常重试重复扣款?答:以业务幂等键查询或条件更新;线程工厂创建失败怎么办?答:回滚工作线程计数并走拒绝或持久化回退。
  1. 问题(队列题):为什么无界 LinkedBlockingQueue(链表阻塞队列)会让 maximumPoolSize(最大线程数)失效?如何把它改成可控容量?
  • 口述答案execute(执行)的顺序决定了这个结果:核心工作线程已满后,线程池先调用 workQueue.offer(任务队列尝试入队);只有入队失败,才会尝试创建非核心 Worker(工作线程)直到 maximumPoolSize(最大线程数)。无界 LinkedBlockingQueue(链表阻塞队列)几乎永远能接收元素,所以第三步没有机会发生,实际并发常停留在 corePoolSize(核心线程数),压力被转化为节点对象、业务上下文和等待时间的无限堆积。服务刚变慢时成功率可能仍高,直到 Heap(堆)逼近上限、Full GC(完全垃圾回收)频繁或任务已经错过业务时效,事故才显性化。改造时我先根据到达率乘允许排队时间反推容量,例如每秒 80 个、最多等待 250 ms(毫秒),基础队列约 20 个,再留可解释的尖峰余量,而非随意设成数万;选择有界 ArrayBlockingQueue(数组阻塞队列)或显式容量的 LinkedBlockingQueue(链表阻塞队列),配合有限最大线程、拒绝策略、任务年龄和内存预算。对不能丢的任务,队列满后转持久化恢复,不靠扩容或无界内存假装已受理。
  • 详细章节:BlockingQueue(阻塞队列)选型
  • 追问树:无界队列能否完全禁止?答:不是绝对禁止,但要有外部总量控制和任务年龄保护;队列设大为何不等于更稳定?答:只延后拒绝并放大恢复时间;SynchronousQueue(同步移交队列)为何不同?答:它没有缓存,每次直接移交或扩线程/拒绝;最大线程设很高能替代队列吗?答:不能,线程、连接和下游配额都会先耗尽。
  1. 问题(拒绝题)CallerRunsPolicy(调用方运行策略)在哪些边界会把背压变成死锁或级联延迟?
  • 口述答案CallerRunsPolicy(调用方运行策略)的正面作用是把队列和线程池满载的压力传回提交者:提交线程自己执行任务,提交动作不再快速返回,新的到达率自然下降。但它改变了执行位置,因此必须审查调用上下文。第一类危险是提交者持有数据库事务、分布式锁或本地锁,任务又需要等待其他线程获取相同资源,等待会从工作池扩散到调用链,甚至形成锁顺序环;第二类危险是 HTTP(超文本传输协议)事件循环、消息消费线程或核心交易线程执行慢 I/O(输入输出),一个被拒绝的后台任务会占住本该快速处理更多请求的线程,尾延迟迅速级联;第三类是任务再次向同一已满线程池提交并同步等待,造成自我饥饿。我的准入条件是任务短、无锁、可中断、无递归提交,且提交线程允许同步变慢;否则用 AbortPolicy(中止策略)或自定义拒绝,将任务状态、幂等键和失败原因持久化,按独立恢复池限速处理。监控调用方执行次数和额外耗时,才能证明背压是在受控减速而非拖垮入口。
  • 详细章节:拒绝策略与 Backpressure(背压)
  • 追问树:能在支付回调线程使用吗?答:通常不能,可能占住核心回调处理;调用者持锁一定死锁吗?答:不一定,但风险取决于任务资源和锁顺序;何时选择 AbortPolicy(中止策略)?答:需要显式失败或转持久化恢复时;自定义拒绝器能无限阻塞写库吗?答:不能,它也要有超时和故障归宿。
  1. 问题(容量题):请给出一个 Little’s Law(利特尔定律)算例,并说明为什么不能只按平均耗时配线程数。
  • 口述答案:以跨境履约聚合为例,稳定到达率 λ 为每秒 150 个任务,正常服务时间为 160 ms(毫秒),Little’s Law(利特尔定律)给出执行中的平均并发 L = λW = 150 × 0.16 = 24。若端到端 SLO(服务等级目标)是 400 ms(毫秒),系统平均在制任务上限约为 150 × 0.4 = 60,因此可容忍的平均排队量约为 36;这不是说队列只能永远等于 36,而是超过该量后,即使不再来新请求,平均目标也会破。随后我以 P95(95 分位响应时间)和 P99(99 分位响应时间)重新校验:若支付渠道的 P99(99 分位响应时间)从 160 ms(毫秒)变成 1.2 秒(秒),24 个线程的完成率会骤降,增加到 100 个线程只会创建更多等待渠道、占用连接并引发超时。容量结论还必须与连接池、渠道配额、CPU(中央处理器)核数和单任务内存相交;压测中观察完成率是否至少等于到达率、最老任务年龄是否受控、拒绝是否有业务归宿。公式提供可复算起点,尾延迟和下游瓶颈决定生产上限。
  • 详细章节:Little’s Law(利特尔定律)容量实测
  • 追问树:L 中是否包含队列等待?答:端到端 W 包含等待和执行;流量持续增长还能用稳态公式吗?答:只能量化压力,不能外推容量;CPU(中央处理器)密集任务怎样起步?答:从核数附近压测;I/O(输入输出)密集为何仍有限制?答:连接、远端配额和内存并不随线程免费增长。
  1. 问题(异步题)CompletableFuture(异步编排)的异常传播、超时、取消和部分成功如何设计,才能既快又不误判结果?
  • 口述答案:我先把每个渠道的结果语义定义清楚:库存和支付属于关键结果,异常或超时必须安全失败或进入一致性补偿;物流轨迹和推荐信息可降级,但必须返回“渠道超时、失败分类、耗时和下次补拉时间”,不能把异常吞成普通空值。实现上,各渠道使用显式有界线程池,thenCombine(并行合并)只组合真正独立的任务;依赖前序结果的调用用 thenCompose(异步扁平化),轻量转换用 thenApply(同步转换)。分支使用 handle(处理成功或异常)将可降级失败包装成结果对象;未处理异常会以 CompletionException(完成异常)沿链传播,最终必须记录根因而不是只记包装异常。orTimeout(超时失败)只让未来结果结束等待,不保证底层 HTTP(超文本传输协议)或数据库请求停止;cancel(取消)同样不天然中断供应函数,所以客户端连接、读写和总超时,以及协作取消和幂等键不可省。部分成功后后台补拉也要限速,避免用户刷新与恢复任务同时制造重试风暴。
  • 详细章节:CompletableFuture(异步编排)异常与取消
  • 追问树:whenComplete(完成观察)能吞掉异常吗?答:它主要观察,异常仍会传播;超时后为什么不能立刻重试?答:原调用可能仍占资源且结果未知;取消后远端会停止吗?答:不保证;何时允许部分成功?答:仅当缺失字段不影响安全和业务判定时。
  1. 问题(版本题):JDK(Java 开发工具包)21 virtual thread(虚拟线程)如何与传统线程池、连接池和限流共同使用?
  • 口述答案:virtual thread(虚拟线程)解决的是大量阻塞等待长期占用 platform thread(平台线程)的成本,适合许多彼此独立、以 HTTP(超文本传输协议)或文件 I/O(输入输出)等待为主的任务;它不是“无限并发”开关。即使每个等待任务可以卸载,数据库连接、供应商每秒配额、文件描述符、对象内存和 CPU(中央处理器)仍然有限,必须用信号量、连接池、限流、超时和任务总量控制。CPU(中央处理器)密集的报表计算或需要明确队列、拒绝和背压的导出任务,传统 ThreadPoolExecutor(线程池执行器)仍更适合,因为它能直接把并行度限制在容量预算内。JDK(Java 开发工具包)21 还要注意 pinning(固定载体线程):虚拟线程在 synchronized(同步锁)监视器内进行阻塞 I/O(输入输出),或执行 native(本地代码)调用时,carrier thread(载体线程)可能不能卸载;应缩短锁内区、移走慢调用,并用 JFR(Java 飞行记录器)观察。迁移必须压测慢渠道、连接耗尽和取消,不是只看创建百万线程的演示。
  • 详细章节:virtual thread(虚拟线程)边界
  • 追问树:虚拟线程能替代连接池吗?答:不能,连接是外部有限资源;何时用传统池?答:CPU(中央处理器)任务或需要有界排队和拒绝时;pinning(固定载体线程)是否意味着不能用 synchronized(同步锁)?答:不是,关键是避免锁内阻塞并按 JDK(Java 开发工具包)21 事实观测;监控哪些指标?答:连接等待、外部拒绝、固定事件、任务年龄和尾延迟。
  1. 问题(恢复题):Runner(执行器)如何用租约、检查点和幂等性处理服务重启、线程池拒绝与重复执行?
  • 口述答案:Runner(执行器)系统必须把任务业务生命周期放在持久化状态机,而不是进程内队列。创建任务时记录任务号、业务幂等键、阶段、下一次执行时间和最大尝试次数;领取时以条件更新抢占租约,写入执行实例、租约版本和到期时间,只有抢到的人才提交到工作池。工作线程在每个可恢复步骤完成后保存检查点,并周期性续租;若线程池拒绝,拒绝处理器把任务保持为可重试状态而非静默丢弃。进程崩溃、shutdownNow(立即关闭)或工作线程异常后,租约不会永久占用,监控器发现到期再由其他实例按限速接管。重复执行不可避免,所以每次外部调用都携带业务幂等键,先查询已有结果或通过唯一约束、条件更新防重;支付、库存等状态未知场景进入查单和对账,不能直接补发。观测上我会看待领取量、租约年龄、续租失败、重试分布、成功率和最老任务年龄,并在下游故障时降低领取速率,确保恢复过程不会压垮在线交易。
  • 详细章节:Runner(执行器)租约恢复
  • 追问树:租约到期就能直接重跑吗?答:先通过版本条件更新取得所有权;长任务如何避免误接管?答:定期续租并设置合理宽限;幂等键失效怎么办?答:进入对账和人工队列;恢复池为何独立?答:避免积压追赶抢占在线业务资源。

10. 复习清单

  • 能按 execute(执行)的“核心线程、入队二检、非核心扩张、拒绝”画出路径,并说明 ctl(控制状态)中的状态和工作线程数。
  • 能区分 shutdown(平滑关闭)与 shutdownNow(立即关闭),并说明中断无法替代检查点、租约和幂等恢复。
  • 能由 Little’s Law(利特尔定律)反推并发和队列,且用 P99(99 分位响应时间)、任务年龄与下游容量校验。
  • 能解释有界队列、无界队列和直接移交如何改变 maximumPoolSize(最大线程数)的意义。
  • 能为关键任务选择显式拒绝、持久化补偿和可观测恢复,而不是静默丢弃。
  • 能用显式执行器编排 CompletableFuture(异步编排),并正确处理超时、取消、异常和部分成功。
  • 能区分 CPU(中央处理器)计算、阻塞 I/O(输入输出)、ForkJoinPool(分治线程池)与 JDK(Java 开发工具包)21 virtual thread(虚拟线程)的适用边界。
  • 能用线程池指标、任务年龄和多份 jstack(线程栈工具)排查耗尽、丢失、泄漏、死锁与公共池污染。