利特尔法则 · 线程数 · 队列长度 · AbortPolicy · CallerRunsPolicy · DiscardPolicy · DiscardOldestPolicy · 背压 · setCorePoolSize · setMaximumPoolSize · getActiveCount · 舱壁 · shutdown · awaitTermination · shutdownNow

线程池下一圈:参数从流量算出来,满了怎么办要提前想好

上一圈的 ThreadPoolExecutor.execute 源码讲了任务进池子走哪几步。这一圈回答上线前必须答的三个问题:开几个线程、队列多长(用“每秒多少个 × 每个多久”算,不是拍脑袋);满了怎么办(四种拒绝策略,实测里三种丢了 86% 的通知,一种把提交的线程拖慢了 40 倍);配错了怎么办(线程池能在运行中改大小,实测在线从 2 调到 16,积压的 400 条通知 1 秒发完)。最后是关机时怎么不丢任务。

全景:一个下单请求在线程池里 · 第 2、10、11 站地图:J02 · 线程池:执行流程、参数、拒绝策略、怎么配(第 6 个知识点,下一圈)上一圈:execute 源码 · Spring @Async 与线程池实验:JDK 17.0.20,2 核 ARM 实测

①为什么需要:全景里有两个池子,参数都是抄来的

在全景第 2 站,这一步要解决的是:Tomcat 的 exec 池默认最多 200 个线程,这个数对交易靶场合不合适?第 10 站的通知要交给一个异步池,这个池子开几个线程、队列多长、满了怎么办?第 11 站线程回池,那发布新版本、进程要退出时,池子里还没发的通知怎么办?

上线前要回答的上一圈讲了什么这一圈补什么
开几个线程、队列多长execute 的顺序:核心 → 队列 → 最大 → 拒绝④-1 用流量算参数,带计算器
满了怎么办拒绝策略在源码里被调到④-2 四种策略各自的代价,⑤ 实测
配错了、流量变了列在“下一圈”:动态调参④-3 运行中改大小,⑤ 实测
怎么知道它快满了-④-4 监控哪几个数
发版时池里的任务-④-5 优雅关闭三步
需求地图上这一项的深度是“懂设计”,过关题是“画出 execute 的三个分支,按你的接口算出该开多少线程”。第一半上一圈做完了,这一圈做第二半:能从 QPS 和耗时算出线程数和队列长度,说得出四种拒绝策略分别把代价转嫁给了谁。

②没有它会怎样:参数拍脑袋、满了才发现

不懂这一点时的典型误用:给通知池配 CallerRunsPolicy,觉得“不丢任务最安全”。实测 池子满了以后,提交通知的线程自己去发通知,提交 100 条从 9 ms 变成 363 ms。在全景里提交方是 Tomcat 的 exec 线程:下单接口每次都多卡一个通知的时间(⑤)。

③相邻方案:同一件事的几种做法

要做的做法说明
配参数经验值:核数 × 2、核数 + 1只适用于纯计算或“一半一半”的活
按流量算:利特尔法则 + 等待比例本页 ④-1
压测找拐点最终都要做,算出来的数是起点
满了拒绝策略(四种 + 自定义)本页 ④-2
在池子前面限流(Semaphore、令牌桶、Sentinel)不让任务进池子(AQS 下一圈 ④-3、J12)
换成消息队列任务落盘,不怕进程重启(J08)
改参数改配置、重启简单,但要发版
运行中调 setCorePoolSize / setMaximumPoolSize配合配置中心推送,即“动态线程池”(美团技术团队 2020 年的文章让这个做法流行起来)

选择规则:参数先算、再压测;满了先想“能不能丢”,能丢的选 Discard,不能丢又不能慢的放消息队列,宁可慢也不能丢的才用 CallerRuns;参数要能在线改、要有监控。本页学怎么算、四种策略、在线改、监控和关闭。

④原理:从流量推参数,满了的代价转给谁

先知道这些
  • execute 的顺序:线程数 < 核心就新建;否则放队列;队列满了且 < 最大就新建;再不行就拒绝(上一圈)。Tomcat 的池子把第二、三步对调了(全景第 2 站)。
  • 线程数公式:核数 × (1 + 等待 ÷ 计算)(线程页 ④-4)。
  • 有界队列和背压:满了就让放的一方等或者失败(上一页 ④-4)。

④-1怎么配:两个公式,一个计算器

同时在忙的任务数 = 每秒到达的任务数 × 每个任务的耗时(利特尔法则)

每秒 200 条通知、每条 50 ms:平均同时有 200 × 0.05 = 10 条在处理,所以至少要 10 个线程,再留余量应对高峰。这个公式不管任务是在算还是在等,只看“占着线程多久”。

CPU 能撑的上限 = 核数 ÷ 每个任务里的计算占比

每条 50 ms 里只有 2 ms 在算,8 核最多撑 8 ÷ (2/50) = 200 个线程同时干活;超过这个数,多出来的线程只能排队等 CPU。两个数一起看:利特尔法则给下限,CPU 上限给天花板。

队列长度 = 每秒到达的任务数 × 能接受的排队时间

通知晚发 2 秒可以接受:200 × 2 = 400。队列再长,排到的任务已经超时,没有意义。

计算器的公式就是上面三条;“核心”取平时需要的线程数,“最大”取高峰需要的数,都不超过 CPU 上限。算出来只是起点,最后看压测和监控(④-4)。

④-2满了:四种拒绝策略,代价转给了谁

策略做什么(JDK 17 源码)代价给了谁适合
AbortPolicy(默认)抛 RejectedExecutionException调用方:要自己 catch,决定重试还是报错调用方能处理失败(返回“稍后再试”)
CallerRunsPolicy池子没关就在提交的线程里直接 r.run()提交方变慢,自然就提交得少了(背压)任务不能丢、提交方慢一点可以接受(批处理、离线任务)
DiscardPolicy什么也不做,悄悄丢掉被丢的任务,没人知道丢了无所谓(采样日志、可补偿的统计)
DiscardOldestPolicy从队列头扔掉最老的一个,再 execute 新的排了最久的那个任务只关心最新状态(行情推送、进度刷新)

④-3运行中改大小:顺序和效果

④-4监控:看这五个数

指标方法说明什么
活跃线程getActiveCount()正在跑任务的线程数,长期等于最大值就是不够用了
排队数getQueue().size()积压;除以每秒完成数就是“新任务大概要等多久”
线程数 / 历史最大getPoolSize()、getLargestPoolSize()有没有扩到最大过
完成数getCompletedTaskCount()两次采样相减就是吞吐
拒绝数自定义拒绝策略里计数JDK 不提供,必须自己记

getActiveCount、getCompletedTaskCount 要拿池子的 mainLock、遍历所有 Worker,是近似值,别每毫秒调一次。Spring Boot 的 Actuator 会给它自己建的任务执行器注册 executor.* 指标(Micrometer 的 ExecutorServiceMetrics),自己 new 的池子要手动绑定。

④-5优雅关闭:三步

④-6为什么这样设计:把“满了”交给调用方决定

JDK:有界 + 可插拔的拒绝策略ThreadPoolExecutor

  • 池子只负责发现“满了”,满了以后丢、等、还是转给别人,由业务按任务的性质选
  • CallerRuns 用最朴素的方式实现了背压:谁提交得快,谁就自己去干活
  • 参数可以在运行中改,池子本身不需要重建
  • 默认的 AbortPolicy 要求调用方处理异常,很多人没处理

被放弃的:永远接、永远不满无界队列,或者无上限的线程

  • 调用方永远不会失败,写起来最省事
  • 失败只是被推迟了:内存一路涨到 OOM,或者线程无限多把机器拖死
  • 问题出现的时候,离真正的原因(下游变慢)已经很远

决定胜负的约束:资源一定是有限的。池子早晚会满,区别只在于“满了”这件事是在设计时被想好,还是在 OOM 时被发现。JDK 给了有界的工具和四种默认答案,但哪个对,只有业务知道。

API最少要会的几行
ThreadPoolExecutor notifyPool = new ThreadPoolExecutor(
    10, 20, 60, TimeUnit.SECONDS,                      // 核心、最大、空闲多久回收
    new ArrayBlockingQueue<>(400),                     // 有界
    namedFactory("notify-"),                           // 起名字(线程页规范 1)
    (r, ex) -> { rejected.increment(); retryLater(r); });   // 自己的拒绝策略:记数 + 补发

notifyPool.setMaximumPoolSize(32);                     // 在线调大:先最大
notifyPool.setCorePoolSize(16);                        //          再核心

notifyPool.shutdown();
if (!notifyPool.awaitTermination(30, TimeUnit.SECONDS)) {
  List<Runnable> left = notifyPool.shutdownNow();     // 没做完的记下来补发
}

Spring 的 ThreadPoolTaskExecutor 怎么配、Boot 关机时怎么等任务、虚拟线程要不要池化,放到第⑥步。

⑤亲眼看到:通知池过载,和在线调大

两个程序,--release 8 编译,JDK 17 运行。完整代码:study-labs/java/juc/threadpool-tuning →

实验 1:核心 2、最大 4、队列 10,一口气提交 100 条通知,每条 20 ms

策略发出被拒 / 丢提交 100 条用了全部发完丢的是哪些谁发的通知
AbortPolicy1486(抛异常)9 ms86 ms15、16、17…(后来的)池里线程
CallerRunsPolicy1000363 ms423 ms-main 和池里线程
DiscardPolicy1486(悄悄)0 ms81 ms15、16、17…(后来的)池里线程
DiscardOldestPolicy1486(悄悄)0 ms80 ms3、4、5…(排了最久的)池里线程

实验 2:核心 2、最大 2,压进 400 条,第 500 ms 在线调到 16

$ java -cp … LiveResize
先 setCorePoolSize(16)(大于当前最大 2):抛 IllegalArgumentException
  时间ms  线程数  活跃  排队  已完成
      0       2     2   398       0
    120       2     2   387      11
    321       2     2   366      32
    523       2     2   346      52
   ↑ 第 523 ms:setMaximumPoolSize(16) 再 setCorePoolSize(16),线程数马上变成 16
    625      16    16   258     126
    728      16    16   172     212
    829      16    16    92     292
    930      16    16    12     372
400 条全部发完:1030 ms(全程 2 个线程要约 4000 ms)

== 优雅关闭 ==
shutdown 之后再提交:RejectedExecutionException
等 500 ms:没做完,已做完 16 个,队列里还有 8 个
shutdownNow:退回没开始的 8 个,被中断的 16 个,做完的共 16 个
到这里已经能交差了:你能用利特尔法则和 CPU 占比算出线程数、用可接受的排队时间算出队列长度,知道四种拒绝策略各把代价给了谁,会在线调大池子、会读监控、会按三步关池子。
已知还没处理的细节:CallerRuns 卡住请求线程(⑥ 坑 1)、拒绝没人知道(⑥ 坑 2)、调参顺序(⑥ 坑 3)、所有业务共用一个池(⑥ 坑 4)、关机丢任务(⑥ 坑 5)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1请求线程提交异步任务,又配了 CallerRuns← ④-2 代价给提交方
现象
通知服务一慢,下单接口跟着变慢,Tomcat 线程被占满。⑤:提交 100 条从 9 ms 变成 363 ms
根源
满了以后由提交的线程自己执行任务,而提交的是 Tomcat 的请求线程。异步就变回了同步。
做法
请求链路上的异步池用“记录 + 补发”的自定义策略;CallerRuns 留给后台批处理这种“慢一点没关系”的提交方。
坑 2Discard 类策略,或者 AbortPolicy 的异常被吞掉← ④-2 悄悄丢
现象
用户说没收到通知,日志里什么都没有。⑤:100 条丢了 86 条,没有任何输出
根源
Discard 什么都不做;AbortPolicy 的异常抛给了 execute 的调用方,调用方又在 CompletableFuture 或 @Async 里被吞了。
做法
任何拒绝都要有日志和计数,接告警。
坑 3在线调参顺序反了← ④-3 先最大后核心
现象
配置中心推了新参数,池子没变,日志里一个 IllegalArgumentException。⑤ 实测
根源
JDK 9 起 setCorePoolSize 要求不大于最大数,setMaximumPoolSize 要求不小于核心数。
做法
调大:先最大后核心;调小:先核心后最大;写成一个方法统一处理。
坑 4所有业务共用一个池子← ④-1 参数从各自的流量算
现象
一个下游(比如短信服务)变慢,占满了公共池,订单、积分、日志的异步任务全被拒。
根源
不同任务的耗时和重要性不一样,挤在一起,慢的会把快的拖死。Spring 的 @Async 不指定执行器时就是共用一个(@Async 页)。
做法
按下游分池(舱壁),各自算参数、各自监控;CompletableFuture 一定传自己的池(下一个知识点)。
坑 5发版时丢了池子里的任务← ④-5 三步关闭
现象
每次发版后都有一小批通知没发出去。
根源
进程直接退出,或者只调了 shutdownNow 又没处理它的返回值;工作线程如果是守护线程,JVM 根本不等。
做法
关闭钩子里按 shutdown → awaitTermination → shutdownNow 走,退回的任务落库;Spring Boot 配 server.shutdown=graceful,ThreadPoolTaskExecutor 配 setWaitForTasksToCompleteOnShutdown(true) 和 setAwaitTerminationSeconds。
第二圈

使用规范

手动 new ThreadPoolExecutor

七个参数全部写明,不用 Executors 的工厂方法(阿里巴巴开发手册强制)。

无界队列、无上限线程。

参数写进配置、能在线改

核心、最大从配置中心读,变更时按 ④-3 的顺序调;队列要能调就用可变容量的队列。

高峰时只能发版。

每个池子有名字、有指标、有告警

活跃数、排队数、拒绝数接监控;排队时间超过阈值告警。

坑 2:满了没人知道。

按下游分池

一个下游一个池子,参数按它自己的 QPS 和耗时算。

坑 4。

第三圈

深处:Tomcat、Spring 和虚拟线程

Tomcat 的 200 是怎么来的

Spring Boot 默认 server.tomcat.threads.max=200。按 ④-1:如果一个请求平均 50 ms、几乎都在等数据库,200 个线程大约能扛每秒 4000 个请求;8 核、每个请求 2 ms 计算时,CPU 上限是 200 个线程,两者刚好对上。请求慢到 500 ms,200 个线程就只够每秒 400 个。Tomcat 的池先加线程再排队,所以它的“队列”其实是连接数上限(8192)和 accept-count(全景第 2 站、预测题 1)。

Spring 的 ThreadPoolTaskExecutor

它包着一个 ThreadPoolExecutor。Spring Boot 自动建的那个(给 @Async 和 Spring MVC 异步用)默认核心 8、队列和最大线程数都不限,通过 spring.task.execution.pool.* 改,关机等待用 spring.task.execution.shutdown.*(按 Spring Boot 文档)。Boot 3.2 起开 spring.threads.virtual.enabled=true 会换成虚拟线程的执行器。

虚拟线程:不要池化,改成限并发

虚拟线程很便宜,线程池存在的两个理由(复用线程、限制线程数)里第一个没了。JDK 21 的写法是每个任务一个虚拟线程(Executors.newVirtualThreadPerTaskExecutor());需要限制同时访问下游的数量时,用 Semaphore 做舱壁,而不是用一个固定大小的池子。按 JEP 444 的说明。

面试官会怎么问

线程池的参数怎么设置?

先估流量:线程数 ≥ 每秒任务数 × 平均耗时(利特尔法则),且不超过核数 ÷ 计算占比;队列 = 每秒任务数 × 可接受排队时间;再压测、上线后看监控在线调(④-1、④-3)。

四种拒绝策略分别是什么?你们用哪个?

Abort 抛异常、CallerRuns 提交方执行、Discard 丢新的、DiscardOldest 丢最老的。请求链路一般自定义:记日志、计数、写消息队列补发(④-2、坑 1、坑 2)。

线程池运行中能改参数吗?

能改核心、最大、空闲时间;调大先最大后核心,调大核心会按积压数预建线程;队列容量改不了,要换可变容量的队列(④-3)。

怎么优雅地关闭线程池?

shutdown 停止接收,awaitTermination 等一段时间,没完再 shutdownNow 中断并拿回未开始的任务(④-5)。

下一圈

↺回到⑤:改造你的案例

1. 写一个“记录 + 补发”的拒绝策略

给 Overload 加第五种:拒绝时把通知编号放进一个 ConcurrentLinkedQueue 并计数,池子空了以后再补发。对比发出数和提交耗时。

用到:④-2、坑 1、坑 2

2. 用计算器算 LiveResize 的参数

每秒 400 条、每条 20 ms、计算 0.1 ms、2 核、可等 500 ms,算出核心和队列,改掉 LiveResize 开头的 2 / 2 / 1000,看还用不用在线调。

用到:④-1

3. 给关闭加上补发

把 shutdownNow() 返回的任务和被中断的任务都记下来,打印它们的编号;再把任务改成不响应中断(catch 后继续睡),看 shutdownNow 之后多久才真正停。

用到:④-5、坑 5

自测

1. 用一句话说出这一圈的精华。

参数用“每秒多少个 × 每个多久”和 CPU 占比算出来,队列按能接受的排队时间定;满了的代价要按任务性质选好给谁;参数要能在线改、有监控,关闭要按三步来。

2. 参数拍脑袋、队列无界,疼在哪?

线程多了抢 CPU,少了排长队;队列无界时下游一慢就堆到 OOM;配错了只能发版(②)。

3. 请求线程配 CallerRuns 让接口变慢,根源是 ④ 的哪一条?

④-2:CallerRuns 把满了的代价给了提交方,提交方是请求线程,异步就变回了同步。

4. 预测:每秒 200 条通知、每条 50 ms,核心 10、最大 20、队列 400。下游变慢成每条 500 ms,几秒后会发生什么?

需要的线程数变成 200 × 0.5 = 100,远超 20。队列先满(20 个线程每秒只能处理 40 条,每秒净积压 160 条,2.5 秒左右满 400),然后开始拒绝;排在队尾的通知要等 400 ÷ 40 = 10 秒才轮到。先看到排队数和拒绝数上涨,再看到用户投诉通知延迟(④-1、④-4)。

5. 预测:把实验 2 的调大顺序写反(先 setCorePoolSize(16)),之后会怎样?

第一行抛 IllegalArgumentException,参数没变;如果代码 catch 了异常继续 setMaximumPoolSize(16),最大变成 16、核心还是 2,而队列(容量 1000)没满,execute 不会多建线程,积压照旧(④-3、上一圈的 execute 顺序)。

6. 放回全景:第 10 站的通知池满了,用 DiscardOldestPolicy。第 11 站的响应和用户会看到什么?

响应完全正常,下单接口不受影响;但排了最久的通知被悄悄丢掉,有些用户收不到下单通知,而且日志里什么都没有。换成“记录 + 补发”的策略,或者把通知放进消息队列(J08)。