4、线程池设计与实践:ThreadPoolExecutor的源码分析、核心参数调优
4.1 线程池,到底解决了什么问题?
说实话,很多新手写多线程,上来就是 new Thread(() -> { ... }).start()。这种做法在小 demo 里没问题,但到了生产环境,我见过太多因为乱建线程把系统搞垮的案例。
线程池的核心价值,说白了就两点:复用线程 和 控制并发。你想想看,每次请求都新建一个线程,操作系统光忙着创建销毁就累死了。而且线程数一多,CPU 上下文切换的开销会吃掉你的性能。
我个人习惯,只要涉及多线程任务,第一反应就是上线程池。别自己造轮子,JDK 给的 ThreadPoolExecutor 已经足够强大。
4.2 ThreadPoolExecutor 源码核心:七个参数
先看构造函数,这是最全的那个版本:
public ThreadPoolExecutor(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler)
七个参数,每个都有讲究。我一个个说。
| 参数 | 含义 | 调优建议 |
|---|---|---|
| corePoolSize | 核心线程数 | CPU 密集型:N+1;IO 密集型:2N |
| maximumPoolSize | 最大线程数 | 一般不超过核心数的 2~4 倍 |
| keepAliveTime | 非核心线程空闲存活时间 | 默认 60 秒,短任务可调小 |
| workQueue | 任务队列 | 有界队列优先,防止 OOM |
| threadFactory | 线程工厂 | 一定要给线程命名,方便排查 |
| handler | 拒绝策略 | 默认抛异常,可自定义 |
核心流程:
- 线程数 < corePoolSize → 新建线程执行
- 线程数 ≥ corePoolSize → 任务入队
- 队列满 & 线程数 < maximumPoolSize → 新建非核心线程
- 队列满 & 线程数 ≥ maximumPoolSize → 执行拒绝策略
4.3 源码里那些容易被忽略的细节
我读源码时,发现 ThreadPoolExecutor 用了一个 AtomicInteger 来同时保存线程池状态和线程数量。高 3 位存状态,低 29 位存数量。为什么这么设计?
因为要保证这两个字段的修改是原子的。你想想看,如果分开两个变量,加锁开销太大,不用锁又会出现线程池状态变了但线程数没更新的情况。用一个 int 搞定,CAS 操作一步到位。
再看 execute() 方法的核心逻辑:
public void execute(Runnable command) {
int c = ctl.get();
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
if (! isRunning(recheck) && remove(command))
reject(command);
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
else if (!addWorker(command, false))
reject(command);
}
注意那个 recheck。为什么入队成功还要再检查一次?
嗯,这里要注意。任务入队后,线程池可能在这期间被 shutdown 了。如果不重新检查,任务就永远卡在队列里没人执行。我曾经在线上遇到过类似问题,就是因为没处理好这个竞态条件。
4.4 核心参数调优:我的实战经验
调优这事,没有银弹。但我可以分享几个原则。
4.4.1 核心线程数怎么定?
如果是 CPU 密集型任务,比如视频编解码、加密解密,线程数建议设为 CPU核心数 + 1。为什么加 1?因为总有线程因为缺页中断或其他原因被挂起,多一个线程能利用这个空档。
如果是 IO 密集型任务,比如网络请求、数据库读写,线程数可以设大一些。我一般用 2 * CPU核心数 作为起点,然后通过压测往上调。
我的小技巧: 用 Runtime.getRuntime().availableProcessors() 获取核心数,别写死。不同机器部署时自动适配。
4.4.2 队列选型:有界还是无界?
我个人强烈建议用 有界队列。无界队列看起来方便,但一旦任务生产速度超过消费速度,队列会无限膨胀,最终 OOM。我在项目中遇到过,一个定时任务因为下游接口变慢,队列里积压了上千万个任务,直接堆内存爆了。
常用的有界队列:
ArrayBlockingQueue:基于数组,有界,公平性可配置LinkedBlockingQueue:基于链表,默认无界,但可以设容量SynchronousQueue:不存任务,直接交给线程,适合高并发短任务
4.4.3 拒绝策略怎么选?
JDK 提供了四种:
AbortPolicy:抛异常,默认策略CallerRunsPolicy:调用者线程自己执行DiscardPolicy:直接丢弃,不抛异常DiscardOldestPolicy:丢弃队列里最老的任务
我曾经在支付系统中用过 CallerRunsPolicy。当线程池满时,让主线程自己执行任务,相当于一种背压机制。这样不会丢任务,但会拖慢生产者,间接降低任务提交速度。
避坑指南: 我曾经在日志系统中用了 DiscardPolicy,结果线上排查问题时发现关键日志丢了。后来改成了自定义策略,把被拒绝的任务写入本地文件,等系统空闲再回放。
4.5 线程池状态流转:一张图看懂
线程池有 5 种状态,流转关系如下:
4.6 监控线程池:别等出事了才看
线程池运行期间,一定要加监控。我常用的指标:
- 活跃线程数:
getActiveCount() - 队列积压:
getQueue().size() - 已完成任务数:
getCompletedTaskCount() - 拒绝次数:自定义计数器,在拒绝策略里加
我习惯把这些指标暴露给 Prometheus,配上 Grafana 看板。一旦队列积压超过阈值,立刻告警。别等到 OOM 了才去查日志,那时候已经晚了。
总结一句话: 线程池调优没有标准答案,但 有界队列 + 自定义拒绝策略 + 实时监控 这三板斧,能帮你挡住 90% 的坑。