qinghuan
← JUC + JVM / 全部笔记

单元 91 / JUC + JVM

P0 · 必会主线L2 · 进阶

线程池:队列容量、扩容与拒绝策略如何决定结果

按线程、队列和等待时间一起规划容量,再决定忙不过来时怎样明确拒绝或放慢提交。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能按任务耗时和依赖容量设置有界队列,观察并解释拒绝发生的位置。

学习理由、准备要求与知识点

为什么学它

线程池的排队与拒绝规则决定请求高峰时的资源占用和失败方式。

先读这些内容

知识点清单

  • 核心线程
  • 工作队列
  • 最大线程
  • 队列容量
  • 拒绝策略
  • CallerRuns
  • 任务异常
  • 关闭与等待

运行前先确认JDK 17 ThreadPoolExecutor,源码固定17.0.12+7。容量算法模拟固定数量工作者、FIFO等待队列和已知任务耗时,忽略调度与创建线程成本;不模拟动态扩容线程池。

01先弄懂它是什么

线程池复用工作线程,并按配置限制池内线程数量,但不会凭空增加 CPU、数据库连接或下游能力。任务持续来得比处理快,就会积压或触发拒绝处理。队列可以接住短暂高峰,不能解决持续过载;任务排得太久,即使最后执行成功,也可能已经超过用户愿意等待的时间。

规划线程池要同时看三件事:多少线程在干活、最多排多少任务、每个任务允许等多久。线程数和队列容量限制池内工作与积压;超时、取消还要配合任务检查中断和底层调用超时,不能假定调用方不等了,任务就已停止。排队任务可能带着请求正文、用户信息和缓冲区,因此一万个任务占用的内存,不只是队列里一万个小节点。

拒绝策略也是业务接口的一部分:任务未被正常接收时,要明确是报告失败、稍后重试,还是交给提交线程执行。静默丢弃通常不适合关键订单。CallerRunsPolicy 在线程池尚未关闭时由提交线程运行任务,可能拖慢 HTTP 线程或阻塞事件循环,这些执行也不计入池内线程上限;线程池已经关闭时,它会丢弃任务。选择前要确认调用方能承受哪些结果。

文中这几个词,先认一认

核心线程数
线程池收到任务时,优先创建工作线程所使用的基本数量门槛。
最大线程数
任务无法入队后,继续增加工作线程时不能超过的上限。
背压
让提交方知道下游忙不过来,并减少、延后或拒绝继续提交,避免积压失控。
排队时延
任务被接收后,到真正开始处理之前花在队列里的时间。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

ThreadPoolExecutor 收到任务时,通常先看工作线程是否少于核心线程数,是就尝试加一个;否则先让任务排队。排不进队列时,才尝试继续加线程到最大线程数,仍接不下才拒绝。所以队列几乎永远装得下时,新任务会一直排队,很少走到增加非核心线程那一步。只把 maximumPoolSize 调大、却保留近似无界队列,线程池仍可能一直只有核心规模。

入队与线程池关闭可能并发发生,所以成功offer后还必须复查运行状态。如果此时已关闭并能移除该任务,就需要拒绝;若队列中已有任务却没有工作者,也需要尝试补充工作者。源码的这些复查处理的是并发时序,不能从顺序流程图中删掉后就声称得到等价线程池。

在运行相对稳定、统计范围一致时,平均有多少任务尚未完成,与每秒完成多少任务、每个任务平均停留多久有关:平均在途数量 = 吞吐量 × 平均停留时间。这就是 Little 定律。它讨论平均值,不能直接拿来预测最慢的那部分请求。

计算密集的任务要看可用 CPU 和线程切换成本;等待网络或磁盘的任务,还要看连接池和远端能同时处理多少请求。估算用于选一个起点,最终仍要用接近真实任务组合和资源限制的负载来测量。

正在加载流程图…

成功入队后仍有状态复查;队列类型会改变最大线程数的实际作用。
查看流程图源码(Mermaid)
flowchart TD
 S["execute提交任务"] --> C{"工作者少于核心阈值"}
 C -->|是| A["尝试创建核心工作者"]
 A -->|成功| D["工作者执行任务"]
 A -->|失败| Q
 C -->|否| Q{"运行中且入队成功"}
 Q -->|是| R["复查运行状态与工作者数量"]
 R --> D
 R --> X["关闭竞态下移除并拒绝"]
 Q -->|否| M{"最大阈值内创建工作者成功"}
 M -->|是| D
 M -->|否| X

按顺序看,每一步做了什么

  1. 按CPU、IO与依赖隔离划分任务,不把全部工作塞进共享池。
  2. 根据服务时间、并发限制和内存预算设置工作者与有界队列。
  3. 明确拒绝结果、等待时限和取消后的处理契约。
  4. 记录提交、开始、结束时间以分离排队与执行耗时。
  5. 测试饱和、关闭竞态、依赖变慢与任务异常的完整行为。

03对照实现代码

execute 的三阶段准入与入队后复查 (新标签页)↗

OpenJDK 17.0.12+7 · ThreadPoolExecutor#execute / addWorker

原创顺序决策模型,仅解释核心阈值、队列与最大阈值的先后;真实源码通过ctl、addWorker和入队后复查处理并发,不能用此模型替代执行器。

核心机制代码 · java
final class PoolAdmissionModel {
    enum Decision { CORE_WORKER, QUEUE, EXTRA_WORKER, REJECT }
    static Decision decide(boolean running, int workers, int core,
                           int maximum, boolean queueHasRoom) {
        if (!running) return Decision.REJECT; // 关闭后不再接受新任务。
        if (workers < core) return Decision.CORE_WORKER; // 优先尝试核心工作者。
        if (queueHasRoom) return Decision.QUEUE; // 达到核心阈值后优先排队。
        return workers < maximum ? Decision.EXTRA_WORKER : Decision.REJECT;
        // 真实创建或入队仍可能竞争失败,必须继续按源码复查。
    }
}

ctl 把“线程池处于什么状态”和“已有多少工作线程”放在同一个原子整数里,让一次读取能拿到互相对应的两项信息。这里的工作线程数包括空闲线程,不只是此刻正在执行任务的线程数。因此不能把监控中的 activeCount 直接拿来代替源码里的数量判断,否则会把闲着但已经存在的工作线程漏掉。

addWorker接收首个任务和是否使用核心阈值的标志,既检查容量,也检查运行状态。创建线程、登记工作者和启动过程还可能失败,因此execute不能简单地把workers加一后就宣布任务已经执行。线程工厂异常或资源耗尽都属于必须考虑的执行边界。

任务入队成功后还要重读 ctl,因为这时线程池可能已经停止接收,或一个工作线程也没有。这个“先尝试再复查”处理的是期间别人改变状态的情况。读源码时给每次重查找出对应竞争,就能理解看似重复的判断为什么不能省。

拒绝策略可能抛异常、在调用线程执行或丢弃任务。submit通常返回Future,如果任务被静默丢弃而Future既不完成也不取消,等待方可能一直等不到结果。shutdown也不等于所有任务已结束,应用应按期限等待终止并处理尚未完成的业务任务。

代码先调用谁,再调用谁

  1. execute验证参数并读取ctl
  2. 核心阈值内尝试addWorker直接执行
  3. 否则offer到workQueue并复查运行状态
  4. 入队失败后按最大阈值尝试addWorker
  5. 无法接收时调用RejectedExecutionHandler

04动手写一个例子

用两个最小堆模拟有界工作队列

给定按到达时刻排序的任务,每个任务耗时为正。固定workers个工作者和queueCapacity个等待位置,按FIFO接受任务,容量已满则返回-1,否则返回完成时刻。时间为非负long,完成时间溢出必须报错。

输入 1
1工作者1等待位;任务(0,5),(1,5),(2,1),(5,1)
输出 1
完成时刻[5,10,-1,11]
输入 2
2工作者0等待位;任务(0,3),(0,4),(0,1)
输出 2
[3,4,-1]

一个最小堆保存每个工作者在已经接受的FIFO计划下何时再次空闲;另一个堆保存所有已接受但在当前到达时刻尚未完成的任务结束时间。每个新任务到来,先弹出已完成项,释放系统容量。若剩余任务数已达到工作者加等待位总数,说明系统满了。

接受任务时取最早空闲的工作者,开始时间是到达时刻与该工作者空闲时刻的最大值,完成时间再加耗时。将新完成时刻放回两个堆。提前给排队任务确定未来开始时间等价于固定工作者下的FIFO调度,前提是耗时已知且任务不会取消、变更优先级或阻塞成未知时间。

每个任务最多进入和离开完成堆一次。工作者堆始终保留固定数量的条目,完成堆大小不超过总容量。相同时间先处理完成事件再接收新任务,明确了边界处容量可立即复用的规则。真实执行器存在调度开销和耗时波动,模拟用来解释容量与排队机制,不能直接当作线上延迟预测器。

为什么这样写是对的完成堆恰好保存当前尚未结束的已接受任务;工作者堆保存所有已安排任务之后各工作者的下一次可用时刻。

时间 O(workers + n log(workers+queueCapacity+1))额外空间 辅助O(workers+queueCapacity),结果O(n)

初始化固定工作者堆可线性建堆,每个任务至多进行常数次堆操作;完成事件总弹出次数不超过接受任务数。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 43 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.*;

public class Main {
    record Task(long arrival, long duration) {
        Task { if (arrival<0 || duration<=0) throw new IllegalArgumentException(); }
    }
    static long[] simulate(int workers, int queueCapacity, List<Task> tasks) {
        if (workers<=0 || queueCapacity<0) throw new IllegalArgumentException();
        Objects.requireNonNull(tasks);
        PriorityQueue<Long> available = new PriorityQueue<>(Collections.nCopies(workers,0L));
        PriorityQueue<Long> unfinished = new PriorityQueue<>();
        long[] result = new long[tasks.size()];
        long previous=0, capacity=(long)workers+queueCapacity;
        for (int i=0;i<tasks.size();i++) {
            Task task=Objects.requireNonNull(tasks.get(i));
            if (task.arrival()<previous) throw new IllegalArgumentException("unsorted arrivals");
            previous=task.arrival();
            while (!unfinished.isEmpty() && unfinished.peek()<=task.arrival()) unfinished.poll();
            // 同时刻先完成旧任务,再判断新任务能否进入系统。
            if (unfinished.size()>=capacity) { result[i]=-1; continue; }
            long start=Math.max(task.arrival(),available.peek());
            long finish=Math.addExact(start,task.duration()); // 溢出时拒绝伪造时间。
            available.poll(); available.add(finish); // 将任务安排给最早可用工作者。
            unfinished.add(finish); result[i]=finish;
        }
        return result;
    }
    static void check(long[] actual, long... expected) {
        if (!Arrays.equals(actual,expected)) throw new AssertionError(Arrays.toString(actual));
    }
    public static void main(String[] args) {
        check(simulate(1,1,List.of(new Task(0,5),new Task(1,5),new Task(2,1),new Task(5,1))),5,10,-1,11);
        check(simulate(2,0,List.of(new Task(0,3),new Task(0,4),new Task(0,1))),3,4,-1);
        check(simulate(1,0,List.of(new Task(0,2),new Task(2,1))),2,3);
        check(simulate(1,1,List.of()));
        check(simulate(2,1,List.of(new Task(0,9),new Task(0,2),new Task(1,1))),9,2,3);
        try { simulate(1,0,List.of(new Task(Long.MAX_VALUE,1))); throw new AssertionError(); }
        catch (ArithmeticException expected) { }
        try { simulate(1,0,List.of(new Task(2,1),new Task(1,1))); throw new AssertionError(); }
        catch (IllegalArgumentException expected) { }
        System.out.println("PASS 7");
    }
}

这些特殊情况也要试一试

  • 零等待位只接受立即可执行的任务。
  • 任务完成与新到达同一时刻时先释放容量。
  • 到达顺序非法或耗时非正拒绝。
  • long完成时间溢出显式报错。
进阶选读 · 带截止时间与优先级的准入控制

场景短查询与长报表共用执行器,报表积压使查询在开始前就超过用户等待期限。

做法先把业务类型不同、依赖资源不同的任务分开安排,再在接收任务时估算它还能等多久。确有需要时,可以让截止时间更近的任务先做,同时逐步提高久等任务的优先程度,避免它们一直排不到。取消、超时和真正开始执行之间也要有明确状态;从队列删掉任务,不足以证明业务从未开始。

复杂度优先队列插入和取出O(log Q),取消任意任务若无索引可能O(Q);维护索引可提速但增加同步复杂度。

好处与代价优先级改善关键请求尾延迟,却可能让低优先级饥饿,并改变FIFO公平性;隔离通常更易解释。

05项目里怎么用

把池指标与依赖指标一起看:活跃线程、队列长度、排队年龄、拒绝数、数据库连接等待和远端延迟。线程很忙却CPU很低,可能在等IO或锁;CPU打满时继续加线程通常只会增加调度竞争。根据证据定位瓶颈,比套用固定的“CPU核数乘某个倍数”更可靠。

任务应有可识别的名称和失败记录。execute 的异常与 submit 放进 Future 的异常,需要不同方式观察;提交后不读取 Future,可能漏掉失败。拒绝时要让调用方得到可处理结果,或可靠存进独立持久队列。仅进入内存队列,不表示进程崩溃后还能找回。

停机先停止接收新流量,再关闭执行器,按期限等待已接收任务完成;超过期限时处理取消与重试责任。shutdownNow只是尽力中断,任务是否停止取决于其阻塞方式和协作检查。关键业务还应有幂等与恢复记录,以应对进程直接退出而没有机会优雅收尾。

容易踩的坑

  • 无界队列配很大maximumPoolSize,误以为会主动扩容。
  • CallerRuns阻塞事件循环或关键提交线程。
  • 静默丢弃Future任务导致等待方永不完成。
  • 池内任务同步等待同池子任务,造成线程饥饿死锁。
  • 队列只按条数估算而忽略任务保留的大对象。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
有界队列与显式拒绝容量和失败边界清楚上游必须处理过载在线服务与关键异步任务
CallerRuns向提交方施加直接反馈改变执行线程与响应时延可承受同步执行的普通生产者
独立持久消息队列进程退出后仍可恢复任务增加消息协议、幂等与运维成本需要可靠接收的大量后台工作

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01为什么无界队列会让最大线程数不明显生效?

展开参考答案与推导

达到核心阈值后先尝试入队,队列通常不会失败,因此很少进入扩容到最大值的分支。

02队列长度相同是否表示压力相同?

展开参考答案与推导

不表示。任务耗时、保留内存与最老等待年龄不同,排队条数只是部分证据。

03为什么入队成功后还要复查池状态?

展开参考答案与推导

关闭可能与offer并发发生,必须处理不再允许接收但任务已进入队列的竞态。

换个情况,还会处理吗

01异步接口返回成功但任务没执行,先查什么?

展开参考答案与推导

检查拒绝策略是否静默丢弃、Future是否观察异常、进程是否重启以及成功响应是否仅代表内存接收。

02所有工作线程都在Future.get,CPU空闲,如何处理?

展开参考答案与推导

检查任务是否等待提交到同一池的子任务,画出依赖关系;使用非阻塞组合或分离执行器,避免循环占用有限工作者。

03下游延迟变长后队列暴涨,直接加线程可行吗?

展开参考答案与推导

先核对下游和连接池容量;更多并发可能继续压垮依赖,应采用隔离、总预算、限流和明确拒绝。

写代码试一试

01实现固定工作者有界队列模拟。

输入 / 条件
1工作者1等待位;(0,5),(1,5),(2,1),(5,1)
输出 / 预期
[5,10,-1,11]

提示 维护工作者可用堆和未完成堆。

展开参考答案与推导

按正文双堆扫描,先结算同刻完成事件。

02编写确定性的线程池饱和测试。

输入 / 条件
核心1最大1队列1,第一任务由Latch阻塞,提交三任务
输出 / 预期
第三任务抛RejectedExecutionException

提示 用Latch控制执行时序,不靠sleep猜测。

展开参考答案与推导

等待第一任务已开始,再填满队列并验证拒绝;finally释放Latch与关闭池。

03给任务增加排队超时检查。

输入 / 条件
提交时0,开始时120,排队上限100
输出 / 预期
跳过业务并记录超时

提示 业务开始前检查单调时钟差值。

展开参考答案与推导

用可注入时钟测试等于边界、已超时与未超时,并区分任务已开始后的取消责任。

读完之后,独立完成

课后作业:自己做一遍

本篇共 12 道作业,先完成 9 道基础与综合任务,再按前置知识挑战进阶题。其中包含 2 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能按任务耗时和依赖容量设置有界队列,观察并解释拒绝发生的位置。

本篇重点加练

队列容量与拒绝决定任务是否真正被接纳,还需把排队时效和业务反馈写入契约。

  • 队列容量
  • 拒绝策略

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    本地线程池核心数 2、最大数 4、队列容量 3;用门闩阻塞工作任务,再顺序提交 9 个带编号任务。运行前写出预测并逐次核对状态。

    提交内容
    预测表、程序与观测表
    怎样算完成
    • 每次记录线程和排队数量
    • 注明任务何时获准继续

    练到的知识点核心线程 · 工作队列 · 最大线程 · 队列容量

  2. 作业 02原理问答

    HTTP 请求线程提交任务时触发 CallerRuns,任务又会访问慢数据库。分析调用者身份变化对延迟、上下文和事务边界的影响。

    提交内容
    调用线程图与风险清单
    怎样算完成
    • 区分提交耗时和异步耗时
    • 包含执行器关闭时的行为核查

    练到的知识点拒绝策略 · CallerRuns

  3. 作业 03原理问答

    同样 100 个排队任务,一组每项只保留整数,另一组每项捕获大请求体。分析以任务数限制队列时遗漏的内存预算。

    提交内容
    容量与对象保留估算
    怎样算完成
    • 写明每项估算依据
    • 区分任务计数和内存占用

    练到的知识点队列容量

  4. 作业 04代码推演

    自定义拒绝处理器只记日志后返回,提交接口却总返回受理成功。列出提交调用、任务未来状态和业务回执之间的契约。

    提交内容
    受理状态图
    怎样算完成
    • 包含 submit 返回对象的后续行为
    • 不能以无异常直接定义执行成功

    练到的知识点拒绝策略

综合应用

  1. 作业 05排错实验

    分别通过 execute 与 submit 运行抛异常的本地任务,使用自定义线程名和结果收集记录异常传播路径。

    提交内容
    最小程序与日志证据
    怎样算完成
    • 注明异常观察位置
    • 不以线程存活作为任务成功证据

    练到的知识点任务异常

  2. 作业 06设计评审

    服务停止时有已完成、排队、执行中的任务,部分任务持有文件句柄。制定停止期限和每类任务的最终状态记录。

    提交内容
    关闭协议与资源核对表
    怎样算完成
    • 包括拒绝新任务
    • 等待超时后仍可识别未完成项

    练到的知识点关闭与等待

  3. 作业 07编程实践

    实现一个本地图片元信息批处理器,以小文本文件代替图片;设置有界队列、异常样本和取消动作,报告每个任务结局。

    提交内容
    源码、输入目录和任务账本
    怎样算完成
    • 不静默丢任务
    • 记录队列容量及执行上限

    练到的知识点核心线程 · 工作队列 · 最大线程 · 队列容量 · 拒绝策略 · CallerRuns · 任务异常 · 关闭与等待

  4. 作业 08设计评审

    批任务占满共享队列后,登录验证码校验也需提交到同一池。制定业务优先级和过载反馈要求,说明公平性指标。

    提交内容
    分类容量与拒绝规则
    怎样算完成
    • 覆盖低流量关键任务
    • 明确不保证的执行顺序

    练到的知识点队列容量 · 拒绝策略

  5. 作业 09编程实践

    实现本地任务接纳账本,以小型可控任务模拟普通、紧急与已超期限提交,核对调用者回执和每个任务最终状态。

    提交内容
    程序与状态对账报告
    怎样算完成
    • 实验线程及队列有上限
    • 拒绝项不能从报告中消失

    练到的知识点队列容量 · 拒绝策略

进阶挑战 · 掌握前置后选做

  1. 作业 10设计评审

    任务平均 40 毫秒但少数耗时 3 秒,系统必须在 2 秒内反馈受理与否。制定入队条件和过载验收场景。

    提交内容
    容量估算与验收计划
    怎样算完成
    • 区分受理成功和执行完成
    • 覆盖持续过载而非只有突发

    练到的知识点工作队列 · 最大线程 · 队列容量 · 拒绝策略 · 关闭与等待

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 11思维拓展 · 跨域迁移

    把有界线程池迁移到 GPU 推理批处理,任务大小和显存需求不同。探究只限制队列长度为何可能不足,并提出可验证的容量口径。

    提交内容
    资源模型与实验计划
    怎样算完成
    • 区分等待任务资源和执行资源
    • 使用本地小模拟器说明验证边界

    练到的知识点队列容量 · 拒绝策略

  2. 作业 12思维拓展 · 反例构造

    构造“队列从未达到容量上限,但用户请求仍长期无进展”的运行轨迹,要求拒绝策略没有配置错误。

    提交内容
    反例轨迹及观测指标
    怎样算完成
    • 不能仅令线程无限 sleep
    • 指出真实等待依赖或执行条件

    练到的知识点队列容量 · 拒绝策略

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗