JDK 21 · THREADPOOLEXECUTOR · CTL · ADDWORKER · RUNWORKER · GETTASK · WORKER · MAINLOCK
三个 if、一个 int、一把不可重入的锁
execute 只有三个分支:核心名额没满,就开线程,把任务直接交给它;否则放进队列,放完再看一眼池子还开着没有、还有没有线程;放不进就开临时线程,开不了就拒绝。池子的运行状态和线程数挤在一个 int(ctl)里,所以“看状态、占名额”一次 CAS 就能做完。线程要退出时,也会回头看一眼队列。两边各查一次,任务就不会卡在没人取的队列里。
这是下一圈线程池“先补核心、再排队、再加临时线程、最后拒绝”的四步规则,在 Spring @Async 与线程池 讲过,还有模拟器。这一页接着往下:execute 的源码为什么是这三个 if,线程怎么被造出来、怎么取任务、怎么退出。所以 ①~⑤ 都很短,篇幅给 ⑥ 的源码和实验台。假设场景是面试被追问“讲讲 execute 的源码”,以及线上遇到几件只有看源码才说得清的怪事。源码以 JDK 21u 为准,标“实测”的在本机跑过:JDK 11.0.32 和 21.0.12。
①为什么需要:四步规则背不出来的几件事
只记四步规则,下面这几件事都解释不了。它们都是本页 ⑥ 实测过的:
10 个任务进来,自始至终只有 1 个线程在干活。第 1 步跳过、第 3 步走不到,那这 1 个线程是谁建的?
线程名从 pool-1-thread-1 变成了 pool-1-thread-2。换成 submit 提交,异常却连日志都没有。
关池以后提交的 100 个任务,一个都没跑,也没有异常。
可实测里先建的那个线程被回收了,留下来的是后建的“临时线程”。
②没有它会怎样:每个任务一个新线程
不用线程池,每来一个任务就 new Thread(task).start():建线程、销毁线程都要进内核,突发流量时线程数没有上限。线程池的四步规则就是在“复用线程”和“限住总数”之间找平衡,旧页有完整的图和模拟器,这里不重讲。
③相邻方案:JDK 里还有哪些执行器
| 执行器 | 线程从哪来 | 任务放哪 | 适合 |
|---|---|---|---|
ThreadPoolExecutor | 按核心数、最大数造,闲久了回收 | 你给的 BlockingQueue | 一般的业务异步、IO 任务 |
ForkJoinPool | 固定并行度,每个线程一个双端队列,空闲的去偷别人的 | 各线程自己的队列 | 能拆分的计算任务;parallelStream、CompletableFuture 默认用它 |
Executors.newVirtualThreadPerTaskExecutor() | 每个任务一个虚拟线程,不复用 | 不排队 | JDK 21 起,大量阻塞 IO 的任务 |
Spring ThreadPoolTaskExecutor | 里面就是一个 ThreadPoolExecutor | 同左 | Spring 里配置更方便,规则完全一样 |
本页学 ThreadPoolExecutor:Spring 的线程池、Executors.newFixedThreadPool、newCachedThreadPool 背后都是它,execute 是所有任务的入口。
④原理:四步规则落到源码里是什么样
四步规则在 旧页讲过:先补满核心线程,再排队,队列满了加临时线程,再不行就拒绝。这一圈先认识源码里撑起这四步的几样东西,再看三个 if 怎么排。
这一圈要用到的四样东西
ctl
一个 AtomicInteger,高 3 位是池子的运行状态,低 29 位是线程数。看状态、改线程数都是对它做 CAS。
Worker
一个工作线程的“外壳”:里面有真正的 Thread、一个 firstTask(建它时顺手带来的第一个任务),自己还是一把锁。
workers + mainLock
所有 Worker 放在一个普通的 HashSet 里,进出都要先拿 mainLock(一把 ReentrantLock)。
workQueue
你给的阻塞队列。工作线程空闲时停在它的 take() 或 poll(keepAlive) 上等任务。
机制 1execute 的三个 if
机制 2一个 int 装两样东西
-1 << 29,是负数,其余状态依次变大,所以“还在跑吗”就是 c < SHUTDOWN,“至少停到哪一步”也是一次整数比较。shutdown() 把状态改成 SHUTDOWN(不再收新任务,队列里的照跑),shutdownNow() 改成 STOP(还要打断正在跑的、退回队列里的);线程数归零、队列也空了,经过 TIDYING 到 TERMINATED。机制 3一个工作线程的一生
机制 4两边各查一次:任务不会卡在没人取的队列里
wc > 1 || workQueue.isEmpty())。⑥ 的实验台和随机测试会拆掉其中一边看看会怎样。为什么这样设计:状态和线程数必须一起 CAS
被放弃的:两个变量runState 一个、workerCount 一个
- addWorker 要做的是“还在 RUNNING、名额没满,才 +1”;tryTerminate 要做的是“已经关了、线程数是 0,才进 TIDYING”。两件事都是“看两个值,再改其中一个”
- 两个变量各自 CAS,挡不住这样的交错:一边刚判定可以终止,另一边同时把线程数 +1,后面只能靠别处再补救
- 想不交错,就得每次都拿一把锁,提交任务、线程退出都要排队
JDK:一个 ctl,一次 CAScompareAndIncrementWorkerCount(c)
- CAS 的期望值 c 里同时带着状态和线程数:状态一变,+1 就失败;线程数一变,进 TIDYING 的 CAS 也失败。两件事最多成功一件
- 失败了就重读 ctl 再判断,不用拿锁
- 代价:线程数上限从 2^31 − 1 降到 2^29 − 1。源码注释说真不够用时改成 AtomicLong 就行,现在用 int 更快更简单
决定胜负的约束:“加线程”和“关池子”必须互斥,又不想让每次提交、每次线程退出都去抢锁。把两样东西塞进一个 int,一次 CAS 就同时检查了两样。这个 CAS 只管名额;真正建线程、放进 workers,是之后在 mainLock 里做的,锁里还会再查一次状态,算第二道保险。
最少的 API
var pool = new ThreadPoolExecutor(
2, 4, // corePoolSize, maximumPoolSize
60, TimeUnit.SECONDS, // keepAliveTime:线程数超过核心数时,闲多久回收
new ArrayBlockingQueue<>(100),// workQueue:有界
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy());
pool.execute(task); // 没有返回值,异常会让线程退出
Future<?> f = pool.submit(task); // 包成 FutureTask 再 execute,异常装进 Future
pool.shutdown(); // 不收新任务,队列里的照跑
pool.awaitTermination(30, TimeUnit.SECONDS);
pool.close(); // JDK 19 起:shutdown + 一直等到结束
异常怎么传、拒绝策略在关池后怎么表现、怎么监控、各版本改了什么,放到 第⑥步。
⑤亲眼看到:三个 if、插队、ctl 的位
核心 2、最大 4、队列容量 2,连续提交 8 个各跑 300 ms 的任务。每次提交后打印线程数和队列长度,再用反射读出 ctl 的 32 位。最后顺带验证 ① 里“核心数 0 只有 1 个线程”。
import java.lang.reflect.Field;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
// 看 execute 的三步判断、任务的实际执行顺序、ctl 的位
// 运行:java --add-opens java.base/java.util.concurrent=ALL-UNNAMED TpePeek.java
public class TpePeek {
static Field CTL;
static String ctl(ThreadPoolExecutor p) throws Exception {
int c = ((AtomicInteger) CTL.get(p)).get();
String[] names = {"RUNNING", "SHUTDOWN", "STOP", "TIDYING", "TERMINATED"};
int rs = c >> 29; // 高 3 位
String bits = String.format("%32s", Integer.toBinaryString(c)).replace(' ', '0');
return bits.substring(0, 3) + " " + bits.substring(3) + " → " + names[rs + 1] + ",线程数 " + (c & ((1 << 29) - 1));
}
public static void main(String[] a) throws Exception {
CTL = ThreadPoolExecutor.class.getDeclaredField("ctl"); CTL.setAccessible(true);
var started = Collections.synchronizedList(new ArrayList<String>());
var pool = new ThreadPoolExecutor(2, 4, 60, TimeUnit.SECONDS, new ArrayBlockingQueue<>(2));
System.out.println("新建: " + ctl(pool));
for (int i = 1; i <= 8; i++) {
final int id = i;
String how;
try {
pool.execute(() -> { started.add("#" + id); sleep(300); });
how = "线程 " + pool.getPoolSize() + ",队列 " + pool.getQueue().size();
} catch (RejectedExecutionException e) { how = "被拒绝"; }
System.out.printf("execute(#%d) → %s%n", i, how);
}
System.out.println("此刻: " + ctl(pool));
sleep(1000);
System.out.println("开跑顺序:" + started);
pool.shutdown();
System.out.println("shutdown 后: " + ctl(pool));
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("全部结束: " + ctl(pool));
// core = 0 + 无界队列:只会有 1 个线程
var zero = new ThreadPoolExecutor(0, 10, 60, TimeUnit.SECONDS, new LinkedBlockingQueue<>());
long t0 = System.nanoTime();
for (int i = 0; i < 10; i++) zero.execute(() -> sleep(100));
zero.shutdown(); zero.awaitTermination(10, TimeUnit.SECONDS);
System.out.printf("core=0、max=10、无界队列:最多同时 %d 个线程,10 个 100 ms 的任务用了 %d ms%n", zero.getLargestPoolSize(), (System.nanoTime() - t0) / 1_000_000);
}
static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { } }
}
$ java --add-opens java.base/java.util.concurrent=ALL-UNNAMED TpePeek.java
新建: 111 00000000000000000000000000000 → RUNNING,线程数 0
execute(#1) → 线程 1,队列 0 ← 第 ① 个 if:开核心线程
execute(#2) → 线程 2,队列 0
execute(#3) → 线程 2,队列 1 ← 第 ② 个 if:排队
execute(#4) → 线程 2,队列 2
execute(#5) → 线程 3,队列 2 ← 第 ③ 个 if:队列满了,开临时线程
execute(#6) → 线程 4,队列 2
execute(#7) → 被拒绝 ← 第 ③ 个 if 也失败:拒绝
execute(#8) → 被拒绝
此刻: 111 00000000000000000000000000100 → RUNNING,线程数 4
开跑顺序:[#1, #2, #5, #6, #3, #4] ← JDK 11 这次是 [#1, #5, #6, #2, #3, #4]
shutdown 后: 000 00000000000000000000000000100 → SHUTDOWN,线程数 4
全部结束: 011 00000000000000000000000000000 → TERMINATED,线程数 0
core=0、max=10、无界队列:最多同时 1 个线程,10 个 100 ms 的任务用了 1003 ms
| 看到了什么 | 对应 ④ |
|---|---|
| #5、#6 比早就在排队的 #3、#4 先开跑:临时线程的第一个任务不经过队列。#1、#2、#5、#6 之间谁先谁后,看线程启动快慢 | 机制 1 第 ③ 个 if |
高 3 位从 111(RUNNING)变成 000(SHUTDOWN)、011(TERMINATED),低 29 位就是线程数 | 机制 2 |
| 核心数 0、无界队列:10 个任务排队一个个跑,1 秒多 | 机制 1 第 ② 个 if 的复查 |
⑥细节:几件怪事的源码根据,和一行行读
旧页的“常踩的坑”讲的是配置(无界队列让最大线程数失效、拒绝了怎么办等),这里只放旧页没讲、要看源码才说得清的。
注意事项
- 现象
- 核心 0、最大 10、
LinkedBlockingQueue:10 个 100 ms 的任务排着队一个个跑,用了 1003 ms,最多同时 1 个线程。实测 - 根源
- 第 ① 个 if 永远不成立(线程数不会 < 0),第 ② 个 if 永远能放进去,第 ③ 个 if 永远走不到。唯一建线程的地方是放进队列后的复查:“线程数是 0 就补一个”。补了 1 个之后,复查看到的就是 1,再也不补。
- 做法
- 想“空闲时缩到 0、忙时开到 10”,写成核心 10 +
allowCoreThreadTimeOut(true):实测 10 个任务 117 ms 跑完,闲 1.5 秒后线程数回到 0。
- 现象
- 单线程池里一个任务抛异常,控制台打一行
Exception in thread "pool-1-thread-1",下一个任务跑在pool-1-thread-2上。换成submit:线程名不变,什么也不打印,只有调get()才抛ExecutionException。实测 - 根源
- runWorker 不吞异常:调完
afterExecute就原样抛出,线程走到 processWorkerExit,因为是异常退出,一定会补一个新线程。submit先把任务包成FutureTask,它自己 catch 住异常存起来,所以对线程池来说任务“正常结束”。 - 做法
- 任务里自己 try / catch;用
submit就一定要处理返回的 Future;统一记日志可以覆写afterExecute(submit 的任务在这里拿到的 Throwable 是 null,要从 Future 里取)。
- 现象
- 关池后提交 100 个任务:一个没跑,没有异常,没有日志。实测
- 根源
- 它的源码只有一句:
if (!e.isShutdown()) r.run();。关了就什么也不做。默认的AbortPolicy关池后仍会抛RejectedExecutionException。 - 做法
- 停机流程里先停止提交、再关池;或者自己写一个拒绝策略,关池时至少打日志。
- 现象
- 1 个线程、队列里 5 个任务:
shutdown后 6 个全跑完;shutdownNow后正在 sleep 的那个被打断,退回 5 个没跑的。一个不看中断标志的忙循环,shutdownNow之后 1 秒也没结束。实测 - 根源
- shutdown 只中断“空闲”的线程(能
tryLock到 Worker 的,就是没在跑任务的);shutdownNow 中断所有已启动的线程,可中断只是设一个标志,任务自己不检查就停不下来。 - 做法
- 任务里响应中断;关闭按
shutdown→awaitTermination→ 超时再shutdownNow的顺序来。
- 现象
main里用newFixedThreadPool(2)跑一个任务就返回:进程不退出,6 秒后被timeout强行杀掉。实测- 根源
- 核心线程空闲时停在
take()上一直等,它们是非守护线程,JVM 不会退出;Worker 是内部类,持有池子的引用,活着的线程又是 GC Root,所以池子也回收不了。JDK 8 的finalize()会调 shutdown,但根本轮不到它执行;JDK 11 起它是空方法,JDK 27 删掉了。 - 做法
- 在 finally 里关;Spring 管理的池子交给容器关;JDK 19 起
ExecutorService是AutoCloseable,可以写 try-with-resources。
- 现象
- 核心 1、最大 2:先建的 T1 的任务先跑完时,10 次里留下的都是后建的 T2,T1 被回收了。实测
- 根源
- getTask 每次都现算
timed = wc > corePoolSize:两个线程时谁进 getTask 都是“要限时”,谁先闲满 keepAlive 谁走。没有哪个 Worker 被标成核心。 - 做法
- 别在 ThreadLocal 里缓存“只给核心线程用”的东西,也别按线程名判断它是不是核心线程。
- 现象
- 在请求路径上调
getActiveCount()打日志,或者每 100 ms 采一次各种计数,高峰期提交变慢。 - 根源
getPoolSize、getActiveCount、getLargestPoolSize、getTaskCount、getCompletedTaskCount都先拿mainLock再遍历 workers,和建线程、线程退出抢同一把锁。- 做法
- 低频采样(比如 10 秒一次),别放在热路径上;队列长度
getQueue().size()走的是队列自己的实现,不碰 mainLock。
使用规范
不用 Executors.newFixedThreadPool(无界队列)和 newCachedThreadPool(线程无上限)。
任务无限堆积、线程无限增长
不要用“核心 0 + 无界队列”来省线程。
只剩 1 个线程干活(坑 1)
统一兜底可以覆写 afterExecute。
线程被反复换掉、异常悄悄消失(坑 2)
自定义 ThreadFactory,名字带上业务,如 order-notify-3。
jstack 里一堆 pool-7-thread-12,分不清是谁的
shutdown → awaitTermination → shutdownNow;JDK 19 起可以 try-with-resources。
停机丢任务、进程退不出(坑 4、坑 5)
线程数、活跃数、队列长度、拒绝次数,10 秒一次足够。
和建线程、线程退出抢 mainLock(坑 7)
读源码:execute → addWorker → runWorker → getTask → processWorkerExit
1362 int c = ctl.get();1363 if (workerCountOf(c) < corePoolSize) {❶1364 if (addWorker(command, true))❷1365 return;1366 c = ctl.get();❸1367 }1368 if (isRunning(c) && workQueue.offer(command)) {❹1369 int recheck = ctl.get();❺1370 if (! isRunning(recheck) && remove(command))❻1371 reject(command);1372 else if (workerCountOf(recheck) == 0)❼1373 addWorker(null, false);1374 }1375 else if (!addWorker(command, false))❽1376 reject(command);
- 1只读一次 ctl,拿到的是当时的快照,后面所有判断都可能已经过时,所以才有后面的重读和复查。
- 2核心名额没满:把 command 当作新线程的
firstTask,成功就直接返回,任务根本不进队列。 - 3addWorker 失败(名额被别人抢了,或者池子关了),重新读 ctl 再往下走。
- 4还在 RUNNING 才放队列。
offer不阻塞,满了就返回 false。 - 5放进去之后,再读一次 ctl:这就是“复查”。
- 6这期间池子被关了:把任务撤回来再拒绝。
remove失败(已经被线程取走了)就不拒绝,任务照样会跑。 - 7线程数是 0:补一个不带任务的线程,它会去队列里取。核心数 0 的池子,所有线程都是从这里来的。
- 8队列放不进:按最大线程数再开一个,带上这个任务,这就是“插队”;开不了就拒绝。
901 private boolean addWorker(Runnable firstTask, boolean core) {902 retry:903 for (int c = ctl.get();;) {904 // Check if queue empty only if necessary.905 if (runStateAtLeast(c, SHUTDOWN)❶906 && (runStateAtLeast(c, STOP)907 || firstTask != null908 || workQueue.isEmpty()))909 return false;910911 for (;;) {912 if (workerCountOf(c)❷913 >= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))914 return false;915 if (compareAndIncrementWorkerCount(c))❸916 break retry;917 c = ctl.get(); // Re-read ctl❹918 if (runStateAtLeast(c, SHUTDOWN))919 continue retry;920 // else CAS failed due to workerCount change; retry inner loop921 }922 }923924 boolean workerStarted = false;925 boolean workerAdded = false;926 Worker w = null;927 try {928 w = new Worker(firstTask);❺929 final Thread t = w.thread;930 if (t != null) {931 final ReentrantLock mainLock = this.mainLock;932 mainLock.lock();❻933 try {934 // Recheck while holding lock.935 // Back out on ThreadFactory failure or if936 // shut down before lock acquired.937 int c = ctl.get();938939 if (isRunning(c) ||❼940 (runStateLessThan(c, STOP) && firstTask == null)) {941 if (t.getState() != Thread.State.NEW)942 throw new IllegalThreadStateException();943 workers.add(w);❽944 workerAdded = true;945 int s = workers.size();946 if (s > largestPoolSize)947 largestPoolSize = s;948 }949 } finally {950 mainLock.unlock();951 }952 if (workerAdded) {953 container.start(t);❾954 workerStarted = true;955 }956 }957 } finally {958 if (! workerStarted)959 addWorkerFailed(w);❿960 }961 return workerStarted;
- 1池子关了就不许加线程。唯一的例外:SHUTDOWN 时、不带任务、队列又不空,允许补一个线程把队列里剩下的跑完。
- 2名额按
core参数选核心数还是最大数。& COUNT_MASK防止有人把它们设得比 2^29 − 1 还大。 - 3CAS 把线程数 +1:期望值 c 里带着状态,状态变了也会失败。
- 4失败了重读。状态变了回到最外层重新判断能不能加;只是线程数被别人改了,就在内层循环里重比名额。
- 5名额占住了,才 new Worker;构造函数里用 ThreadFactory 建线程,可能失败(返回 null,或者内存不够抛异常)。
- 6拿 mainLock,因为要动 workers 这个普通 HashSet。
- 7锁里再查一次状态:拿锁的这段时间里池子可能被关了。
- 8放进 workers,顺手更新
largestPoolSize。 - 9出锁以后才启动线程。JDK 19 起通过
container.start(t)启动,线程转储会按池子分组(见版本差异)。 - 10没启动成功:
addWorkerFailed把 Worker 移出 workers、线程数 −1,再试着推进终止流程。
1123 final void runWorker(Worker w) {1124 Thread wt = Thread.currentThread();1125 Runnable task = w.firstTask;1126 w.firstTask = null;❶1127 w.unlock(); // allow interrupts❷1128 boolean completedAbruptly = true;1129 try {1130 while (task != null || (task = getTask()) != null) {❸1131 w.lock();❹1132 // If pool is stopping, ensure thread is interrupted;1133 // if not, ensure thread is not interrupted. This1134 // requires a recheck in second case to deal with1135 // shutdownNow race while clearing interrupt1136 if ((runStateAtLeast(ctl.get(), STOP) ||❺1137 (Thread.interrupted() &&1138 runStateAtLeast(ctl.get(), STOP))) &&1139 !wt.isInterrupted())1140 wt.interrupt();1141 try {1142 beforeExecute(wt, task);❻1143 try {1144 task.run();1145 afterExecute(task, null);1146 } catch (Throwable ex) {1147 afterExecute(task, ex);1148 throw ex;❼1149 }1150 } finally {1151 task = null;1152 w.completedTasks++;❽1153 w.unlock();1154 }1155 }1156 completedAbruptly = false;❾1157 } finally {1158 processWorkerExit(w, completedAbruptly);1159 }1160 }
1042 private Runnable getTask() {1043 boolean timedOut = false; // Did the last poll() time out?10441045 for (;;) {1046 int c = ctl.get();10471048 // Check if queue empty only if necessary.1049 if (runStateAtLeast(c, SHUTDOWN)❶1050 && (runStateAtLeast(c, STOP) || workQueue.isEmpty())) {1051 decrementWorkerCount();1052 return null;1053 }10541055 int wc = workerCountOf(c);10561057 // Are workers subject to culling?1058 boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;❷10591060 if ((wc > maximumPoolSize || (timed && timedOut))❸1061 && (wc > 1 || workQueue.isEmpty())) {1062 if (compareAndDecrementWorkerCount(c))❹1063 return null;1064 continue;1065 }10661067 try {1068 Runnable r = timed ?❺1069 workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :1070 workQueue.take();1071 if (r != null)1072 return r;1073 timedOut = true;❻1074 } catch (InterruptedException retry) {1075 timedOut = false;❼1076 }1077 }1078 }
- 1先拿走 firstTask,并把字段清空,免得一直引用着跑完的任务。
- 2Worker 构造时 state 是 −1,这里 unlock 把它改成 0:从这一刻起才允许被中断。
- 3主循环:手上有任务就跑,没有就去 getTask 取,取到 null 就退出循环。
- 4跑任务前锁住自己。shutdown 用
tryLock判断谁空闲,锁着的就不打断。 - 5池子到了 STOP,确保线程被中断;否则清掉残留的中断标志,免得上一个任务的中断影响下一个。
- 6
beforeExecute/afterExecute是留给子类的钩子。 - 7任务抛异常:调完 afterExecute 就原样抛出去,循环结束。
- 8不管成败,完成数 +1、解锁。
- 9正常取到 null 退出时
completedAbruptly是 false;因为异常退出时还是 true,processWorkerExit 就知道要补一个线程。
- 1SHUTDOWN 且队列空、或者已经 STOP:线程数 −1,返回 null。
- 2这次要不要限时等:允许核心超时,或者此刻线程数比核心数多。每次现算,所以没有“固定的核心线程”。
- 3该退了(线程数超过最大数,或者限时等过一轮没等到),但如果自己是最后一个线程、队列又不空,就不退。
- 4CAS 线程数 −1;失败说明 ctl 变了,回到循环开头重新判断。
- 5限时就
poll(keepAliveTime),不限时就take()一直等。 - 6poll 等满了还是 null:记下 timedOut,下一圈循环决定退不退。
- 7被中断(shutdown、调小核心数时会中断空闲线程):不算超时,回去重新判断状态。
997 private void processWorkerExit(Worker w, boolean completedAbruptly) {998 if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted❶999 decrementWorkerCount();10001001 final ReentrantLock mainLock = this.mainLock;1002 mainLock.lock();1003 try {1004 completedTaskCount += w.completedTasks;1005 workers.remove(w);❷1006 } finally {1007 mainLock.unlock();1008 }10091010 tryTerminate();❸10111012 int c = ctl.get();1013 if (runStateLessThan(c, STOP)) {1014 if (!completedAbruptly) {1015 int min = allowCoreThreadTimeOut ? 0 : corePoolSize;❹1016 if (min == 0 && ! workQueue.isEmpty())❺1017 min = 1;1018 if (workerCountOf(c) >= min)❻1019 return; // replacement not needed1020 }1021 addWorker(null, false);❼1022 }1023 }
- 1异常退出时 getTask 没来得及减线程数,这里补减。
- 2拿 mainLock,把自己的完成数加到池子的总数里,再离开 workers。
- 3每个线程退出都试着推进终止:如果池子关了、队列空了、自己是最后一个,就由它把状态推到 TERMINATED。
- 4还该留几个线程:允许核心超时就是 0,否则是核心数。
- 5就算该留 0 个,只要队列不空,至少留 1 个。这就是 ④ 机制 4 里“退出的一边”的那次检查。
- 6线程数够了就不补。
- 7否则补一个不带任务的线程。异常退出时一律补,所以抛异常的线程会被“换掉”。
实验台:你来当线程调度器 先猜,再一步步点
提交线程(S)、工作线程(W)、调用 shutdown 的线程(X)都按上面的源码拆成原子步骤,谁先走由你决定。日志前的行号对应源码。停在 poll(keepAlive) 上的线程可以点“等满 keepAlive”让它超时。
同一套模拟逻辑在 node 里随机交错测过:JDK 的写法随机 3 万次、专门针对“最后一个线程正要走”的 5 万次,没有一次任务卡在没人取的队列里;去掉复查,核心数 0 时 5 万次全部卡住;只去掉退出前的检查,5 万次里卡住 2 次。概率很小,但会发生。
版本差异
| JDK | 改了什么 | 影响 |
|---|---|---|
| 8 | finalize() 会调 shutdown();常量叫 CAPACITY | 有线程活着时池子根本不会被回收,这个 finalize 基本派不上用场 |
| 11 | finalize() 变成空方法(JDK-8190324);CAPACITY 改名 COUNT_MASK,addWorker 的条件改写成更易读的形式 | 行为不变;更不能指望“没人引用就自动关” |
| 13 | addWorker 检查线程能不能启动,从 t.isAlive() 改成 t.getState() != NEW(JDK-8221892) | ThreadFactory 返回了已经启动过的线程时,报错更准确 |
| 19 | 线程通过 SharedThreadContainer 启动(虚拟线程那批改动,JDK-8284161);ExecutorService 加了 close()、成了 AutoCloseable | jcmd <pid> Thread.dump_to_file -format=json 里,池子的线程归在名为 java.util.concurrent.ThreadPoolExecutor@… 的容器下(本机 JDK 21 实测);可以 try-with-resources |
| 24 | Security Manager 永久禁用(JEP 486),shutdown 里的权限检查删掉 | 无 |
| 25 | 构造参数不合法时,IllegalArgumentException 带上了说明(JDK-8347491) | 报错更好懂 |
| 27 | 删除空的 finalize()(JDK-8371748) | 子类里重写 finalize 并调 super 的代码要改 |
对照了 jdk8u、jdk11u、jdk17u、jdk21u、jdk25u 和主线的源码;每条改动属于哪个版本,是用 GitHub 上主线的提交和各版本的 GA 标签比出来的。从 JDK 8 到主线,execute 的三个 if 一个字没变。
再往深一点
Worker 为什么自己写一把不可重入的锁
- 源码注释:锁住正在跑任务的线程,是为了让“叫醒空闲线程”的中断不会打到正在跑的任务上。shutdown、调小核心数都会
tryLock每个 Worker,锁得上的才中断。 - 不用
ReentrantLock:任务里如果调了setCorePoolSize,它会去中断空闲线程,可重入的锁会让当前线程 tryLock 到自己,把自己中断掉。 - 初始 state 是 −1:线程还没真正开始跑任务前,谁也 tryLock 不到它,也就不会被中断。
workers 为什么用 HashSet + 锁,不用并发集合
- 源码注释给的头号理由:锁让
interruptIdleWorkers串行执行,避免关池时一大批正在退出的线程同时去中断别人,形成“中断风暴”。 largestPoolSize、completedTaskCount这类统计也顺便有了锁保护。- 代价就是坑 7:监控接口也要拿这把锁。
终止是“一个传一个”完成的
- tryTerminate 发现还有线程,只中断一个空闲线程(
ONLY_ONE)。那个线程醒来、退出,又调 tryTerminate,再中断下一个。 - 最后一个线程退出时线程数是 0,它用 CAS 把状态从 SHUTDOWN 推到 TIDYING,调
terminated()钩子,再设成 TERMINATED,唤醒awaitTermination。 - 这个 CAS 和 addWorker 的 +1 互斥:要么终止成功,要么加线程成功。
先占名额,后建线程
- addWorker 先 CAS 线程数 +1,再 new Worker、建线程、启动。顺序反过来的话,几个提交者可能同时建出线程,再发现名额不够,白建一场。
- 建线程可能失败(ThreadFactory 返回 null,或者
unable to create native thread),所以要有addWorkerFailed回滚这个名额。 - 名额的检查和占用一次 CAS 完成,不拿锁;只有改 workers 才拿 mainLock。
面试官会怎么问
讲讲 execute 的源码。
读一次 ctl:线程数小于核心数就 addWorker(command, true),任务作为新线程的第一个任务直接跑。否则如果池子在跑,就 offer 进队列;放进去后复查:池子关了就撤回并拒绝,线程数为 0 就补一个不带任务的线程。放不进队列就 addWorker(command, false) 开临时线程,再失败就拒绝。
ctl 是什么?为什么把两个值放一起?
一个 AtomicInteger,高 3 位是运行状态,低 29 位是线程数。放在一起,“状态还允许、名额也够才 +1”和“已经关闭、线程数为 0 才进入 TIDYING”都能用一次 CAS 完成,两者天然互斥,不用拿锁。
放进队列后为什么还要再检查一次?
两种情况:一是放进去前后池子被关了,要撤回任务;二是这期间最后一个线程退出了,或者核心数本来就是 0,队列里的任务没人取,所以线程数为 0 时补一个。线程退出时也会检查“队列不空却没线程”,两边各查一次,保证任务不会卡在队列里。
线程池里的线程怎么复用、怎么回收?
每个 Worker 的线程跑 runWorker 循环:先跑 firstTask,然后不断 getTask。线程数不超过核心数时用 take() 一直等,超过时用 poll(keepAliveTime) 限时等;限时等没等到、线程数仍超过核心数(或允许核心超时),就 CAS 减线程数并退出。所以“核心线程”不是固定的哪几个。
任务抛异常会怎样?execute 和 submit 有什么区别?
execute:异常从 runWorker 抛出,线程退出,processWorkerExit 补一个新线程,默认的未捕获异常处理器会打印堆栈。submit:任务被包成 FutureTask,异常存在 Future 里,线程不退出,不调 get() 就永远看不到。
下一圈
- 已做:线程池下一圈:怎么配、拒绝策略、动态调参与监控
- AQS:Worker、
ReentrantLock、CountDownLatch共用的那套 state + CLH 队列(已写成下一页) FutureTask的状态机:cancel(true)怎么中断、get()的等待链表ScheduledThreadPoolExecutor:延迟队列DelayedWorkQueue和固定频率任务的“漂移”ForkJoinPool:工作窃取、ManagedBlocker补偿线程,和CompletableFuture的默认池- 动态调参:运行中改
setCorePoolSize、setMaximumPoolSize时,源码里发生了什么 - 虚拟线程:为什么不该把虚拟线程放进池子,
newVirtualThreadPerTaskExecutor怎么限流
↺回到第⑤步:改 TpePeek
每个练习都写了做对时会看到什么。
练习 1 · 换掉核心数 0 的写法
把 TpePeek 末尾那个池子改成核心 10、最大 10、keepAlive 1 秒,再调 allowCoreThreadTimeOut(true),跑完任务后等 1.5 秒看 getPoolSize()。
做对了会看到:10 个 100 ms 的任务一百多毫秒就跑完(本机 113~117 ms),1.5 秒后线程数回到 0;再提交一个任务,又从 0 建出 1 个线程。
用到:坑 1,getTask 的 timed
练习 2 · 让异常现形
继承 ThreadPoolExecutor 覆写 afterExecute(r, t) 打印 t;分别用 execute 和 submit 提交一个抛异常的任务,打印每个任务所在的线程名。
做对了会看到:execute 时 t 是那个异常,下一个任务换到了新线程;submit 时 t 是 null,线程名不变,要从 ((Future<?>) r).get() 里才拿得到异常。
用到:坑 2,runWorker 第 7、9 条
练习 3 · 看看线程转储里的池子
JDK 21 上让 TpePeek 的池子跑着长任务,另开终端执行 jcmd <pid> Thread.dump_to_file -format=json dump.json,找 threadContainers。
做对了会看到:一个名为 java.util.concurrent.ThreadPoolExecutor@… 的容器,里面正好是池子的几个线程;JDK 17 上没有这个命令。
用到:版本差异 JDK 19
1. 用一句话说出 execute 的精华。
三个 if:没满核心就开线程直接跑,否则放队列并复查,放不进就开临时线程或拒绝;状态和线程数在一个 int 里,一次 CAS 同时检查两样。
← 开头一段
2. 不用线程池、每个任务 new Thread,痛在哪?
建、销毁线程都进内核,每次都付这笔钱;突发流量时线程数没有上限,内存和调度都会被拖垮。
← ②
3. 核心数 0 + 无界队列只有 1 个线程,根源是哪条?
execute 第 ② 个 if 的复查:只有线程数为 0 时才补线程。第 ① 个 if 不成立,第 ③ 个 if 永远走不到。
← ④ 机制 1
4. 预测:核心 2、最大 4、SynchronousQueue,连续提交 3 个长任务,会有几个线程?
3 个。前 2 个走第 ① 个 if;第 3 个时 SynchronousQueue 的 offer 只有在有线程正停在 poll 上才成功,两个核心线程都在忙,offer 失败,走第 ③ 个 if 开临时线程。
← ④ 机制 1;SynchronousQueue 见上一页
5. 预测:核心 2,线程 A 在跑一个很长的任务,线程 B 空闲停在 take() 上。这时提交第 3 个任务,它怎么到 B 手里?
线程数 2 不小于核心数,第 ① 个 if 不成立;任务被 offer 进队列,正停在 take() 上的 B 被唤醒取走。不会“直接交给空闲线程”,一定经过队列。
← ④ 机制 1 + 机制 3
6. 预测:allowCoreThreadTimeOut(true),池子闲到线程数 0 以后,再提交一个任务,走的是哪个 if?
第 ① 个:线程数 0 小于核心数,直接 addWorker(task, true),任务作为第一个任务跑。本机练习 1 里正是从 0 建回 1 个线程。
← ④ 机制 1
7. 预测:如果 runWorker 把任务的异常 catch 住、不往外抛,会有什么变化?
线程不会因为异常退出,也就不会被换掉,ThreadLocal 里的东西保得住;但默认的未捕获异常处理器也不再打印堆栈,execute 提交的任务出错就和 submit 一样悄无声息,除非 afterExecute 里记日志。
← ④ 机制 3 + 坑 2