跳到正文

线程池原理与调优 ​

一句话:ThreadPoolExecutor 用「核心线程 → 队列 → 非核心线程 → 拒绝策略」四道闸门复用线程、限制并发;代价是队列会吞掉背压,参数配错时表现为延迟暴涨而不是立刻报错。

适用 JDK 17 / JDK 21。JDK 21 的虚拟线程不是线程池,见文末边界。

为什么需要 ​

每次请求 new Thread() 的成本不只是创建对象:操作系统线程栈默认约 1 MB,线程创建和调度要进内核,频繁创建会放大上下文切换。线程池把线程复用起来,同时给并发数一个上限。

但线程池不是「线程越多越好」。下游只有 20 个连接时,池里 200 个线程只会把等待时间变成排队和超时。线程池的真正职责是把无限的需求挡在有限资源前面,并决定挡不住时怎么失败。

核心机制 ​

execute 的真实顺序 ​

很多资料把流程记成「核心满了进队列,队列满了扩到最大」。这只对了一部分,准确顺序是:

  1. workerCount < corePoolSize:直接 addWorker(command, true) 创建核心线程执行,不看队列。
  2. 否则尝试 workQueue.offer(command);入队成功后重新检查运行状态,必要时补偿一个 worker。
  3. 队列满,且 workerCount < maximumPoolSize:addWorker(command, false) 创建非核心线程。
  4. 仍失败:交给 RejectedExecutionHandler。

由此得到两个反直觉结论:

  • 核心线程不是构造时创建,也不是队列满才创建。默认 prestartAllCoreThreads() 不调用,第一个任务到来才创建;但在达到 corePoolSize 之前,每个新任务都会倾向新建核心线程。
  • 无界队列会让 maximumPoolSize 失效。LinkedBlockingQueue 不设容量时,第 3 步永远到不了,池最多只有 corePoolSize 个线程。

队列与最大线程的算术 ​

以 core=2, max=4, queue=2 为例:

到达任务去向运行线程数队列长度
1创建核心线程10
2创建核心线程20
3入队21
4入队22
5队列满,创建非核心线程32
6创建非核心线程42
7拒绝42

所以容量不是 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/parkIO 等待占比高即 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 还是 executesubmit 且忽略返回值 → 异常被吞
是否消费结果有没有 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 密集任务,也不是传统线程池的简单替代。

把一门技术讲透到既懂也会用