简单聊聊线程池源码

简单聊聊线程池源码

线程池是面试高频题,但大多数人只能背出"核心线程、最大线程、队列、拒绝策略"四个名词。

与其背结论,不如自己按 JDK ThreadPoolExecutor 的核心执行流程手写一个迷你版

MyThreadPool,一行一行对照源码,聊明白它到底在干什么。


0. 线程池到底在解决什么问题

一句话:避免频繁创建/销毁线程。

new Thread().start() 是一次系统调用,底层要分配内核资源,创建快则几十微秒、慢则毫秒级;如果任务很短,线程创建销毁的开销甚至比任务本身还大。线程池的思路是:

  1. 线程复用------一批工作线程长期存活,反复执行任务;
  2. 任务缓冲------任务太多、线程不够时,先排进队列;
  3. 流量控制------核心数 / 最大数 / 队列 / 拒绝策略,四件套把"可接受的并发度"框住。

MyThreadPool 完整复刻了这三件事。下面按"一个任务从提交到执行完"的完整链路讲。

1. 全景图:execute() 的三步提交

任务提交只有一个入口,JDK 里的核心逻辑是著名的 三步提交 (对应源码的 execute 方法):

java 复制代码
public void execute(Runnable command) {
    ...
    int c = ctl.get();

    // 步骤1:线程数 < 核心线程数 → 直接新建线程干活
    if (workerCountOf(c) < corePoolSize) {
        if (addWorker(command, true)) return;
        c = ctl.get();
    }

    // 步骤2:线程数够了 → 尝试把任务放队列
    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);
    }

    // 步骤3:队列也满了 → 新建非核心线程;还不行就拒绝
    else if (!addWorker(command, false)) {
        reject(command);
    }
}

画成流程图就是:

复制代码
       提交任务
          │
    ┌─────▼─────┐
    │线程数<核心数?│──是──▶ 新建核心线程执行 ══▶ 结束
    └─────┬─────┘
          │否
    ┌─────▼─────┐
    │池在运行?    │──是──▶ 任务入队(offer)
    └─────┬─────┘        │
          │否/入队失败     ├─ 入队后状态变了?→ 撤销+拒绝
          │               └─ 入队成功但没线程了?→ 补一个兜底线程
    ┌─────▼─────┐
    │新建非核心线程? │──成功──▶ 执行
    └─────┬─────┘
          │失败
    ┌─────▼─────┐
    │  拒绝策略   │
    └───────────┘

两个细节值得停下来看:

  • 步骤2 的双重检查 :任务 offer 进队列之后、还没来得及返回,线程池可能被 shutdown 了。所以重新读一次 ctl,如果状态变了,把刚入队的任务撤销 并走拒绝策略;如果状态没变但 workerCount == 0(核心线程全部退化为 0,比如被异常弄死了),补一个空任务的线程去把队列消费掉。
  • 步骤2 的入队先于建线程 :线程数够了之后,先入队,而不是先建线程------这就是"队列是缓冲"的体现,只有队列满才动最大线程。

2. ctl:一个 int 打包"状态 + 线程数"

线程池有状态线程数 两个会并发变化的东西。JDK 没有用两个 volatile 变量,而是用一个 AtomicInteger 打包:

java 复制代码
// 高 3 位存状态,低 29 位存线程数
private static final int COUNT_BITS = Integer.SIZE - 3;   // 29
private static final int CAPACITY   = (1 << COUNT_BITS) - 1;

private static final int RUNNING    = -1 << COUNT_BITS;   // 负数
private static final int SHUTDOWN   = 0;                  // 0
private static final int STOP       = 1 << COUNT_BITS;    // 正数

private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));

为什么这么干?

  1. 原子性 :状态和线程数经常要一起改 (比如"线程数 +1 且状态不变"只是一次 CAS)。拆成两个变量就得保证跨变量的原子性,麻烦还容易出 bug;打成一个 int,一次 compareAndSet 同时搞定;
  2. 状态比较方便 :让 RUNNING < SHUTDOWN < STOP 数值递增,于是"是否还在运行"一行位运算就能判断:
java 复制代码
private static int runStateOf(int c)     { return c & ~CAPACITY; }  // 取高3位
private static int workerCountOf(int c)  { return c & CAPACITY; }   // 取低29位
private static boolean isRunning(int c)  { return c < SHUTDOWN; }   // 负数=运行中

(迷你版只留了三个状态;JDK 还有 TIDYING、TERMINATED,用于优雅关闭。)

3. addWorker:带着 retry 标签的双重自旋

新建线程是"先抢名额、再真建线程"两段式。抢名额这段,JDK 写得极其讲究,是一个双 for 循环 + retry 标签的结构:

java 复制代码
private boolean addWorker(Runnable firstTask, boolean core) {
    retry:
    for (;;) {                       // 外层:检查状态
        int c = ctl.get();
        int rs = runStateOf(c);

        if (rs >= SHUTDOWN && !(rs == SHUTDOWN && firstTask == null && !workQueue.isEmpty())) {
            return false;            // 状态闸门
        }

        for (;;) {                   // 内层:CAS 抢线程名额
            int wc = workerCountOf(c);
            if (wc >= CAPACITY || wc >= (core ? corePoolSize : maximumPoolSize)) {
                return false;        // 到上限了
            }
            if (ctl.compareAndSet(c, ctlOf(rs, wc + 1))) {
                break retry;         // 抢到了,跳出两层
            }
            c = ctl.get();           // CAS 失败,重读
            if (runStateOf(c) != rs) {
                continue retry;      // 状态变了,回外层重新判断
            }
        }
    }

    Worker w = new Worker(firstTask);  // 抢到名额后才真正建线程
    ...
    w.thread.start();
    return true;
}

为什么需要两层?

  • 内层 CAS 抢线程数名额,并发下必然有失败,失败了要重试;
  • 重试时状态可能已经变了 (比如期间被 shutdown),如果还按老状态抢,就白抢甚至抢错,所以要 continue retry 回外层重新读状态;
  • retry 标签 就是"跳出内层循环回到外层"的 goto 式跳转,break retry 则是抢成功后直接跳出两层。

状态闸门那行 (rs >= SHUTDOWN && !(rs == SHUTDOWN && firstTask == null && !workQueue.isEmpty()))是整个线程池最精妙的一行,它是"拒绝新任务,但允许清尾":

  • 池已进入 SHUTDOWN 之后,一律不再接受带任务的线程 (firstTask != null 就是收新活);
  • 唯一放行的例外:处于 SHUTDOWN 且不带新任务、且队列里还有任务 的"补位线程"------用来把 shutdown 前入队的任务清完。

4. Worker 与 runWorker:线程复用

工作线程不是"一个任务一个线程",而是一个 Worker 包一个 Thread,runWorkerwhile 循环反复拉任务:

java 复制代码
private final class Worker implements Runnable {
    final Thread thread;
    Runnable firstTask;               // 第一个任务,可能为 null(纯补位线程)

    Worker(Runnable firstTask) {
        this.firstTask = firstTask;
        this.thread = threadFactory.newThread(this);   // 把自身当 Runnable 交给工厂
    }
    public void run() { runWorker(this); }
}

final void runWorker(Worker w) {
    Runnable task = w.firstTask;
    w.firstTask = null;
    while (task != null || (task = getTask()) != null) {  // 先干首任务,再循环拉队列
        try {
            task.run();               // 注意:是 run(),不是 start()
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            task = null;              // 清空引用,进下一次循环
        }
    }
    workers.remove(w);                // 没任务了,线程退出
    decrementWorkerCount();
}

关键点:

  • task.run()直接调用方法 ,不是 start()------所以这就是"复用一个线程"的本质:同一个 Thread,通过循环把多个 Runnable 一个个 run 掉;
  • 循环退出条件 = getTask() 返回 null,即"拿不到任务了";
  • 线程退出前把 Worker 从集合里移除、线程数减一------否则会"死线程泄漏"。

5. getTask:核心线程阻塞,非核心线程超时回收

getTask() 决定一个线程"等任务"还是"退出":

java 复制代码
private Runnable getTask() {
    boolean timedOut = false;
    for (;;) {
        int c = ctl.get();
        int rs = runStateOf(c);

        if (rs >= STOP || (rs >= SHUTDOWN && workQueue.isEmpty())) {
            decrementWorkerCount();     // 池停了 → 线程退出
            return null;
        }

        int wc = workerCountOf(c);
        boolean timed = wc > corePoolSize;    // 超过核心数的线程走"超时"

        if ((wc > maximumPoolSize || (timed && timedOut))
                && (wc > 1 || workQueue.isEmpty())) {
            if (ctl.compareAndSet(c, ctlOf(rs, wc - 1))) {
                return null;            // 超时无任务 → 回收
            }
            continue;
        }

        try {
            Runnable r = timed
                    ? workQueue.poll(keepAliveTime, TimeUnit.MILLISECONDS)  // 带超时等
                    : workQueue.take();                                    // 一直阻塞等
            if (r != null) return r;
            timedOut = true;            // poll 超时返回 null,标记"该回收了"
        } catch (InterruptedException e) {
            timedOut = false;
        }
    }
}

三个设计点:

  1. 核心 vs 非核心的差别就在一个 timed 标志 :wc > corePoolSize 的线程用 poll(keepAliveTime) 等,超过存活时间没任务就返回 null → 线程退出被回收 ;核心线程用 take() 无限阻塞,所以核心线程"默认不回收";
  2. timedOut 标志配合 keepAliveTime 的两次轮询 :poll 超时返回 null 后不会立刻退出,而是置 timedOut = true,下一次循环判断 timed && timedOut 成立才回收------这是 keepAliveTime 语义的精确还原;
  3. 最后保留一个线程 :回收时加了 (wc > 1 || workQueue.isEmpty()) 保护------哪怕该回收,也不能把最后一个线程也收掉,否则队列里的任务没人消费。

6. 参数与拒绝策略

构造函数七个参数,对应调优的全部旋钮:

参数 作用 面试常问
corePoolSize 核心线程数,不回收 核心线程会被回收吗?------不会(默认),除非 allowCoreThreadTimeOut(true)
maximumPoolSize 最大线程数 什么时候动它?------队列满之后才建非核心线程
keepAliveTime 非核心线程空闲存活时间 回收条件就是 getTask 里的超时逻辑
workQueue 任务缓冲队列 选型:有界 vs 无界,ArrayBlockingQueue vs LinkedBlockingQueue vs SynchronousQueue
threadFactory 线程工厂 一定要起名字,否则排查问题抓瞎
handler 拒绝策略 见下

JDK 自带四种拒绝策略:

策略 行为 适用
AbortPolicy(默认) 直接抛 RejectedExecutionException 宁丢任务也不静默,适合明确失败
CallerRunsPolicy 让提交者自己跑 天然背压,不丢任务
DiscardPolicy 静默丢弃 允许丢
DiscardOldestPolicy 丢弃队头最老任务,再试提交 偏向新鲜任务

7. 诚实说明:迷你版和 JDK 的差距

MyThreadPool 复刻的是执行主路径,有意识地砍掉了一些东西:

  • 没有完整的关闭状态机 (shutdown()/awaitTermination()、TIDYING/TERMINATED、中断逻辑);
  • 没有 beforeExecute/afterExecute 钩子(JDK 用它做监控、清理 ThreadLocal);
  • 没有对 Worker 加锁(JDK 的 Worker 还带锁,用于中断安全);
  • 异常处理简化成 printStackTrace(生产应该记录日志并打点)。

核心执行路径------三步提交、ctl 打包、addWorker 自旋、runWorker 复用、getTask 超时回收------与 JDK 完全一致。把这五个点讲清楚,再去啃 JDK 源码,剩下的都是增量。

8. 结语

线程池看似是一个"池",本质是**"状态机 + 队列 + 工作线程循环"**三件事的协作:

  • ctl 用位运算把状态和线程数压进一个 int,换来原子性和可比较性;
  • execute 用三步提交实现"先线程、再队列、最后扩容"的渐进策略;
  • Worker + runWorker 用 while 循环实现线程复用;
  • getTask 用 take/poll 区分核心与非核心线程的生命周期。

把这四个点吃透,线程池的源码面试基本就过关了。完整代码见 MyThreadPool.java,也可以对照 JDK 的 ThreadPoolExecutor 逐行读,体会 Doug Lea 的位运算功力。

相关推荐
NGINX开源社区6 小时前
NGINX Ingress Controller 5.5:安全性与性能提升,迁移更轻松
java·服务器·nginx
云烟成雨TD7 小时前
Micrometer 系列【25】Spring Boot Actuator | Spring MVC、Spring WebFlux 指标
java·spring boot·micrometer
liudashuang20177 小时前
Kafka Producer 隐藏深坑:Sender 线程自阻塞(自死锁)导致 BufferExhaustedException 完整复盘
java·分布式·kafka·linq
风筱7 小时前
Idea的CC GUI插件安装Claude Code SDK失败
java·ide·ai编程
m0_527034337 小时前
异步任务审核系统设计:消息队列、超时重试与失败补偿
java·大数据·开发语言
xbgRS7 小时前
springBoot项目配置加载优先级
java·spring boot
zzzll11117 小时前
Loop Engineering:循环工程的原理、实践与应用
java·数据库·python
假客套7 小时前
记一次若依 Excel 导出踩坑:Permission denied、401 报错完整排查记录
java·若依·linux服务器
岁岁养乐多8 小时前
深入解析 Spring @Cacheable 注解:从声明式缓存 到多维替代方案
java
开发者联盟league8 小时前
maven核心插件介绍
java·maven