← 返回博客
2026-09-29 08:00:02

阻塞队列选错,线程池白调

阻塞队列选错,线程池白调

手把手实操

先搭一个能跑的场景:一个订单导出接口,任务量忽大忽小,你想用线程池扛住突发流量。

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) 能让核心线程也走超时逻辑,适合流量波峰波谷明显的服务。

阻塞队列家族怎么选,看底层结构:

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),这就是背压生效。

面试速答

核心收获:线程池调参的前提是先选对队列,队列容量语义决定扩容和拒绝的时机。下一步把 CompletableFuture 和 ForkJoinPool 的工作窃取接上,看异步编排怎么复用这套线程模型。

本文关键词:阻塞队列、ThreadPoolExecutor、SynchronousQueue、ConcurrentLinkedQueue、拒绝策略