3. 线程池陷阱:线程数量与上下文切换的平衡

线程池这东西,用好了是利器,用不好就是灾难。我见过太多项目,一上来就开几百个线程,结果CPU全耗在上下文切换上,业务吞吐量反而上不去。说白了,线程池的核心就一个问题:到底开多少个线程才合适?

3.1 线程多了为什么反而慢?

很多人觉得线程越多处理越快,这是个误区。你想想看,CPU核心数是固定的,比如8核16线程。当活跃线程数超过CPU能同时处理的数量时,操作系统就得频繁做上下文切换——保存当前线程的寄存器、栈指针,加载下一个线程的上下文。这个切换本身就有开销。

上下文切换的成本:一次切换大约消耗 1-10 微秒。如果每秒切换几万次,光切换就吃掉 10% 以上的 CPU。

我在项目中遇到过这样一个案例:一个网关服务,线程池配了 200 个线程,QPS 只有 3000。后来压测发现 CPU 的上下文切换次数高达每秒 15 万次。把线程数降到 64 后,QPS 反而涨到了 8000。嗯,这就是典型的线程过多导致性能反降。

3.2 线程池大小计算公式

业内有个经典公式,我习惯叫它「布赖恩·戈茨公式」。它把任务分成两类:CPU密集型和IO密集型。

任务类型 计算公式 说明
CPU密集型 Nthreads = Ncpu + 1 计算为主,多开一个线程应对缺页中断
IO密集型 Nthreads = Ncpu * (1 + W / C) W=等待时间,C=计算时间

公式里的 W/C 是关键。比如一个请求,计算花了 10ms,等待数据库返回花了 90ms,那 W/C = 9。8核机器上,线程数 = 8 * (1 + 9) = 80。这个数字比随便拍脑袋定的 200 要靠谱得多。

我的经验:实际项目中,W/C 很难精确测量。我一般先用公式算个基准值,然后压测微调。从基准值的 50% 开始往上加,观察 CPU 利用率和响应时间的变化。

3.3 核心线程数 vs 最大线程数

Java 的 ThreadPoolExecutor 有 corePoolSize 和 maximumPoolSize 两个参数。很多人搞不清它们的区别。我简单解释一下:

  • 核心线程数:池子里常驻的线程,即使空闲也不回收
  • 最大线程数:池子能扩到的上限,超过这个数任务就进队列或拒绝

那什么时候从核心扩到最大?当工作队列满了的时候。举个例子:

// 一个典型的IO密集型线程池配置
ThreadPoolExecutor executor = new ThreadPoolExecutor(
    16,          // corePoolSize
    32,          // maximumPoolSize
    60L,         // keepAliveTime
    TimeUnit.SECONDS,
    new LinkedBlockingQueue<Runnable>(1024),  // 有界队列
    new ThreadPoolExecutor.CallerRunsPolicy()  // 拒绝策略
);

这里有个陷阱:队列大小和最大线程数要配合着调。队列太小,线程频繁扩容缩容;队列太大,任务积压导致响应延迟飙升。我个人习惯把队列设成最大线程数的 32 倍左右,再根据压测结果调整。

3.4 拒绝策略的选择

线程池满了、队列也满了,新任务怎么办?Java 提供了四种策略:

策略 行为 适用场景
AbortPolicy 直接抛异常 不允许丢任务,但能接受异常
CallerRunsPolicy 调用线程自己执行 降低任务提交速度,反压上游
DiscardPolicy 静默丢弃 允许丢非关键任务
DiscardOldestPolicy 丢弃队列中最旧的任务 追求最新数据的场景

我曾经踩过的坑:线上用 AbortPolicy,结果突发流量一来,线程池满了直接抛 RejectedExecutionException,客户端收到 500 错误。后来改成 CallerRunsPolicy,让 Netty 的 IO 线程帮忙处理任务,虽然响应时间变长了,但至少不会丢请求。这个反压机制其实挺优雅的。

3.5 线程池监控:别等出事了才看

线程池运行得好不好,不能靠猜。我习惯给每个线程池加上监控指标:

  • 活跃线程数:当前正在工作的线程数
  • 队列深度:积压的任务数量
  • 完成任务数:累计处理了多少任务
  • 拒绝次数:触发了多少次拒绝策略

把这些指标暴露给 Prometheus 或 Grafana,设置告警阈值。比如队列深度超过 80% 就报警,说明线程数可能不够了。

3.6 一张图看懂线程池调优

下面这张图总结了线程池调优的核心逻辑,从任务类型分析到最终参数确定:

线程池调优决策流程 线程池调优决策流程 分析任务类型 CPU密集型还是IO密集型? N = CPU核心数 + 1 N = CPU * (1 + W/C) 设置核心/最大线程数 + 队列 压测验证 + 监控指标 调整参数,直到性能达标 监控指标:活跃线程、队列深度、拒绝次数

3.7 避坑指南

最后总结几个我踩过的坑,希望能帮你少走弯路:

  • 不要用无界队列:LinkedBlockingQueue 不设上限,任务积压会撑爆内存。我曾经见过一个服务因为无界队列导致 OOM,整个进程被 kill 掉。
  • 线程名称要自定义:给线程池的线程起个有意义的名字,比如 "db-pool-thread-1"。出问题的时候,jstack 一看就知道是哪个池子在搞鬼。
  • 小心线程池嵌套:一个请求里如果 A 线程池提交任务到 B 线程池,B 满了又依赖 A 释放资源,很容易死锁。我习惯用全局线程池隔离不同业务,避免交叉依赖。
  • 动态调整:Java 的 ThreadPoolExecutor 支持 setCorePoolSize 和 setMaximumPoolSize 动态调整。线上可以通过配置中心实时修改,不用重启服务。

一句话总结:线程池调优没有银弹。公式给个起点,压测给个终点,监控贯穿始终。别信什么「最佳实践」的固定数字,你的业务场景才是唯一的真理。