阻塞队列选错,线程池白调
手把手实操
先搭一个能跑的场景:一个订单导出接口,任务量忽大忽小,你想用线程池扛住突发流量。
ThreadPoolExecutor pool = new ThreadPoolExecutor(
4, 16, 60L, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200),
new ThreadFactory() {
private final AtomicInteger n = new AtomicInteger(1);
public Thread newThread(Runnable r) {
Thread t = new Thread(r, "export-" + n.getAndIncrement());
t.setDaemon(false);
return t;
}
},
new ThreadPoolExecutor.CallerRunsPolicy());
跑起来后压测,你会看到两个现象。一是队列满之前,线程数一直卡在 4,maximumPoolSize=16 根本没生效。二是队列满了之后,提交任务的业务线程开始自己执行任务,接口 RT 直接飙上去。
这不是 bug,是 ThreadPoolExecutor 的固定顺序:核心线程 → 队列 → 非核心线程 → 拒绝策略。队列只要没满,就不会创建第 5 个线程。想让 16 个线程真正用上,得换队列。
new ThreadPoolExecutor(4, 16, 60L, TimeUnit.SECONDS,
new SynchronousQueue<>(), ...);
SynchronousQueue 不存元素,每个 offer 必须等到有线程来 take。这样任务一进来就尝试交给空闲线程,交不出去才扩容到 16,扩到顶才走拒绝策略。代价是队列容量为 0,突发流量直接打到拒绝策略上,所以配 CallerRunsPolicy 做背压。
关键点在队列的容量语义,不在容量数字。ArrayBlockingQueue 有界、数组实现、一把锁;LinkedBlockingQueue 默认容量是 Integer.MAX_VALUE,等于无界,用默认构造就等于把 maximumPoolSize 废掉。踩坑最多的就是这一条:Executors.newFixedThreadPool 内部就是 LinkedBlockingQueue 无界队列,任务堆积到 OOM 才报错。
看源码及解析
ThreadPoolExecutor.execute 的三段判断是理解一切的地基:
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);
ctl 是一个 AtomicInteger,高 3 位存运行状态,低 29 位存线程数。为什么把状态和数量塞进一个 int?因为扩容判断和状态判断要原子完成,拆成两个字段就得上锁。这是 Doug Lea 的经典手法,代价是线程数上限被压到约 5 亿,够用。
第二段里的 recheck 是防并发:offer 成功后池子可能刚好被 shutdown,任务留在队列里没人消费,所以再查一次状态,能移除就移除并拒绝。workQueue.offer 返回 false 才走第三段扩容。这就是为什么队列类型直接决定扩容时机。
再看 getTask,它决定线程什么时候死:
boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;
Runnable r = timed ? workQueue.poll(keepAliveTime, NANOSECONDS)
: workQueue.take();
if (r != null) return r;
if (timedOut) continue;
核心线程默认用 take() 无限阻塞,所以不会被回收;超出核心数的线程用 poll(keepAliveTime),超时拿不到任务就返回 null,外层循环退出,线程销毁。allowCoreThreadTimeOut(true) 能让核心线程也走超时逻辑,适合流量波峰波谷明显的服务。
阻塞队列家族怎么选,看底层结构:
ArrayBlockingQueue:数组 + 单锁,读写互斥,容量必须指定。LinkedBlockingQueue:链表 + 两把锁(takeLock/putLock),读写可以并行,吞吐通常更高,代价是默认无界。SynchronousQueue:不存储,公平模式用队列、非公平用栈,适合直接交接。PriorityBlockingQueue:无界优先队列,二叉堆,出队按优先级,注意它不保证同优先级顺序。DelayQueue:元素实现Delayed,按到期时间出队,定时任务常用。LinkedTransferQueue:JDK 7 加入,transfer()能阻塞到消费者接手,比 SynchronousQueue 多一个可缓冲的选项。
ConcurrentLinkedQueue 是无锁队列,CAS 实现,不阻塞、无界、size() 是 O(n) 遍历,别在循环里调。它不适合做线程池队列,因为没有阻塞语义,getTask 的 poll(timeout) 用不上。它适合事件总线、任务分发这类消费者自己轮询的场景。ConcurrentSkipListMap 是跳表实现的有序 Map,get/put 平均 O(log n),适合需要按 key 排序且高并发的场景,比如排行榜、范围查询。
验证方法
写个 20 行的探针,把线程数和队列长度打出来:
for (int i = 0; i < 300; i++) {
final int id = i;
pool.execute(() -> {
System.out.println(Thread.currentThread().getName()
+ " task=" + id + " pool=" + pool.getPoolSize()
+ " queue=" + pool.getQueue().size());
try { Thread.sleep(200); } catch (InterruptedException e) {}
});
}
换成 ArrayBlockingQueue(200) 跑一遍,再换 SynchronousQueue 跑一遍,对比 pool 这一列什么时候从 4 涨到 16。看到 SynchronousQueue 版本线程数立刻冲高、ArrayBlockingQueue 版本一直贴 4,说明顺序机制吃透了。
再验证拒绝:把队列调到 1、最大线程调到 2,灌 100 个任务,观察 CallerRunsPolicy 下日志里出现业务线程名(比如 http-nio-8080-exec-1),这就是背压生效。
面试速答
- 执行顺序固定:核心线程 → 入队 → 扩容到 max → 拒绝。队列没满就不扩容,
LinkedBlockingQueue默认无界会让 max 失效。 ctl高 3 位状态、低 29 位线程数,一个 CAS 同时管两件事。- 核心线程靠
take()常驻,非核心靠poll(keepAliveTime)超时退出,allowCoreThreadTimeOut可让核心也回收。 - 无锁队列没有阻塞语义,不能当线程池队列;
ConcurrentSkipListMap适合有序高并发读。 - 被追问就答:
Executors的固定/缓存线程池为什么在生产禁用——队列无界或线程无界,都会 OOM。
核心收获:线程池调参的前提是先选对队列,队列容量语义决定扩容和拒绝的时机。下一步把 CompletableFuture 和 ForkJoinPool 的工作窃取接上,看异步编排怎么复用这套线程模型。
本文关键词:阻塞队列、ThreadPoolExecutor、SynchronousQueue、ConcurrentLinkedQueue、拒绝策略