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 拒绝策略 默认抛异常,可自定义

核心流程:

  1. 线程数 < corePoolSize → 新建线程执行
  2. 线程数 ≥ corePoolSize → 任务入队
  3. 队列满 & 线程数 < maximumPoolSize → 新建非核心线程
  4. 队列满 & 线程数 ≥ 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 种状态,流转关系如下:

RUNNING SHUTDOWN STOP TIDYING TERMINATED shutdown() shutdownNow() 队列 & 线程都空 terminated() 执行完毕 说明:RUNNING → SHUTDOWN(不再接收新任务,处理完已有任务) 说明:RUNNING → STOP(立即停止,不处理队列任务)

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

线程池运行期间,一定要加监控。我常用的指标:

  • 活跃线程数:getActiveCount()
  • 队列积压:getQueue().size()
  • 已完成任务数:getCompletedTaskCount()
  • 拒绝次数:自定义计数器,在拒绝策略里加

我习惯把这些指标暴露给 Prometheus,配上 Grafana 看板。一旦队列积压超过阈值,立刻告警。别等到 OOM 了才去查日志,那时候已经晚了。

总结一句话: 线程池调优没有标准答案,但 有界队列 + 自定义拒绝策略 + 实时监控 这三板斧,能帮你挡住 90% 的坑。