主题
线程池原理与调优
一句话:
ThreadPoolExecutor用「核心线程 → 队列 → 非核心线程 → 拒绝策略」四道闸门复用线程、限制并发;代价是队列会吞掉背压,参数配错时表现为延迟暴涨而不是立刻报错。
适用 JDK 17 / JDK 21。JDK 21 的虚拟线程不是线程池,见文末边界。
为什么需要
每次请求 new Thread() 的成本不只是创建对象:操作系统线程栈默认约 1 MB,线程创建和调度要进内核,频繁创建会放大上下文切换。线程池把线程复用起来,同时给并发数一个上限。
但线程池不是「线程越多越好」。下游只有 20 个连接时,池里 200 个线程只会把等待时间变成排队和超时。线程池的真正职责是把无限的需求挡在有限资源前面,并决定挡不住时怎么失败。
核心机制
execute 的真实顺序
很多资料把流程记成「核心满了进队列,队列满了扩到最大」。这只对了一部分,准确顺序是:
workerCount < corePoolSize:直接addWorker(command, true)创建核心线程执行,不看队列。- 否则尝试
workQueue.offer(command);入队成功后重新检查运行状态,必要时补偿一个 worker。 - 队列满,且
workerCount < maximumPoolSize:addWorker(command, false)创建非核心线程。 - 仍失败:交给
RejectedExecutionHandler。
由此得到两个反直觉结论:
- 核心线程不是构造时创建,也不是队列满才创建。默认
prestartAllCoreThreads()不调用,第一个任务到来才创建;但在达到corePoolSize之前,每个新任务都会倾向新建核心线程。 - 无界队列会让
maximumPoolSize失效。LinkedBlockingQueue不设容量时,第 3 步永远到不了,池最多只有corePoolSize个线程。
队列与最大线程的算术
以 core=2, max=4, queue=2 为例:
| 到达任务 | 去向 | 运行线程数 | 队列长度 |
|---|---|---|---|
| 1 | 创建核心线程 | 1 | 0 |
| 2 | 创建核心线程 | 2 | 0 |
| 3 | 入队 | 2 | 1 |
| 4 | 入队 | 2 | 2 |
| 5 | 队列满,创建非核心线程 | 3 | 2 |
| 6 | 创建非核心线程 | 4 | 2 |
| 7 | 拒绝 | 4 | 2 |
所以容量不是 max + queue 的简单相加:第 5 个任务不会排队,而是直接开新线程;只有队列满了才轮到 max。
拒绝策略
| 策略 | 行为 | 适用 |
|---|---|---|
AbortPolicy(默认) | 抛 RejectedExecutionException | 必须让调用方感知失败 |
CallerRunsPolicy | 调用线程自己执行;池已 shutdown 则丢弃 | 能接受反压、希望自然限速 |
DiscardPolicy | 静默丢弃 | 可丢的遥测、日志 |
DiscardOldestPolicy | 丢队列头,再重试提交 | 只保留最新值的场景 |
CallerRunsPolicy 不是「异步降级」,它会让提交任务的线程阻塞。Web 请求线程被拿去跑任务时,吞吐会下降,但这正是背压生效的表现。
实战场景
一、CallerRunsPolicy 把任务退回 Tomcat 线程,接口集体超时
现象:异步导出线程池用 core=8, max=16, queue=200 + CallerRunsPolicy。大促时队列打满,之后提交任务的 HTTP 线程开始自己执行导出(每个 20~40 秒),Tomcat 200 个线程被逐步占光,连不依赖导出的查询接口也开始超时。监控只显示“线程池没有拒绝”,看起来一切正常。
排查:拒绝策略不是“失败处理”,它会改变执行线程。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 谁在执行任务 | 任务日志里的线程名:http-nio-8080-exec-* 还是 export-* | 出现容器线程即命中回压 |
| 池与队列水位 | activeCount、queue.size、completedTaskCount 增速 | 队列长期满说明容量与速率不匹配 |
| 拒绝计数 | 自定义计数器统计 RejectedExecutionException | 用 CallerRuns 时永远为 0,掩盖饱和 |
| 上游超时 | HTTP 超时 vs 任务耗时 | 任务在 HTTP 线程里跑必然吃满超时预算 |
| 是否有背压 | 提交方是否知道“现在不能提交” | 有界队列 + 明确失败才是可控背压 |
execute 的顺序决定了第 5 个任务不会排队而是直接开非核心线程(core=2, max=4, queue=2 的例子里第 7 个才被拒绝);CallerRunsPolicy 的语义是“让调用线程自己执行”,用于天然限流,但在 Web 容器线程上提交任务就等于把任务塞进请求线程。
对症做法:
java
// 1. Web 线程只负责“受理”,不接受执行任务
try {
exportPool.submit(task);
return ResponseEntity.accepted().body(new AcceptDto(taskId)); // 立即返回受理号
} catch (RejectedExecutionException e) {
metrics.rejected("export");
return ResponseEntity.status(429).body(new RetryDto(3)); // 明确失败,交给客户端退避
}
// 2. 队列要能观测,饱和要能定位
ThreadPoolExecutor pool = new ThreadPoolExecutor(8, 16, 60, SECONDS,
new ArrayBlockingQueue<>(200),
new NamedThreadFactory("export"),
new AbortPolicy()); // 拒绝就失败,不退回调用线程
Gauge.builder("pool.queue", pool, p -> p.getQueue().size()).register(registry);
Gauge.builder("pool.active", pool, ThreadPoolExecutor::getActiveCount).register(registry);text
3. 确实需要“反压上游”时,用信号量或限流器在入口挡住,而不是让请求线程干活:
tryAcquire 失败直接返回 429,行为可预测、耗时可控。
4. CallerRunsPolicy 只适合“调用线程就是批处理主线程、且任务本身可重入”的场景
(例如离线任务自限流),放在线程模型是请求线程的 Web 层就是事故。复盘要点:判据是“业务线程池的任务永远只在池内线程执行”——线程名与池名不一致即报警。拒绝计数必须存在且有值:如果 rejected == 0 而队列长期满载,说明用了把失败藏起来的策略。prestartAllCoreThreads() 不调用时核心线程也是首个任务才创建,所以冷启动阶段的线程创建开销也要计入观测。
二、CPU 核数 ×2 的公式套在 IO 密集服务上
现象:调外部接口的聚合服务把线程池设成 core=max=CPU×2(8 核机器 16 线程)。日常够用,一旦下游 P99 从 300ms 涨到 1.5s,本服务 QPS 上限从 500 掉到 100,大量请求在队列里等待,接口 P99 直接顶到超时。GC 正常,CPU 只有 25%。
排查:线程数的正确口径是等待与计算的比例,不是核数的倍数。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 线程在干什么 | 线程栈采样:多少线程停在 socketRead/park | IO 等待占比高即 CPU 不是瓶颈 |
| 理论吞吐 | threads = cores × (1 + 等待时间 / 计算时间) | 等待 1200ms、计算 30ms → 需要远大于 16 |
| 队列等待时间 | 任务从提交到开始的时延分布 | 与端到端 P99 对齐即为排队主导 |
| 下游连接池 | HTTP 客户端连接数上限 | 线程加多了也没用,连接池才是上限 |
| 上下文切换 | vmstat 的 cs、jstack 线程数 | 盲目放大线程数会把开销转成切换 |
| 下游承受力 | 目标服务 QPS 与容量 | 扩容线程等于放大对下游的冲击 |
对症做法:
java
// 1. 按等待/计算比算初值,再用压测收敛(本例:1200/30 → 41 倍,取 32~64 并压测)
int cores = Runtime.getRuntime().availableProcessors();
int poolSize = (int) (cores * (1 + waitMs / computeMs)); // 初值,不是结论
// 2. IO 密集优先换异步客户端或减少同步等待,而不是无限加线程
// 与 [IO 与 NIO](06-IO与NIO.md) 的边界一致:一个请求一个线程模型有上限
// 3. 线程数与下游连接池、下游容量一起调,三者错配时先看最小的那个
httpclient.setMaxConnTotal(200);
httpclient.setMaxConnPerRoute(80);properties
# 4. 隔离比调线程数更重要:慢依赖独立池 + 有界队列 + 快速失败
downstream.coupon.pool.size=16
downstream.coupon.timeout.connect=300
downstream.coupon.timeout.read=800复盘要点:判据是“队列等待时间 P99 占端到端 P99 的比例”,比例高说明瓶颈在排队而不是计算;这个数字比“线程数设多少”更有指导意义。同时把“线程数 × 单请求下游占用 ≤ 下游容量”写进容量表,避免上游调优变成下游压垮。
三、submit 吞异常,任务失败三天无人知
现象:对账作业用 ExecutorService.submit() 提交,内部 try 只包了一半逻辑。某段代码抛异常后,日志里没有任何痕迹,future 也从没被 get()。三天后对账差异被发现,回溯发现那三天的作业其实每天都在第二次调用前异常终止。
排查:submit 把异常存进 Future,不 get 就永远不上抛;execute 未捕获异常会终止该 worker(并由池补一个新线程)并走 UncaughtExceptionHandler。
| 定位层 | 取证据 | 判定 |
|---|---|---|
| 提交方式 | submit 还是 execute | submit 且忽略返回值 → 异常被吞 |
| 是否消费结果 | 有没有 future.get() / allOf(...).join() | 从不消费即无异常可见 |
| 线程死亡记录 | 默认处理器只打 stderr;容器里 stdout 是否被采集 | 日志未采集等于没有 |
| 任务完成计数 | completedTaskCount 是否按预期增长 | 计数不涨即任务未跑完 |
| 事务/幂等 | 半途失败是否留下中间状态 | 三天差异的放大原因 |
| 关闭行为 | 停机是否 shutdown + awaitTermination | 未等待导致在途任务被丢 |
对症做法:
java
// 1. 异步任务必须消费结果,或在任务内部自己兜住并上报
List<Future<?>> fs = new ArrayList<>();
for (Job job : jobs) fs.add(pool.submit(job));
for (Future<?> f : fs) {
try { f.get(5, MINUTES); }
catch (ExecutionException e) {
metrics.jobFailed(rootCauseOf(e));
log.error("job failed", e.getCause()); // 关键:打真实原因
} catch (TimeoutException e) { f.cancel(true); metrics.jobTimeout(); }
}
// 2. 给线程池装统一的异常出口,别依赖默认 stderr
ThreadPoolExecutor pool = new ThreadPoolExecutor(...);
pool.setThreadFactory(r -> {
Thread t = new Thread(r, "recon-" + seq.incrementAndGet());
t.setUncaughtExceptionHandler((th, ex) -> { metrics.uncaught(th.getName()); log.error("uncaught in {}", th.getName(), ex); });
return t;
});java
// 3. 停机要排空,否则在途任务被丢弃且无记录
@PreDestroy
void drain() throws InterruptedException {
pool.shutdown();
if (!pool.awaitTermination(30, SECONDS)) {
metrics.dropped(pool.shutdownNow().size()); // 被中断的任务数要能看见
}
}复盘要点:判据是“submit 出来的任务必须有对应的结果消费点”,可以用静态检查(submit( 的返回值被丢弃即报警)。比检查更根本的是把“任务成功/失败/超时”做成指标:对账作业这类后台任务,缺计数就等于缺监控,异常吞掉后能静默很多天。这与 Spring 事务管理 里“异常被 catch 后事务照提交”是同一类缺陷:失败没有出口。
最小上手
下面的程序用 core=1, max=2, queue=1 触发拒绝,并让调用线程执行第四个任务:
java
import java.util.concurrent.*;
public class RejectDemo {
public static void main(String[] args) throws Exception {
CountDownLatch hold = new CountDownLatch(1);
ThreadPoolExecutor pool = new ThreadPoolExecutor(
1, 2, 0L, TimeUnit.MILLISECONDS,
new ArrayBlockingQueue<>(1),
new ThreadPoolExecutor.CallerRunsPolicy());
Runnable task = () -> {
System.out.println("run on " + Thread.currentThread().getName());
try { hold.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
new Thread(() -> { try { Thread.sleep(500); } catch (InterruptedException ignored) {}
hold.countDown(); }, "releaser").start();
pool.execute(task); // 核心线程
pool.execute(task); // 入队
pool.execute(task); // 非核心线程
pool.execute(task); // 队列满、已达 max,调用线程执行
pool.shutdown();
}
}bash
javac RejectDemo.java && java RejectDemo
# 会看到 main 线程也打印 run on main把 CallerRunsPolicy 换成 AbortPolicy,第四个任务会抛 RejectedExecutionException;换成无界 LinkedBlockingQueue,第三个任务不会创建非核心线程,第四个也会一直排队。
生产动作
- 不要直接用
Executors的快捷工厂:newFixedThreadPool用无界LinkedBlockingQueue,newCachedThreadPool的maximumPoolSize=Integer.MAX_VALUE,都可能把故障拖成 OOM。手动new ThreadPoolExecutor,显式给队列容量。 - 参数没有通用默认值:
ThreadPoolExecutor构造器没有默认配置;keepAliveTime默认只作用于超过核心数的线程,allowCoreThreadTimeOut=false(JDK 8+)。设置true后核心线程也会超时回收。 - 队列是有界的:
ArrayBlockingQueue(capacity)或LinkedBlockingQueue(capacity)。队列长度要按「可接受排队时间 × 处理速率」估算,不要拍一个 10000。 - 监控四个数:
getPoolSize()、getActiveCount()、getQueue().size()、getCompletedTaskCount()。队列持续增长而完成数不涨,说明下游或线程数有问题,不是「再加线程」能解决。 - 线程命名:自定义
ThreadFactory带上业务名和序号,jstack和日志才能定位;同时设置未捕获异常处理器。 - 优雅关闭:先
shutdown()拒绝新任务,再awaitTermination,超时才shutdownNow();shutdownNow()返回未执行任务,并尝试中断运行中的线程。 - ThreadLocal 清理:线程池复用线程,
ThreadLocal不remove()会把上一个请求的值带进下一个请求;在finally中清理,或改用显式传参。
边界
- 线程池不解决下游容量:池大小超过数据库连接池、HTTP 连接池或下游 QPS 上限,只会把排队从池里挪到下游。
- 无界队列不是「不丢任务」:它把拒绝变成延迟和内存增长,故障来得更晚、更难查。
- 虚拟线程不是线程池:JDK 21 的
Executors.newVirtualThreadPerTaskExecutor()为每个任务创建虚拟线程,不适合 CPU 密集型任务;JDK 21 中synchronized阻塞可能 pin 住载体线程,JDK 24+ 才由 JEP 491 处理。 CallerRunsPolicy可能拖垮调用方:Web 线程、RPC 线程被拿去执行长任务时,要先确认调用链能承受。- 调参不能替代容量规划:先看 QPS、P99、下游配额,再用池参数表达约束;只调
maximumPoolSize通常是在掩盖问题。
讲清楚
线程池的提交顺序是核心线程、队列、非核心线程、拒绝策略。核心线程在任务到来时按需创建,不是构造时创建,也不是队列满才创建;无界队列会让最大线程数永远用不上,这是
Executors.newFixedThreadPool最常见的坑。队列是背压的缓冲,不是无限的仓库。生产上要手动建池、给有界队列、命名线程、监控活跃数和队列长度,并选一个业务能接受的拒绝策略。
CallerRunsPolicy让调用方自己执行,能自然限速,但也会阻塞调用线程。JDK 21 的虚拟线程改变了 IO 密集场景的成本结构,但它不适合 CPU 密集任务,也不是传统线程池的简单替代。