任务提交后到底进队列还是开新线程
线上有个导报表接口,平时 QPS 很低,某天运营批量导出,接口全卡住,日志里线程池队列堆到几千。排查发现用的是 Executors.newFixedThreadPool,队列是 LinkedBlockingQueue,默认容量 Integer.MAX_VALUE,任务只进队列不加线程,maximumPoolSize 形同虚设。这篇就从这个坑出发,把线程池的任务流转、参数、源码和 ThreadLocal 串值一次讲透。
手把手实操:10 个任务,谁开线程、谁进队列、谁被拒
先复现问题。核心 2、最大 4、队列容量 2,一次提交 10 个任务,每个任务睡 1 秒:
import java.util.concurrent.*;
public class FlowDemo {
public static void main(String[] args) throws Exception {
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 4, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
new ThreadFactory() {
private int n = 1;
public Thread newThread(Runnable r) {
return new Thread(r, "biz-thread-" + n++);
}
},
new ThreadPoolExecutor.AbortPolicy());
for (int i = 1; i <= 10; i++) {
final int id = i;
try {
pool.execute(() -> {
System.out.println(" run task-" + id + " on " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
});
System.out.println("submit task-" + id + " ok poolSize="
+ pool.getPoolSize() + " queue=" + pool.getQueue().size());
} catch (RejectedExecutionException e) {
System.out.println("submit task-" + id + " REJECTED poolSize="
+ pool.getPoolSize() + " queue=" + pool.getQueue().size());
}
}
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
System.out.println("all done");
}
}
这里特意用了自定义 ThreadFactory 给线程起名。默认工厂产生的是 pool-1-thread-1 这种名字,线上出问题在 jstack 里认不出是谁,加名字是排查成本最低的一个改动。跑一遍,JDK 26.0.1 的输出是:
run task-1 on biz-thread-1
submit task-1 ok poolSize=1 queue=0
submit task-2 ok poolSize=2 queue=0
submit task-3 ok poolSize=2 queue=1
submit task-4 ok poolSize=2 queue=2
run task-2 on biz-thread-2
submit task-5 ok poolSize=3 queue=2
submit task-6 ok poolSize=4 queue=2
run task-5 on biz-thread-3
run task-6 on biz-thread-4
submit task-7 REJECTED poolSize=4 queue=2
submit task-8 REJECTED poolSize=4 queue=2
submit task-9 REJECTED poolSize=4 queue=2
submit task-10 REJECTED poolSize=4 queue=2
run task-3 on biz-thread-1
run task-4 on biz-thread-2
all done
run 那几行的位置随线程调度会变,submit 行里的 poolSize 和 queue 两列是确定的,对照着看:
- task-1、task-2:
poolSize从 0 涨到 1、2,queue一直是 0。核心线程没满,直接开线程,任务不在队列里停留。 - task-3、task-4:
poolSize停在 2,queue从 0 涨到 1、2。核心线程满了,任务进队列。 - task-5、task-6:
queue已经满了(容量 2),poolSize才从 2 涨到 3、4。队列装不下、线程数又没到 maximum,这时候才开非核心线程。 - task-7 到 task-10:
poolSize卡在 4(等于 maximum),queue卡在 2,四个任务全部 REJECTED。
排在最后的两行 run task-3、run task-4 落在 biz-thread-1、biz-thread-2 上——这两个正是最开始跑 task-1、task-2 的线程。队列里的任务不是新线程来取的,是空闲下来的老线程取走的,这就是线程复用。
顺序记住一句话:核心线程 → 队列 → 非核心线程 → 拒绝策略。
真正决定这个顺序能不能走完的是队列容量。ArrayBlockingQueue 有界,所以能走到第三步、把线程数顶到 maximum;换成 LinkedBlockingQueue 就永远走不到第三步,因为它默认容量是 Integer.MAX_VALUE,等于没有上限。开头线上那个事故就是这个原因:Executors.newFixedThreadPool 内部用的正是无界 LinkedBlockingQueue,任务只进队列、不加线程,maximumPoolSize 形同虚设。
看源码及解析:execute() 的三步与两次 addWorker
源码以 JDK 26 为基准。这个方法的骨架从 JDK 8 到现在没变过,注释里的 "Proceed in 3 steps" 也是原样的。
ctl 一个 int 同时装状态和线程数
在看 execute() 之前必须先看懂 ctl,否则读不懂里面的条件判断:
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3; // 29
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1; // 536870911
private static int ctlOf(int rs, int wc) { return rs | wc; }
private static int runStateOf(int c) { return c & ~COUNT_MASK; }
private static int workerCountOf(int c) { return c & COUNT_MASK; }
private static boolean isRunning(int c) { return c < SHUTDOWN; }
一个 32 位 int,高 3 位存运行状态,低 29 位存线程数。这样设计的原因是把"状态"和"线程数"绑在一次 CAS 里改:不用两个变量、不用两把锁,也就不存在"状态改了线程数没改"的中间态。
把值和掩码都打印出来:
System.out.println("COUNT_BITS = " + 29);
System.out.println("COUNT_MASK = " + ((1 << 29) - 1));
System.out.println("RUNNING = -1 << 29 = " + (-1 << 29));
System.out.println("SHUTDOWN = 0 << 29 = " + (0 << 29));
System.out.println("STOP = 1 << 29 = " + (1 << 29));
System.out.println("TIDYING = 2 << 29 = " + (2 << 29));
System.out.println("TERMINATED = 3 << 29 = " + (3 << 29));
COUNT_BITS = 29
COUNT_MASK = 536870911
RUNNING = -1 << 29 = -536870912
SHUTDOWN = 0 << 29 = 0
STOP = 1 << 29 = 536870912
TIDYING = 2 << 29 = 1073741824
TERMINATED = 3 << 29 = 1610612736
线程数上限因此是 2^29 - 1 = 536870911,不是 Integer.MAX_VALUE。这个上限在实际使用里碰不到,但面试问"ctl 能装多少线程"的时候,答案是 536870911 而不是 21 亿。
假设此刻是 RUNNING、池子里有 2 个线程,ctl 的值是 0xE0000002。拆一次:
ctl = 0xE0000002 = -536870910
ctl & COUNT_MASK = 2
ctl & ~COUNT_MASK= 0xE0000000 = -536870912 (RUNNING=-536870912)
ctl & ~COUNT_MASK == RUNNING ? true
RUNNING < SHUTDOWN < STOP < TIDYING < TERMINATED ? true
最后两行是这张图之外最容易漏掉的一点。RUNNING 是 -1 << 29,是个负数;SHUTDOWN 是 0;再往后都是正数。所以从 RUNNING 到 TERMINATED,这五个值一路递增,源码里判断"状态够不够高"直接用小于号就行:
private static boolean isRunning(int c) { return c < SHUTDOWN; }
isRunning 就这么一行。它等价于 runStateOf(c) == RUNNING,但少了一次位运算。runWorker 里判断要不要中断线程用的 runStateAtLeast(ctl.get(), STOP) 也是同一个道理——因为值有序,比较大小就是比较状态先后,不用写一串 if。
execute() 的三步
public void execute(Runnable command) {
Objects.requireNonNull(command, "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);
}
三步对应上面手把手看到的四种落点:
workerCountOf(c) < corePoolSize→ 开核心线程(addWorker(command, true)),成功就直接 return,任务不进队列。- 核心线程满了,
workQueue.offer(command)入队成功 → 这一步就是 task-3、task-4 的落点。 - 入队失败(队列满)→
addWorker(command, false)开非核心线程,也就是 task-5、task-6。这个方法再失败,说明线程数也到 maximum 了,走reject(command)。
这里有三个细节值得单独说。
第一,为什么先入队,而不是先开到 maximum。 创建线程是有成本的(栈内存、内核线程、调度开销)。队列没满说明任务还有地方缓冲,没必要急着开线程。这是一个明确的权衡:队列缓冲降低线程创建开销,代价是任务可能排队等很久。想要低延迟就调大队列并用小 maximum,想要高吞吐就反过来。反过来,如果先开到 maximum 再入队,队列就几乎永远用不上,那设置队列容量也没有意义了。
第二,recheck 那段二次检查在防什么。 入队成功之后重新读一次 ctl,因为从 offer 成功到读完 ctl 之间池子状态可能已经变了:可能有人调了 shutdown()。这时要把刚放进去的任务 remove 出来再 reject,否则任务留在队列里、线程已经退出,这个任务就永远没人执行了(也就是被静默吞掉)。另一个分支 workerCountOf(recheck) == 0 是补一个线程:入队成功但池子里一个线程都没有(可能上一个线程刚好超时退出),得 addWorker(null, false) 补一个。注意这里第一个参数是 null,意思是"只开一个线程,不把任务交给它,让它自己去队列里取"。
第三,addWorker 的两个调用参数不一样。 第一次是 (command, true),第二次是 (command, false)。第二个参数叫 core,决定用哪个上限做校验:
if (workerCountOf(c)
>= ((core ? corePoolSize : maximumPoolSize) & COUNT_MASK))
return false;
core = true 时按 corePoolSize 卡上限,false 时按 maximumPoolSize 卡。所以同一个方法被复用了两种语义:开核心线程和非核心线程的区别就只在这一个布尔值上。方法内部还做了一次 CAS 自增 workerCount (compareAndIncrementWorkerCount),失败就整个重试——这就是 ctl 把状态和数量合在一起的价值,一次 CAS 同时保证"池子还在跑"和"线程数没超"。
再往下 addWorker 拿到 mainLock(一把普通的 ReentrantLock,只用来保护 workers 这个 HashSet),把新建的 Worker 塞进集合、更新 largestPoolSize,然后启动线程。如果启动失败,finally 里走 addWorkerFailed 回滚:把 worker 从集合里删掉、workerCount 减回去、再 tryTerminate()。多减一次或者漏减一次,ctl 里的线程数就和真实的线程数对不上了,后面所有基于 workerCountOf 的判断都会错,所以这段回滚是必须的。
顺便一个冷知识:addWorker 里有一行 if (t.getState() != Thread.State.NEW) throw new IllegalThreadStateException();。ThreadFactory 如果返回了一个已经 start() 过的线程(比如把同一个 Thread 对象缓存下来重复用),这里会直接抛。自己写 ThreadFactory 时把 new Thread(r, name) 缓存复用是会踩这个坑的。
线程复用:Worker 为什么继承 AbstractQueuedSynchronizer
任务执行完线程不销毁、继续跑下一个任务,这件事靠的是 Worker 这个内部类。它的声明有点反直觉:
private final class Worker
extends AbstractQueuedSynchronizer
implements Runnable
{
final Thread thread;
Runnable firstTask;
volatile long completedTasks;
一个"工作线程"为什么要继承 AQS?JDK 源码里的注释写得很清楚,原文如下:
Class Worker mainly maintains interrupt control state for threads running tasks... This class opportunistically extends AbstractQueuedSynchronizer to simplify acquiring and releasing a lock surrounding each task execution. This protects against interrupts that are intended to wake up a worker thread waiting for a task from instead interrupting a task being run. We implement a simple non-reentrant mutual exclusion lock rather than use ReentrantLock because we do not want worker tasks to be able to reacquire the lock when they invoke pool control methods like setCorePoolSize. Additionally, to suppress interrupts until the thread actually starts running tasks, we initialize lock state to a negative value, and clear it upon start (in runWorker).
拆成三条:
一是"顺手"复用 AQS 当锁。 注意 opportunistically 这个词,意思是"捎带手地"——不是为了用 AQS 的队列能力,只是想要 AQS 已经实现好的 state 加 CAS、以及 exclusiveOwnerThread 这套记录字段,省得自己再写一遍。Worker 把自己当成锁对象,配的方法就这四个:
public void lock() { acquire(1); }
public boolean tryLock() { return tryAcquire(1); }
public void unlock() { release(1); }
public boolean isLocked() { return isHeldExclusively(); }
protected boolean tryAcquire(int unused) {
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
protected boolean tryRelease(int unused) {
setExclusiveOwnerThread(null);
setState(0);
return true;
}
tryAcquire 只认 state == 0 → 1 这一种情况,没有"已经是自己就重入"的分支,所以这是一把不可重入的锁。
二是为什么不用 ReentrantLock。 注释给的理由是"不希望 worker 任务在执行时还能重新拿到这把锁"。因为任务代码里是可以调用池子控制方法的——比如在任务里调 setCorePoolSize。如果这把锁可重入,任务就能在自己持锁的情况下再进来,外面那些依赖这把锁来保证"任务执行期间不被误中断"的逻辑就会出现可乘之机。不可重入等于把这条路堵死。这里省一把 ReentrantLock 对象不是主要目的,语义上的"不可重入"才是。
三是初始 state 是负数。 Worker 的构造函数里做的是 setState(-1),runWorker 一进来第一件事是 w.unlock(); // allow interrupts。这是个刻意的设计:线程刚创建、还没开始跑任务的时候,不允许被中断,因为中断信号有可能落到"线程已启动但任务还没开始"的缝隙里,导致任务还没跑就被打断。等 runWorker 明确准备就绪,unlock 把 state 从 -1 清成 0,之后才能正常被中断。注释里那句 "initialize lock state to a negative value, and clear it upon start" 说的就是这一手。
复用的循环在 runWorker 里,骨架就一行:
while (task != null || (task = getTask()) != null) {
w.lock();
// ... 中断检查
try {
beforeExecute(wt, task);
try {
task.run();
afterExecute(task, null);
} catch (Throwable ex) {
afterExecute(task, ex);
throw ex;
}
} finally {
task = null;
w.completedTasks++;
w.unlock();
}
}
第一个任务来自 w.firstTask(就是 addWorker 传进去的 command),之后每次都靠 getTask() 从队列里取。取到一个就跑一个,completedTasks 累加,然后 unlock 释放,下一轮再取。取不到并且该退出时才返回 null,循环结束、线程销毁。
beforeExecute 和 afterExecute 都是空方法,这也是 ThreadPoolExecutor 留的扩展点:想给每个任务统一加埋点(耗时统计、MDC 上下文),继承后覆写这两个方法比在业务代码里到处包一层干净得多。注意 afterExecute 在任务抛异常时也会被调用一次(参数是那个异常),然后异常被重新抛出——所以在里面做统计不会漏掉失败的任务。
验证复用有多直接:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 4, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
AtomicInteger n = new AtomicInteger();
for (int i = 0; i < 1000; i++) {
pool.execute(n::incrementAndGet);
}
pool.shutdown();
pool.awaitTermination(30, TimeUnit.SECONDS);
System.out.println("tasks executed = " + n.get());
System.out.println("largestPoolSize = " + pool.getLargestPoolSize());
System.out.println("completedTaskCount = " + pool.getCompletedTaskCount());
tasks executed = 1000
largestPoolSize = 4
completedTaskCount = 1000
1000 个任务,历史上最多同时只存在 4 个线程。要是没有复用,这里会是 1000。
线程什么时候死:getTask 的 timed 分支
getTask() 决定一个空闲线程是继续等还是退出:
int wc = workerCountOf(c);
// Are workers subject to culling?
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
if ((wc > maximumPoolSize || (timed && timedOut))
&& (wc > 1 || workQueue.isEmpty())) {
if (compareAndDecrementWorkerCount(c))
return null;
continue;
}
try {
Runnable r = timed ?
workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
workQueue.take();
if (r != null)
return r;
timedOut = true;
} catch (InterruptedException retry) {
timedOut = false;
}
关键就一行:boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
timed == false(是核心线程,且没开allowCoreThreadTimeOut)→ 走workQueue.take(),无限期阻塞。队列空就一直挂着,不超时、不退出。timed == true(非核心线程,或开了核心超时)→ 走workQueue.poll(keepAliveTime, ...),最多等keepAliveTime,等到超时返回null,下一轮循环timedOut为 true 就把自己减掉并return null,线程退出。
keepAliveTime 就作用在这里,而 corePoolSize 之所以是"核心",本质就是"这些线程不走 timed 分支"。想让它也超时,只能显式开 allowCoreThreadTimeOut(true)。
实测一遍。核心 2、最大 6、队列容量 1,一次性提交 6 个会阻塞的任务:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
2, 6, 1L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(1),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy());
CountDownLatch gate = new CountDownLatch(1);
for (int i = 0; i < 6; i++) {
pool.execute(() -> { try { gate.await(); } catch (Exception e) {} });
}
Thread.sleep(300);
System.out.println("coreTimeout=false peak poolSize=" + pool.getPoolSize()
+ " queue=" + pool.getQueue().size() + " largest=" + pool.getLargestPoolSize());
gate.countDown();
Thread.sleep(400);
System.out.println("coreTimeout=false just done poolSize=" + pool.getPoolSize());
Thread.sleep(2500);
System.out.println("coreTimeout=false idle 2.5s poolSize=" + pool.getPoolSize());
上面这段是默认配置(不调 allowCoreThreadTimeOut)的输出:
coreTimeout=false peak poolSize=5 queue=1 largest=5
coreTimeout=false just done poolSize=5
coreTimeout=false idle 2.5s poolSize=2
peak 是 5 而不是 6,因为 6 个任务里 2 个占着核心线程、1 个进队列(容量 1)、剩下 3 个开非核心线程,总共 5 个线程加 1 个排队任务。空闲 2.5 秒(超过 1 秒的 keepAliveTime)之后 poolSize 从 5 掉到 2——掉掉的 3 个正好是非核心线程,核心线程 2 个留着不动。这就是 timed 那一行的效果。
把 allowCoreThreadTimeOut(true) 打开再跑一次,只有这一行的差别:
coreTimeout=true peak poolSize=5 queue=1 largest=5
coreTimeout=true just done poolSize=5
coreTimeout=true idle 2.5s poolSize=0
从 5 掉到 0,核心线程也超时退出了。
这里有个不能漏的校验:allowCoreThreadTimeOut(true) 要求 keepAliveTime > 0,否则直接抛异常。
new ThreadPoolExecutor(2, 4, 0L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(2),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()).allowCoreThreadTimeOut(true);
keepAliveTime=0 + allowCoreThreadTimeOut(true) -> java.lang.IllegalArgumentException: Core threads must have nonzero keep alive times
反过来,new ThreadPoolExecutor 的构造函数本身允许 keepAliveTime = 0,只有想开核心超时的时候才会撞上这个校验。
getTask 之外还有一个出口逻辑容易忽略:processWorkerExit 里有一句 int min = allowCoreThreadTimeOut ? 0 : corePoolSize;。一个 worker 因为超时退出后,如果当前线程数已经不低于 min 就不再补线程,否则补一个。这解释了为什么核心线程即使空闲也维持得住——不是它们不会退出,而是退出之后会被补回来。
还有一种反向需求:不想让第一个请求承担建线程的开销。prestartAllCoreThreads() 在池子空闲时就把核心线程全部建好,返回实际启动的个数:
fresh pool poolSize=0
prestartAllCoreThreads() -> 3 poolSize=3
刚 new 出来的池子 poolSize 是 0,线程是懒创建的,第一个任务提交时才建。预热过的池子省掉这一次抖动,对延迟敏感的接口有用。
顺带把 ctl 里的状态也跑出来看看,ThreadPoolExecutor.toString() 正好把状态和计数都打了出来:
just created [Running, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0]
after submit [Running, pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]
after shutdown() [Shutting down, pool size = 1, active threads = 1, queued tasks = 0, completed tasks = 0]
task finished [Terminated, pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 1]
isTerminated true
注意第三行:任务还在跑的时候调 shutdown(),状态变成 Shutting down,active threads 还是 1,任务照跑不误。shutdown() 的语义是"不再接新任务,队列里已有的做完",不是立刻停。要立刻停得用 shutdownNow(),那个会中断正在跑的线程并且不再执行队列里剩下的任务。
拒绝策略:四种内置,以及两个"关闭后静默丢弃"的坑
队列满、线程数又到 maximum,任务就走到 reject(command)。四个内置实现:
| 策略 | 行为 | 后果 |
|---|---|---|
AbortPolicy | 抛 RejectedExecutionException | 默认策略,调用方能感知到失败 |
CallerRunsPolicy | 让提交任务的线程自己执行 | 形成背压,但会占住调用方线程 |
DiscardPolicy | 什么都不做,静默丢弃 | 任务凭空消失,最难排查 |
DiscardOldestPolicy | 丢掉队列里最老的一个,再重试入队 | 丢的可能是重要任务 |
AbortPolicy 是默认值,构造时不传 handler 就是它。它的"吵"恰恰是优点:提交方立即拿到异常,可以打日志、降级、返回 503,问题暴露在最外面。
CallerRunsPolicy 的写法是 if (!e.isShutdown()) r.run();——用调用 execute 的那个线程去跑任务,而不是池子里的线程。这带来一个副作用:调用方被占住,没法继续提交新任务,于是天然形成背压(back pressure)。实测一下:
ThreadPoolExecutor pool = new ThreadPoolExecutor(
1, 1, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(1),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.CallerRunsPolicy());
CountDownLatch gate = new CountDownLatch(1);
pool.execute(() -> { try { gate.await(); } catch (Exception e) {} });
pool.execute(() -> {});
pool.execute(() -> System.out.println("task-3 (queue full) ran on " + Thread.currentThread().getName()));
gate.countDown();
pool.shutdown();
pool.awaitTermination(5, TimeUnit.SECONDS);
System.out.println("pool.isShutdown() = " + pool.isShutdown());
try {
pool.execute(() -> System.out.println("task-4 should never print"));
System.out.println("task-4: execute() returned normally, no exception, no output");
} catch (RejectedExecutionException e) {
System.out.println("task-4 got RejectedExecutionException");
}
task-3 (queue full) ran on main
pool.isShutdown() = true
task-4: execute() returned normally, no exception, no output
第一行 ran on main 就是背压的直接证据:核心线程占着、队列占着,task-3 被拒绝后由主线程自己跑了。如果这段代码在 Web 请求线程里,占住的就是 Tomcat 的工作线程——背压是你想要的,但代价要心里有数,请求线程被一个长任务占住,吞吐会掉。
第一个坑是第三行。 池子已经 shutdown() 之后,execute(task-4) 既不抛异常、也不打印任何东西,任务就这么没了。原因在 CallerRunsPolicy 那两个条件上:execute 走到最后是 else if (!addWorker(command, false)) reject(command),addWorker 在池子非 RUNNING 时返回 false,于是进 reject;rejectedExecution 的第一句是 if (!e.isShutdown())——池子已经关了,条件不成立,r.run() 不执行,同时也不抛异常。任务被静默丢弃。 换成 AbortPolicy 至少会抛出来,所以用了 CallerRunsPolicy 就要接受"关闭之后不报错"这个特性,别指望它兜底。
第二个坑是 DiscardOldestPolicy 的"最老"是什么。 看它的实现:e.getQueue().poll() 再 e.execute(r)。poll() 拿掉的是队列头部,对 ArrayBlockingQueue 来说是最早入队的那个,符合直觉;但对 PriorityQueue 这类按优先级出队的队列,"头部"是优先级最高的那个,丢掉的恰恰是最该执行的任务。这两个策略组合在一起是要出事的。
线程池里的 ThreadLocal:串值与"父值传不进去"
线程池把线程复用,ThreadLocal 的问题就出在复用的线程上。ThreadLocal 的值挂在线程的 ThreadLocalMap 上,只要线程不死,值就一直在。
在任务里 set 之后不 remove,下一个用同一个线程的任务会读到上一个任务留下的值:
task-A set "trace-A" (no remove), reads trace-A on pool-1-thread-1
task-B never called set() , reads trace-A on pool-1-thread-1
task-B 从没调过 set(),却读到了 task-A 的 trace-A。这就是串值。如果 A 是租户 1 的请求、B 是租户 2 的,B 就会拿着 A 的上下文去查数据,是实打实的数据越权风险。而且它比内存泄漏更隐蔽——泄漏只是占内存,串值是读错了别人的数据。
内存泄漏是同一个原因的另一面:ThreadLocalMap 的 Entry 是 WeakReference,key 是弱引用,value 是强引用。ThreadLocal 对象本身被回收后 key 变 null,但 value 还被 Entry 强引用着,线程不死就永远回收不掉,最终变成 key 为 null 的"僵尸"条目。
InheritableThreadLocal 常被拿来解决"父线程的值传给子线程",但它在池子里并不可靠:
submitter=tenant-1, task-A sees tenant-1 on pool-1-thread-1
submitter=tenant-2, task-B sees tenant-1 on pool-1-thread-1
tenant-2 提交的 task-B 看到的还是 tenant-1。原因是 InheritableThreadLocal 只在创建线程的那一刻把父线程的值复制给子线程。池子里的线程是第一个提交者创建并复用的,后面所有任务的线程都是同一个,不会再复制一次,于是永远拿到第一个提交者的值。用这个传 traceId,会出现"整条链路 traceId 都是别人那次请求的"这种诡异现象。
线程池场景的解法是显式传递:提交前用 TransmittableThreadLocal(阿里 TTL)包装 Runnable,或者自己写个包装类,在 run() 的开头 set、finally 里 remove。包装是必须的,因为"谁 set 谁 remove"这件事在池子里没有别的机制能保证。
结论只有一句:线程池里的 ThreadLocal 必须 remove,写在 finally 里。 用 ThreadLocal 存储请求上下文的地方,这一条没有例外。
验证方法:自己动手确认这三件事
前面所有输出都在 JDK 26.0.1 上跑出来,Windows 下用命令行编译运行,PowerShell 里直接粘:
javac -encoding UTF-8 FlowDemo.java
java FlowDemo
-encoding UTF-8 不能省,否则源码里的中文注释会编译报错。
第一件:对齐四个落点。 跑 FlowDemo,只看 submit 行的 poolSize 和 queue 两列,应该依次看到 1/0、2/0、2/1、2/2、3/2、4/2,然后四行 REJECTED。看到这个序列说明任务流转的顺序搞懂了。run 行的位置每次都不一样,不用在意。
第二件:确认线程复用。 把 FlowDemo 末尾的 pool.shutdown() 去掉,换成循环打印,观察线程名会不会变多:
for (int i = 0; i < 100; i++) {
pool.execute(() -> System.out.println(Thread.currentThread().getName()));
}
Thread.sleep(500);
System.out.println("poolSize = " + pool.getPoolSize());
System.out.println("largest = " + pool.getLargestPoolSize());
poolSize 会稳定在 4 附近,不会涨到 100,打印出来的线程名也只有 biz-thread-1 到 biz-thread-4 四个。
第三件:在真实进程里看线程栈。 前面都是单进程自己打印,排查线上问题时得从外面看。先找到进程号,再打印线程栈:
jps -l
jcmd <pid> Thread.print
jps -l 列出 Java 进程和主类名,拿到 pid 之后用 jcmd (老版本用 jstack )。线程名就是前面 ThreadFactory 起的名字,哪个业务线程卡在哪个方法上,一眼能看出来。如果只看到一堆 pool-1-thread-N,说明线上那个池子没给线程起名,回头补上。
第四件:确认 ThreadLocal 串值。 在任务里 set 之后不 remove,再提交一个不 set 的任务,读出来是上一个任务的值;把 remove 加进 finally 再跑,第二个任务读到 null。两次结果一对比,串值这件事就再也不会记混了。
面试速答
- 提交顺序:核心线程 → 队列 → 非核心线程 → 拒绝策略。队列无界则第三步永远走不到,
maximumPoolSize形同虚设。 ctl是一个AtomicInteger,高 3 位状态、低 29 位线程数,一次 CAS 同时改;线程数上限2^29 - 1 = 536870911。- 五种状态的值从 RUNNING(
-1 << 29) 到 TERMINATED(3 << 29) 单调递增,所以isRunning(c)就是c < SHUTDOWN一行。 - 为什么先入队再加线程:建线程有成本,队列没满就先缓冲;代价是任务可能排队等很久。
- 线程复用靠
Worker的runWorker循环 +getTask从队列取任务,第一个任务来自firstTask,之后的都来自队列。 Worker继承 AQS 是为了拿一把不可重入的锁:防止任务在持锁时通过setCorePoolSize之类的方法重入。初始state是 -1,runWorker的w.unlock()之后才允许被中断。keepAliveTime只在getTask的timed分支生效,而timed = allowCoreThreadTimeOut || wc > corePoolSize,所以核心线程默认永不超时;开allowCoreThreadTimeOut(true)要求keepAliveTime > 0,否则抛IllegalArgumentException。shutdown()不接新任务、队列里做完,任务运行中调它状态是Shutting down但任务照跑;shutdownNow()才中断在跑的线程。CallerRunsPolicy用提交线程跑任务形成背压,但池子shutdown()之后它不抛异常也不执行,任务被静默丢弃。DiscardOldestPolicy丢的是队列头部,对优先级队列丢的是优先级最高的那个。- 线程池里
ThreadLocal必须remove,否则串值加内存泄漏;InheritableThreadLocal只在建线程时复制,池子里不可靠,得靠包装Runnable显式传递。 - 生产上别用
Executors工厂方法,手动new ThreadPoolExecutor指定有界队列、命名线程工厂和明确的拒绝策略。
核心收获:线程池的参数不是孤立的,队列类型决定了 maximumPoolSize 有没有机会生效,拒绝策略决定了任务堆积时是报错还是悄悄消失。下一步,把你项目里所有 Executors.newXxx 找出来,换成显式 ThreadPoolExecutor 并给线程起名。
本文关键词:ThreadPoolExecutor、任务流转、拒绝策略、ThreadLocal 串值、InheritableThreadLocal