简单聊聊线程池源码
线程池是面试高频题,但大多数人只能背出"核心线程、最大线程、队列、拒绝策略"四个名词。
与其背结论,不如自己按 JDK
ThreadPoolExecutor的核心执行流程手写一个迷你版
MyThreadPool,一行一行对照源码,聊明白它到底在干什么。
0. 线程池到底在解决什么问题
一句话:避免频繁创建/销毁线程。
new Thread().start() 是一次系统调用,底层要分配内核资源,创建快则几十微秒、慢则毫秒级;如果任务很短,线程创建销毁的开销甚至比任务本身还大。线程池的思路是:
- 线程复用------一批工作线程长期存活,反复执行任务;
- 任务缓冲------任务太多、线程不够时,先排进队列;
- 流量控制------核心数 / 最大数 / 队列 / 拒绝策略,四件套把"可接受的并发度"框住。
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 且状态不变"只是一次 CAS)。拆成两个变量就得保证跨变量的原子性,麻烦还容易出 bug;打成一个 int,一次
compareAndSet同时搞定; - 状态比较方便 :让
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,runWorker 里 while 循环反复拉任务:
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;
}
}
}
三个设计点:
- 核心 vs 非核心的差别就在一个
timed标志 :wc > corePoolSize的线程用poll(keepAliveTime)等,超过存活时间没任务就返回 null → 线程退出被回收 ;核心线程用take()无限阻塞,所以核心线程"默认不回收"; timedOut标志配合keepAliveTime的两次轮询 :poll超时返回 null 后不会立刻退出,而是置timedOut = true,下一次循环判断timed && timedOut成立才回收------这是keepAliveTime语义的精确还原;- 最后保留一个线程 :回收时加了
(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 的位运算功力。