利特尔法则 · 线程数 · 队列长度 · 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 优雅关闭三步 |
②没有它会怎样:参数拍脑袋、满了才发现
- 线程数拍脑袋:CPU 密集的活开 200 个线程,互相抢核、切换(线程页实验 1:512 个线程慢 36%);等下游的活只开 8 个,CPU 闲着、请求排长队。
- 队列不设上限:
Executors.newFixedThreadPool的队列是 21 亿,下游一慢就一直堆,最后 OOM(上一页坑 4)。 - 拒绝策略用默认的:AbortPolicy 抛异常,调用方没 catch,下单接口直接 500。
- 配错了只能改代码发版,而流量高峰时最不该发版。
- 缺的那一块:参数从流量推出来,满了的代价提前选好,还能在线改、看得见。
不懂这一点时的典型误用:给通知池配 CallerRunsPolicy,觉得“不丢任务最安全”。实测 池子满了以后,提交通知的线程自己去发通知,提交 100 条从 9 ms 变成 363 ms。在全景里提交方是 Tomcat 的 exec 线程:下单接口每次都多卡一个通知的时间(⑤)。
③相邻方案:同一件事的几种做法
| 要做的 | 做法 | 说明 |
|---|---|---|
| 配参数 | 经验值:核数 × 2、核数 + 1 | 只适用于纯计算或“一半一半”的活 |
| 按流量算:利特尔法则 + 等待比例 | 本页 ④-1 | |
| 压测找拐点 | 最终都要做,算出来的数是起点 | |
| 满了 | 拒绝策略(四种 + 自定义) | 本页 ④-2 |
| 在池子前面限流(Semaphore、令牌桶、Sentinel) | 不让任务进池子(AQS 下一圈 ④-3、J12) | |
| 换成消息队列 | 任务落盘,不怕进程重启(J08) | |
| 改参数 | 改配置、重启 | 简单,但要发版 |
运行中调 setCorePoolSize / setMaximumPoolSize | 配合配置中心推送,即“动态线程池”(美团技术团队 2020 年的文章让这个做法流行起来) |
选择规则:参数先算、再压测;满了先想“能不能丢”,能丢的选 Discard,不能丢又不能慢的放消息队列,宁可慢也不能丢的才用 CallerRuns;参数要能在线改、要有监控。本页学怎么算、四种策略、在线改、监控和关闭。
④原理:从流量推参数,满了的代价转给谁
④-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 新的 | 排了最久的那个任务 | 只关心最新状态(行情推送、进度刷新) |
- 1自己实现
RejectedExecutionHandler更常见:记一条日志和一个指标,再把任务写进消息队列或数据库稍后补发。拒绝不该是悄悄发生的。 - 2CallerRuns 不是“不丢”:池子已经 shutdown 时它也直接丢(源码第一行就是
if (!e.isShutdown()))。
④-3运行中改大小:顺序和效果
- 1调大:先
setMaximumPoolSize,再setCorePoolSize。JDK 9 起核心数不能大于最大数,顺序反了直接抛IllegalArgumentException(实测)。调大核心时,源码会立刻按“队列里有几个任务”预先建线程,所以积压的任务马上开始被消化,不用等下一次 execute。 - 2调小:先核心后最大。多出来的线程不会被杀,源码只是
interruptIdleWorkers()叫醒空闲的线程,让它们在下一次取任务时发现“超了”而自己退出;正在干活的线程干完才走。 - 3队列长度改不了:LinkedBlockingQueue、ArrayBlockingQueue 的容量是 final 的。想在线调队列,要换成一个容量能改的自定义队列(动态线程池框架都这么做)。
④-4监控:看这五个数
| 指标 | 方法 | 说明什么 |
|---|---|---|
| 活跃线程 | getActiveCount() | 正在跑任务的线程数,长期等于最大值就是不够用了 |
| 排队数 | getQueue().size() | 积压;除以每秒完成数就是“新任务大概要等多久” |
| 线程数 / 历史最大 | getPoolSize()、getLargestPoolSize() | 有没有扩到最大过 |
| 完成数 | getCompletedTaskCount() | 两次采样相减就是吞吐 |
| 拒绝数 | 自定义拒绝策略里计数 | JDK 不提供,必须自己记 |
getActiveCount、getCompletedTaskCount 要拿池子的 mainLock、遍历所有 Worker,是近似值,别每毫秒调一次。Spring Boot 的 Actuator 会给它自己建的任务执行器注册 executor.* 指标(Micrometer 的 ExecutorServiceMetrics),自己 new 的池子要手动绑定。
④-5优雅关闭:三步
- 1
shutdown():不再接新任务(再提交就走拒绝策略),队列里的和正在跑的继续做完。 - 2
awaitTermination(超时):等它们做完,最多等这么久,返回是否全部结束。 - 3
shutdownNow():给所有工作线程发中断,返回队列里还没开始的任务。正在跑的任务只有响应中断才会停(线程页坑 2);退回来的任务要自己记下来补发。
④-6为什么这样设计:把“满了”交给调用方决定
JDK:有界 + 可插拔的拒绝策略ThreadPoolExecutor
- 池子只负责发现“满了”,满了以后丢、等、还是转给别人,由业务按任务的性质选
- CallerRuns 用最朴素的方式实现了背压:谁提交得快,谁就自己去干活
- 参数可以在运行中改,池子本身不需要重建
- 默认的 AbortPolicy 要求调用方处理异常,很多人没处理
被放弃的:永远接、永远不满无界队列,或者无上限的线程
- 调用方永远不会失败,写起来最省事
- 失败只是被推迟了:内存一路涨到 OOM,或者线程无限多把机器拖死
- 问题出现的时候,离真正的原因(下游变慢)已经很远
决定胜负的约束:资源一定是有限的。池子早晚会满,区别只在于“满了”这件事是在设计时被想好,还是在 OOM 时被发现。JDK 给了有界的工具和四种默认答案,但哪个对,只有业务知道。
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 条用了 | 全部发完 | 丢的是哪些 | 谁发的通知 |
|---|---|---|---|---|---|---|
| AbortPolicy | 14 | 86(抛异常) | 9 ms | 86 ms | 15、16、17…(后来的) | 池里线程 |
| CallerRunsPolicy | 100 | 0 | 363 ms | 423 ms | - | main 和池里线程 |
| DiscardPolicy | 14 | 86(悄悄) | 0 ms | 81 ms | 15、16、17…(后来的) | 池里线程 |
| DiscardOldestPolicy | 14 | 86(悄悄) | 0 ms | 80 ms | 3、4、5…(排了最久的) | 池里线程 |
- 1池子一次最多容纳 2(核心)+ 10(队列)+ 2(扩到最大)= 14 条,所以三种丢弃类的策略都只发出 14 条。区别只在丢谁、告不告诉你:Abort 抛异常,Discard 悄悄丢新的,DiscardOldest 悄悄丢老的(它留下的是最后进来的那 10 条)。
- 2CallerRuns 一条不丢,代价是提交方 main 自己发了不少通知,提交 100 条从 9 ms 变成 363 ms,慢了 40 倍。放到全景里,就是第 10 站把通知的耗时加回到了下单请求上。
实验 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 个
- 1调大的那一刻,线程数直接从 2 变 16,不用等新任务来:
setCorePoolSize里按队列里的任务数预先建了线程。每 100 ms 完成数从约 10 涨到约 85,约 8 倍。 - 2关闭:40 个 300 ms 的任务,16 个线程第一批做完 16 个,第二批 16 个刚开始就被 shutdownNow 中断,队列里剩下的 8 个原样退回。实际生产里,这 24 个都要记下来补发。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 通知服务一慢,下单接口跟着变慢,Tomcat 线程被占满。⑤:提交 100 条从 9 ms 变成 363 ms
- 根源
- 满了以后由提交的线程自己执行任务,而提交的是 Tomcat 的请求线程。异步就变回了同步。
- 做法
- 请求链路上的异步池用“记录 + 补发”的自定义策略;CallerRuns 留给后台批处理这种“慢一点没关系”的提交方。
- 现象
- 用户说没收到通知,日志里什么都没有。⑤:100 条丢了 86 条,没有任何输出
- 根源
- Discard 什么都不做;AbortPolicy 的异常抛给了 execute 的调用方,调用方又在 CompletableFuture 或 @Async 里被吞了。
- 做法
- 任何拒绝都要有日志和计数,接告警。
- 现象
- 配置中心推了新参数,池子没变,日志里一个
IllegalArgumentException。⑤ 实测 - 根源
- JDK 9 起 setCorePoolSize 要求不大于最大数,setMaximumPoolSize 要求不小于核心数。
- 做法
- 调大:先最大后核心;调小:先核心后最大;写成一个方法统一处理。
- 现象
- 每次发版后都有一小批通知没发出去。
- 根源
- 进程直接退出,或者只调了 shutdownNow 又没处理它的返回值;工作线程如果是守护线程,JVM 根本不等。
- 做法
- 关闭钩子里按 shutdown → awaitTermination → shutdownNow 走,退回的任务落库;Spring Boot 配
server.shutdown=graceful,ThreadPoolTaskExecutor 配setWaitForTasksToCompleteOnShutdown(true)和setAwaitTerminationSeconds。
使用规范
七个参数全部写明,不用 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)。
- 可变容量的阻塞队列:照着 LinkedBlockingQueue 改一个
setCapacity,接配置中心做完整的动态线程池 - ScheduledThreadPoolExecutor 和 DelayedWorkQueue:固定频率任务的“漂移”(上一圈列过)
- ForkJoinPool:工作窃取和 CompletableFuture 的默认池(下一个知识点会用到)
- Micrometer 给线程池注册指标,Grafana 画排队时间(J13)
- 按“排队时间”而不是“排队个数”拒绝:给任务打上入队时间,超时的直接丢(J12)
↺回到⑤:改造你的案例
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)。