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 一张图看懂线程池调优
下面这张图总结了线程池调优的核心逻辑,从任务类型分析到最终参数确定:
3.7 避坑指南
最后总结几个我踩过的坑,希望能帮你少走弯路:
- 不要用无界队列:LinkedBlockingQueue 不设上限,任务积压会撑爆内存。我曾经见过一个服务因为无界队列导致 OOM,整个进程被 kill 掉。
- 线程名称要自定义:给线程池的线程起个有意义的名字,比如 "db-pool-thread-1"。出问题的时候,jstack 一看就知道是哪个池子在搞鬼。
- 小心线程池嵌套:一个请求里如果 A 线程池提交任务到 B 线程池,B 满了又依赖 A 释放资源,很容易死锁。我习惯用全局线程池隔离不同业务,避免交叉依赖。
- 动态调整:Java 的 ThreadPoolExecutor 支持 setCorePoolSize 和 setMaximumPoolSize 动态调整。线上可以通过配置中心实时修改,不用重启服务。
一句话总结:线程池调优没有银弹。公式给个起点,压测给个终点,监控贯穿始终。别信什么「最佳实践」的固定数字,你的业务场景才是唯一的真理。