Java 线程池到底怎么接任务?一张图看懂核心线程、队列、最大线程与拒绝策略

Java 线程池到底怎么接任务?一张图看懂核心线程、队列、最大线程与拒绝策略 写在前面很多人记得线程池有“核心线程数、最大线程数、阻塞队列、拒绝策略”但参数一放进具体场景就容易混乱核心线程忙了以后线程池为什么不立刻扩容队列变大后为什么同时执行的任务反而可能减少任务没有报错为什么 P95 和 P99 仍然很高本文从一个可重复的实验出发用 7 个任务讲清ThreadPoolExecutor的任务去向、队列大小的取舍、拒绝策略以及应该观察的指标。文中的小数字用于理解机制不是生产环境的推荐配置。目录文章目录一、为什么需要线程池二、先看懂 ThreadPoolExecutor 的七个参数corePoolSize核心线程数maximumPoolSize最大线程数keepAliveTime 和 unit额外线程空闲多久后回收workQueue等待执行的任务放在哪里threadFactory怎样创建线程handler线程池满了怎么办三、任务进入线程池的固定顺序四、实验一队列容量为2五、实验二只把队列容量改成4为什么最大线程是4却只创建了3个线程六、队列为什么会拉长响应时间队列容量2队列容量4七、P95 和 P99 具体怎么计算八、四种拒绝策略怎么选AbortPolicy明确抛出异常CallerRunsPolicy让提交任务的线程自己执行DiscardPolicy静默丢弃新任务DiscardOldestPolicy丢掉队列中最旧的任务后重试九、生产环境参数怎么确定核心线程和最大线程队列容量不要只看CPU十、一个更完整的线程池写法十一、排查任务积压应该看哪些证据十二、最容易踩的六个坑误区一maximumPoolSize配置为100就一定有100个线程误区二队列越大吞吐量越高误区三没有报错就代表线程池健康误区四AbortPolicy会自动保存失败任务误区五CallerRunsPolicy不会丢任务所以永远最好误区六直接使用Executors工厂方法就不需要关注容量十三、最后总结参考资料一、为什么需要线程池如果每来一个任务就创建一个新线程任务1 → new Thread 任务2 → new Thread 任务3 → new Thread ……任务突然增多时线程数量也可能快速增长。创建、销毁线程需要成本太多线程还会增加内存占用和线程切换。线程池相当于一个有固定员工、有等候区、也有接待上限的工作室任务提交 ↓ 已有或新建的工作线程执行 ↓ 暂时处理不了的任务进入队列 ↓ 线程和队列都达到上限后执行拒绝策略它解决的核心问题不是“让任务无限并发”而是复用线程并限制并发资源的使用范围。二、先看懂 ThreadPoolExecutor 的七个参数一个常见的手动配置如下ThreadPoolExecutorexecutornewThreadPoolExecutor(2,// corePoolSize4,// maximumPoolSize30,// keepAliveTimeTimeUnit.SECONDS,// unitnewArrayBlockingQueue(2),// workQueuenamedThreadFactory,// threadFactorynewThreadPoolExecutor.AbortPolicy()// handler);corePoolSize核心线程数核心线程负责承接日常任务。默认情况下任务到达时才逐步创建核心线程而不是构造线程池后立即全部创建。maximumPoolSize最大线程数线程池允许存在的线程上限。它不是目标值也不代表线程池一定会创建这么多线程。keepAliveTime 和 unit额外线程空闲多久后回收线程数超过核心线程数时多出来的线程如果空闲时间超过keepAliveTime通常会被回收。默认情况下这个规则不作用于核心线程。workQueue等待执行的任务放在哪里这里使用有界的ArrayBlockingQueue。有界表示队列位置有限可以避免任务无限积压。threadFactory怎样创建线程可以通过它给线程设置容易排查的名称例如order-export-1 order-export-2发生故障时这比pool-1-thread-1更容易判断线程属于哪个业务。handler线程池满了怎么办当线程池仍在运行并且最大线程数与队列都达到上限时新任务交给拒绝策略处理。三、任务进入线程池的固定顺序对本文这种“线程池仍在运行并使用有界ArrayBlockingQueue”的ThreadPoolExecutor可以先记住这条链路当前工作线程数小于 corePoolSize创建工作线程执行 ↓ 当前工作线程数达到 corePoolSize 阻塞队列 ↓ 队列已满任务无法入队 继续创建工作线程直到 maximumPoolSize ↓ 最大线程数也达到上限 拒绝策略最容易记错的是第二步当前工作线程数达到corePoolSize后线程池优先让任务排队只有任务无法进入队列时才继续创建工作线程直到maximumPoolSize。Java 21 官方说明还有一个容易忽略的细节只要当前工作线程数少于corePoolSize提交新任务时就会尝试创建工作线程即使池中已有其他空闲线程。因此“核心线程”适合帮助理解参数边界不应想象成几名永远固定编号、固定身份的员工。Java 8、17、21、25 的标准ThreadPoolExecutor都遵循这套任务接收顺序具体表现还取决于队列类型、任务执行速度和线程池状态。这里说的是标准ThreadPoolExecutor。例如 Java 21 以后可以使用的虚拟线程执行器Executors.newVirtualThreadPerTaskExecutor()并不采用“核心线程 → 队列 → 最大线程”这套参数模型。所以maximumPoolSize4不代表第 4 个任务到达时就一定有 4 个线程。四、实验一队列容量为2实验参数核心线程数2 最大线程数4 队列容量2 同时提交任务7本地实验不是依靠任务“碰巧执行得慢”。运行中的任务会先在CountDownLatch闸门处等待直到 7 个任务全部提交并记录快照后才统一放行。因此截图时运行、排队和拒绝状态不会提前变化。7 个任务依次进入线程池任务1 → 核心线程1运行 任务2 → 核心线程2运行 任务3 → 进入队列 任务4 → 进入队列队列已满 任务5 → 创建额外线程3运行 任务6 → 创建额外线程4运行 任务7 → 最大线程和队列都满触发拒绝策略快照结果任务状态1运行2运行3排队4排队5运行6运行7拒绝汇总为运行4个排队2个拒绝1个五、实验二只把队列容量改成4其他参数不变核心线程数2 最大线程数4 队列容量4 同时提交任务7这一组使用相同的闸门和提交顺序只把队列容量从 2 改成 4。任务去向变成任务1 → 核心线程1运行 任务2 → 核心线程2运行 任务3 → 排队 任务4 → 排队 任务5 → 排队 任务6 → 排队队列已满 任务7 → 创建额外线程3运行快照结果运行3个排队4个拒绝0个为什么最大线程是4却只创建了3个线程这不是排队任务把正在运行的线程挤掉了而是大队列让线程池更晚扩容队列容量变大 ↓ 更多任务可以先排队 ↓ 更晚出现“队列已满” ↓ 额外线程更晚创建任务 7 只触发了第 3 个线程之后已经没有任务 8 来触发第 4 个线程。因此maximumPoolSize4只是上限实际快照中只有 3 个线程。两次实验的区别可以放在一起看配置正在运行正在排队被拒绝核心2、最大4、队列2421核心2、最大4、队列4340结论不是“队列越小越好”而是小队列更快触发扩容但更早拒绝任务 大队列能暂存更多任务但可能增加等待并延迟扩容六、队列为什么会拉长响应时间为了单独理解排队时间假设 7 个任务在运行中任务结束前依次快速提交每个成功任务执行 1 秒队列按 FIFO 取任务并暂时忽略线程启动、调度、网络和系统误差。下面是基于前述任务去向推导的教学模型不是本地CountDownLatch实验直接测得的耗时。队列容量2成功接收 6 个任务4个任务立即执行等待0秒总耗时1秒 2个任务排队一轮等待1秒总耗时2秒平均等待时间(0×4 1×2) ÷ 6 ≈ 0.333秒平均总耗时(1×4 2×2) ÷ 6 ≈ 1.333秒但是还有 1 个任务被拒绝拒绝率为1 ÷ 7 ≈ 14.29%队列容量4成功接收全部 7 个任务第一批3个等待0秒总耗时1秒 第二批3个等待1秒总耗时2秒 最后1个等待2秒总耗时3秒平均等待时间(0×3 1×3 2×1) ÷ 7 ≈ 0.714秒平均总耗时(1×3 2×3 3×1) ÷ 7 ≈ 1.714秒这组简化计算说明扩大队列可以减少拒绝却可能让更多已接收任务等待更久。由于两组接收的任务数量不同不能只比较平均值就断言哪组配置更好还必须同时查看拒绝率。真实接口的用户响应时间通常是总响应时间 线程池排队时间 业务执行时间 数据库或下游等待 框架、网络和系统调度开销七、P95 和 P99 具体怎么计算P95、P99 不是“平均值乘以 95% 或 99%”。本文为了方便手算明确采用最近排名法Nearest Rank1. 取成功请求的耗时 2. 从小到大排序 3. P95位置 向上取整(请求数量 × 0.95) 4. P99位置 向上取整(请求数量 × 0.99) 5. 取对应位置的耗时假设有 20 个成功请求500ms × 5个 1000ms × 5个 1500ms × 5个 2000ms × 4个 2100ms × 1个P95 的位置向上取整(20 × 0.95) 19 P95 排序后的第19个 2000msP99 的位置向上取整(20 × 0.99) 20 P99 排序后的第20个 2100ms在本文采用的最近排名法下它们表达的是P95至少95%的成功请求耗时不超过这个值 P99至少99%的成功请求耗时不超过这个值样本只有 20 个时每个请求占 5%所以 P99 会直接落到最慢请求。样本越少百分位越粗糙。百分位数并不存在唯一的有限样本算法不同压测和监控工具可能采用插值、直方图估算等其他口径比较数据前必须先确认算法和时间窗口。八、四种拒绝策略怎么选AbortPolicy明确抛出异常任务被拒绝时抛出RejectedExecutionException。它的优点是失败不会悄悄消失适合需要明确发现问题的任务。但抛出异常不等于任务已经得到妥善处理。业务代码仍然需要捕获异常 ↓ 记录任务和失败原因 ↓ 告警、落库或进入可靠重试流程适用场景订单处理、账务、库存等不能静默丢失的关键任务。调用方具备明确的异常处理、告警、落库或补偿流程。希望线程池一旦过载就快速暴露问题而不是继续隐藏积压。**不适合直接使用的情况**调用方没有捕获RejectedExecutionException。此时虽然线程池明确报错了业务任务仍可能丢失。CallerRunsPolicy让提交任务的线程自己执行线程池因容量饱和而无法接收任务时由调用execute的线程直接运行任务。这会减慢提交速度形成一种简单的反馈控制。若线程池已经关闭则任务会被丢弃不会由调用线程执行。如果提交者是 Tomcat 请求线程Tomcat线程提交异步任务 ↓ 线程池已满 Tomcat线程自己执行任务 ↓ HTTP接口响应时间变长因此它不是“任务永不失败”的万能方案。适用场景提交线程允许被减速任务本身执行时间较短。内部批处理、数据转换等场景希望用调用方变慢形成简单的反馈控制。调用方能够接受任务同步执行带来的耗时和异常传播。**慎用场景**Tomcat 请求线程、Netty 事件循环线程、定时调度线程或者提交任务时仍持有业务锁的代码。调用线程被长期占用后可能拉长响应、阻塞事件循环甚至放大锁竞争。DiscardPolicy静默丢弃新任务新任务直接被丢弃不抛异常。金融、订单、通知等必须追踪结果的业务通常不能接受这种策略。适用场景非常有限允许丢样本的高频监控、统计或调试事件。高频刷新类任务单次结果丢失不会影响最终业务状态。系统已经有独立的丢弃计数、监控和告警能够知道发生了多少次丢弃。如果任务是否完成会影响用户、资金、订单状态或消息送达就不应使用静默丢弃。DiscardOldestPolicy丢掉队列中最旧的任务后重试在线程池尚未关闭时它会移除队头等待最久的任务再尝试提交当前任务重试仍可能再次失败并重复触发拒绝处理。若线程池已经关闭当前任务会被丢弃。对必须保证每个任务都有结果的场景同样风险很高。可能考虑的场景队列严格按 FIFO 排列而且新状态明显比旧状态更有价值。例如非关键的界面刷新、实时预览或可覆盖状态更新旧任务即使执行也已经没有意义。业务能够识别被淘汰的旧任务并有取消、计数或日志记录。**不适用场景**订单、流水、消息投递等要求逐项完成的任务使用优先级队列时也不能简单把“队头”理解成提交时间最早的任务。可以先用这张表记忆策略更适合什么场景主要风险AbortPolicy关键任务需要明确失败并进入补偿调用方不处理异常仍会丢任务CallerRunsPolicy允许提交方减速的短任务占用提交线程关闭后仍丢弃DiscardPolicy真正允许丢失的监控、采样、调试事件任务静默消失且难发现DiscardOldestPolicy新状态明显比旧状态重要的可覆盖任务队头任务被牺牲重试仍可能失败生产环境没有“永远最好”的拒绝策略。真正的选择顺序应该是任务能不能丢 ↓ 调用线程能不能被占用 ↓ 新任务是否真的比旧任务更重要 ↓ 失败后由谁记录、告警、补偿或重试九、生产环境参数怎么确定实验里的 2、4、2、7 是为了让任务去向清晰不是根据生产机器计算出的标准答案。真实参数要从整条资源链路判断任务到达速度 ↓ 线程池活动线程 ↓ CPU计算或I/O等待 ↓ 数据库连接池 ↓ Redis、消息队列、远程服务核心线程和最大线程CPU计算很多的任务线程太多会增加切换开销。大量等待数据库或网络的任务可以容纳更多线程但不能超过数据库连接池和下游的实际承载能力。最大线程数只是保护上限不能靠无限增加线程绕过下游瓶颈。队列容量太小突发流量下更容易拒绝。太大任务等待时间、P95/P99和内存占用可能持续增加。无界提交速度长期超过处理速度时任务可能不断积压。因此对于需要明确容量边界的在线业务通常会优先评估有界队列但队列类型和容量没有通用答案必须结合任务依赖、峰值流量、延迟目标与拒绝后的补偿能力验证。不要只看CPU线程池任务可能在等待数据库连接此时 CPU 并不高但接口已经非常慢线程数100 数据库连接数20 ↓ 最多20个任务真正使用数据库 其余线程仍然需要等待增加线程前要确认瓶颈究竟在 CPU、数据库连接池、锁、磁盘、网络还是下游服务。十、一个更完整的线程池写法AtomicIntegerthreadNumbernewAtomicInteger();ThreadFactorythreadFactorytask-newThread(task,order-export-threadNumber.incrementAndGet());ThreadPoolExecutorexecutornewThreadPoolExecutor(4,8,30,TimeUnit.SECONDS,newArrayBlockingQueue(100),threadFactory,newThreadPoolExecutor.AbortPolicy());try{executor.execute(task);}catch(RejectedExecutionExceptionexception){// 关键任务不能只打印日志需要记录任务、告警或进入可靠补偿流程。saveFailedTask(task,exception);}这段代码中的4、8、100只是写法示例必须通过业务规模、SLA和压测验证后才能成为真实配置。如果线程池由当前组件负责创建也要负责有序关闭executor.shutdown();shutdown()会停止接收新任务并继续处理已经提交的任务。不要在每次业务调用中临时创建线程池又立即关闭而应根据组件生命周期统一管理。十一、排查任务积压应该看哪些证据只看“接口有没有报错”不够。线程池已经积压时任务可能仍然全部成功但用户等待时间已经无法接受。建议同时观察当前线程数poolSize正在执行的线程数activeCount队列当前长度与容量已完成任务数拒绝任务数任务排队时间和实际执行时间接口 QPS、平均耗时、P95、P99和错误率CPU、内存、GC数据库连接池等待时间与下游接口耗时。getActiveCount()、getTaskCount()、getCompletedTaskCount()等线程池统计值是近似值适合监控趋势和排障快照不应被当作强一致的业务计数。推荐按照下面的链路排查任务量和SLA ↓ 任务提交到哪里、经过哪些资源 ↓ 线程、队列还是下游先到上限 ↓ 选择优化、限流、扩容或异步削峰 ↓ 处理拒绝、超时、重试和重复执行 ↓ 用QPS、P95/P99、拒绝率和资源指标验证 ↓ 评估等待、成本和复杂度的取舍十二、最容易踩的六个坑误区一maximumPoolSize配置为100就一定有100个线程错误。它只是上限。队列没有满时线程数可能一直停留在核心线程数附近。误区二队列越大吞吐量越高错误。大队列主要增加缓冲能力也可能延迟扩容、拉长等待和尾部延迟。误区三没有报错就代表线程池健康错误。任务可能全部成功但 P95、P99和队列等待已经严重超标。误区四AbortPolicy会自动保存失败任务错误。它只负责抛出异常记录、告警、落库和重试仍然需要业务代码完成。误区五CallerRunsPolicy不会丢任务所以永远最好错误。它会占用提交任务的线程。如果提交者是Tomcat线程HTTP请求可能被拖慢。误区六直接使用Executors工厂方法就不需要关注容量例如Executors.newFixedThreadPool使用共享的无界LinkedBlockingQueue。由于核心线程数与最大线程数相同线程数固定任务持续提交得比处理更快时等待队列可能不断增长。生产环境仍需理解其内部配置和资源边界。十三、最后总结线程池最重要的不是背参数而是看清任务在容量变化时去了哪里当前工作线程数小于核心线程数时创建线程 当前工作线程数达到核心线程数后优先进入队列 队列满后才扩容到最大线程数 线程与队列都达到边界或线程池已关闭时执行拒绝策略队列大小和最大线程数是一组取舍更大的队列 → 可以接收更多突发任务 → 但可能延迟扩容并增加等待 更小的队列 → 更快触发额外线程 → 但可能更早拒绝任务真正判断配置是否合适需要同时观察QPS 平均耗时 P95/P99 拒绝率 CPU/GC 活动线程 队列长度 数据库连接池和下游服务线程池不是把压力消灭了而是在执行、等待和拒绝之间建立明确边界。参考资料Oracle Java 8ThreadPoolExecutorOracle Java 17ThreadPoolExecutorOracle Java 21ThreadPoolExecutorOracle Java 25ThreadPoolExecutorOracle Java 21ThreadPoolExecutor.AbortPolicyOracle Java 21ThreadPoolExecutor.CallerRunsPolicyOracle Java 21ThreadPoolExecutor.DiscardPolicyOracle Java 21ThreadPoolExecutor.DiscardOldestPolicyOracle Java 21Executors