《Java 并发编程·线程与内存模型》10.1 线程池七参数与执行流程

把 ThreadPoolExecutor 的七个参数逐个拆开,说明 core、max、queue、keepAlive、工厂与拒绝策略各管哪一段,用三段判断还原 execute 的执行顺序,并以逐步提交实验打印 poolSize 与 queue 的真实变化,验证核心线程默认不超时、只有开启 allowCoreThreadTimeOut 才会归零。

承接的问题

上一章讨论的是「一把锁怎么选」。锁解决的是同一时刻谁能进临界区,不解决「同时有一千个请求,用几个线程去跑」。第 1 章说过平台线程是 1:1 映射到内核线程的实体,创建有成本、数量有上限;第 1.2 节把任务和执行器拆开了,但只用了 newSingleThreadExecutor 这个最简单的壳。

这一节把壳拆掉,直接面对 ThreadPoolExecutor 的构造函数,回答三个问题:七个参数各在哪一步生效;execute 面对新任务按什么顺序判断;以及 JDK 为什么选择「先入队,队列满了才扩容」,而不是「有空闲线程就复用、没有就新建」。这三个问题答不上来,参数就只能靠抄;答上来之后,newFixedThreadPool 为什么在生产环境危险、max 为什么常常形同虚设,都是自然推论。

七个参数各管一段

ThreadPoolExecutor 的构造签名有七个参数,它们的顺序不是随意的,而是按「先看谁、后看谁」排列:

public ThreadPoolExecutor(int corePoolSize,
                          int maximumPoolSize,
                          long keepAliveTime,
                          TimeUnit unit,
                          BlockingQueue<Runnable> workQueue,
                          ThreadFactory threadFactory,
                          RejectedExecutionHandler handler)
参数管什么什么时候生效
corePoolSize常驻线程数每次提交都先看它:线程数没到 core,直接新建
maximumPoolSize线程总数上限只有队列满了才看它
keepAliveTime空闲线程存活时间线程数超过 core 时,多余线程空闲多久退出
unit上者的时间单位同上
workQueue排队容器决定「队列满」这个事件何时发生,也决定容量能不能变
threadFactory线程怎么造每次新建线程时;命名、daemon、异常处理器都在这里
handler队列满且线程到顶怎么办最后一道闸门

几个容易被忽略的约束:

  • corePoolSize 不能大于 maximumPoolSize,否则构造器直接抛 IllegalArgumentException。本机实测的报错文案是 maximumPoolSize must be greater than or equal to corePoolSize。
  • keepAliveTime 默认只作用于超过 core 的那部分线程。核心线程空闲了也留着,这是「常驻」的含义。
  • allowCoreThreadTimeOut(true) 可以让核心线程也超时,但要求 keepAliveTime > 0,否则抛 IllegalArgumentException: Core threads must have nonzero keep alive times。
  • 队列的容量在构造时定死,ArrayBlockingQueue 之后不能改。要动态调整容量,只能换队列实现或重建线程池。

ThreadFactory 的写法在 1.2 节已经给过完整实现,这里只强调一点:线程名是最便宜的可观测性。一个未命名的池在 jcmd Thread.print 里全是 pool-3-thread-7,出事时无法判断它属于借阅、通知还是对账。

execute 的三段判断

execute 的逻辑可以压缩成三段判断。下面不是 JDK 源码的复制,而是决策层面的等价描述(ctl、Worker 这些实现细节留给卷二第 11 章):

public void execute(Runnable command) {
    if (command == null) throw new NullPointerException();

    // 第一段:线程数还没到 core,直接开一个新线程跑它
    if (workerCountOf(ctl.get()) < corePoolSize) {
        if (addWorker(command, true)) return;
        // 走到这里说明并发下状态变了,落到第二段
    }

    // 第二段:入队。能入队就认为「接下了」
    if (isRunning(ctl.get()) && workQueue.offer(command)) {
        int recheck = ctl.get();
        // 入队后复查:池可能刚刚被关闭,或者最后一个工作线程刚死
        if (!isRunning(recheck) && remove(command)) reject(command);
        else if (workerCountOf(recheck) == 0) addWorker(null, false);
        return;
    }

    // 第三段:入队失败(队列满),尝试扩容到 max
    if (!addWorker(command, false)) reject(command);
}

三段之间有严格的优先级:core → queue → max → 拒绝。注意 maximumPoolSize 排在队列后面,这是理解整个线程池行为的钥匙。

第二段里的「入队后复查」值得单独说。offer 成功不代表任务一定会被执行:

  • 池可能在 offer 和复查之间被 shutdown,任务卡在队列里没人取,所以要 remove 回来并交给拒绝策略;
  • 池里可能一个工作线程都没有(比如 corePoolSize = 0),任务进了队列也没有消费者,所以要补一个 addWorker(null, false)。

这两条分支对应两种真实事故:关闭期间丢任务、core=0 的池提交任务后永远不执行。后者在本机实测过:core=0、无界队列、提交 5 个阻塞任务,结果是 poolSize=1 queue=4——池用 1 个线程跑完了所有排队任务,max=4 一次都没用上。

为什么先入队再加线程

如果让业务方设计,直觉方案通常是「没有空闲线程就新建,直到 max」。JDK 选了反过来的顺序,原因有三条:

第一,线程创建比入队贵一个数量级。 平台线程要有自己的栈(默认 1 MB 量级)、要进内核调度队列、要初始化 TLS。入队只是一次带锁的数组/链表写入。突发流量下,先让队列吸收,比立刻扩线程便宜得多。

第二,扩容容易,缩容难。 线程一旦建出来,要等它空闲满 keepAliveTime 才回收。如果每次小高峰都扩到 max,池的线程数会长期停在高位,内存和上下文切换成本都下不来。

第三,排队是一种可控的背压。 队列长度是显式的、可观测的;线程数暴涨是隐式的。队列满了会触发拒绝策略,让上游知道「系统到极限了」;而线程数暴涨只会让所有任务一起变慢,最后集体超时。

代价也很直接:队列越长,任务排队时间越长。所以这套顺序适合「短任务、允许排队」的场景;如果是长阻塞任务(等下游 RPC、等大文件 IO),任务会在队列里堆很久,此时应该调大 core、缩小队列,或者干脆换虚拟线程(第 14 章)。

还有一个常见误解:以为 max 是「最多同时跑多少个任务」。不是,max 是「最多存在多少个线程」,队列里的任务一个都没在跑。线程池通常长不到 max——除非队列先满。

实测:逐步提交看池与队列

下面这个类用 core=2, max=4, queue=ArrayBlockingQueue(2), keepAlive=2s,逐个提交 8 个会阻塞在同一个 CountDownLatch 上的任务,每次提交后打印池大小、队列长度和已开始执行的任务数。

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public final class ExecutorFlowProbe {

    public static void main(String[] args) throws Exception {
        AtomicInteger seq = new AtomicInteger();
        ThreadFactory factory = r -> new Thread(r, "loan-" + seq.incrementAndGet());

        ThreadPoolExecutor pool = new ThreadPoolExecutor(
                2, 4, 2, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(2), factory);

        CountDownLatch release = new CountDownLatch(1);
        AtomicInteger started = new AtomicInteger();
        AtomicInteger submitted = new AtomicInteger();

        for (int i = 1; i <= 8; i++) {
            int id = i;
            try {
                pool.execute(() -> {
                    started.incrementAndGet();
                    try {
                        release.await();
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                });
                submitted.incrementAndGet();
            } catch (RejectedExecutionException e) {
                System.out.println("#" + id + " rejected");
                continue;
            }
            // 等到「已开始 + 已入队」覆盖本次提交,再取快照
            awaitSettled(pool, started, submitted);
            System.out.printf("#%d poolSize=%d queue=%d active=%d started=%d largest=%d%n",
                    id, pool.getPoolSize(), pool.getQueue().size(),
                    pool.getActiveCount(), started.get(), pool.getLargestPoolSize());
        }

        release.countDown();
        boolean backToCore = waitUntil(() -> pool.getPoolSize() == 2, 10_000);
        System.out.println("afterKeepAlive poolSize=" + pool.getPoolSize()
                + " reachedCore=" + backToCore);
        pool.shutdown();
        pool.awaitTermination(5, TimeUnit.SECONDS);

        ThreadPoolExecutor ttl = new ThreadPoolExecutor(
                2, 4, 1, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(8), factory);
        ttl.allowCoreThreadTimeOut(true);
        CountDownLatch quick = new CountDownLatch(2);
        for (int i = 0; i < 2; i++) {
            ttl.execute(quick::countDown);
        }
        quick.await();
        Thread.sleep(200);
        System.out.println("coreTimeout before poolSize=" + ttl.getPoolSize());
        boolean drained = waitUntil(() -> ttl.getPoolSize() == 0, 10_000);
        System.out.println("coreTimeout after poolSize=" + ttl.getPoolSize()
                + " reachedZero=" + drained
                + " allowCoreThreadTimeOut=" + ttl.allowsCoreThreadTimeOut());
        ttl.shutdown();
    }

    private static void awaitSettled(ThreadPoolExecutor pool,
                                     AtomicInteger started,
                                     AtomicInteger submitted) throws InterruptedException {
        waitUntil(() -> started.get() + pool.getQueue().size() >= submitted.get(), 5_000);
    }

    private static boolean waitUntil(java.util.function.BooleanSupplier cond, long timeoutMs)
            throws InterruptedException {
        long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs);
        while (System.nanoTime() < deadline) {
            if (cond.getAsBoolean()) {
                return true;
            }
            Thread.sleep(5);
        }
        return cond.getAsBoolean();
    }
}

编译运行:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
$JAVA_HOME/bin/javac --release 25 -d classes ExecutorFlowProbe.java
$JAVA_HOME/bin/java -cp classes ExecutorFlowProbe

本机输出(连续两次完全相同):

#1 poolSize=1 queue=0 active=1 started=1 largest=1
#2 poolSize=2 queue=0 active=2 started=2 largest=2
#3 poolSize=2 queue=1 active=2 started=2 largest=2
#4 poolSize=2 queue=2 active=2 started=2 largest=2
#5 poolSize=3 queue=2 active=3 started=3 largest=3
#6 poolSize=4 queue=2 active=4 started=4 largest=4
#7 rejected
#8 rejected
afterKeepAlive poolSize=2 reachedCore=true
coreTimeout before poolSize=2
coreTimeout after poolSize=0 reachedZero=true allowCoreThreadTimeOut=true

逐行读:

  • #1、#2:线程数没到 core,直接新建核心线程,队列为空。
  • #3、#4:core 已满,任务入队,queue 从 1 涨到 2,poolSize 保持在 2。这就是「先入队」的直接证据——队列还有空间时,max=4 完全不参与。
  • #5、#6:队列满了(容量 2),offer 失败,才开始扩容,poolSize 变成 3、4,largest 同步涨到 4。
  • #7、#8:队列满且线程到顶,交给拒绝策略,AbortPolicy 抛 RejectedExecutionException。
  • afterKeepAlive poolSize=2:释放 latch 后任务跑完,两个非核心线程空闲满 2 秒退出,池回落到 core。keepAlive 只回收 core 之外的部分。
  • coreTimeout before/after:第二个池显式 allowCoreThreadTimeOut(true),keepAlive 设 1 秒,两个核心线程在任务结束后也退出了,poolSize 归零。

这些数字是结构性快照,不是耗时统计。本机 load averages 在 140 以上,但 poolSize、queue.size() 的取值不依赖 CPU 速度,只依赖线程池的决策顺序,所以可以作为证据;任何耗时类数字在这种负载下都不可引用。

在 libraryd-conc 里落地

把默认的固定线程池换成显式构造,七个参数一次写清,并且用 prestartAllCoreThreads() 避开「第一批请求同时触发建线程」的抖动:

package com.plume.conc.core;

import java.time.Instant;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public final class PooledLoanService implements LoanService, AutoCloseable {

    private final BookRepository books;
    private final LoanRepository loans;
    private final ThreadPoolExecutor pool;

    public PooledLoanService(BookRepository books, LoanRepository loans) {
        this.books = books;
        this.loans = loans;
        this.pool = new ThreadPoolExecutor(
                4, 8, 60, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(64),
                new ThreadFactory() {
                    private final AtomicInteger seq = new AtomicInteger();

                    @Override
                    public Thread newThread(Runnable r) {
                        Thread t = new Thread(r, "loan-pool-" + seq.incrementAndGet());
                        t.setUncaughtExceptionHandler((thread, error) ->
                                System.err.println("uncaught in " + thread.getName() + ": " + error));
                        return t;
                    }
                },
                new ThreadPoolExecutor.AbortPolicy());
        this.pool.prestartAllCoreThreads();
    }

    @Override
    public Reservation reserve(String isbn, String memberId) {
        return submitAndWait(() -> reserveLocked(isbn, memberId), "reserve isbn=" + isbn);
    }

    private synchronized Reservation reserveLocked(String isbn, String memberId) {
        Book book = books.findByIsbn(isbn)
                .orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, isbn));
        if (book.stock() <= 0) {
            throw new LoanException(ErrorCode.OUT_OF_STOCK, isbn);
        }
        books.save(new Book(book.isbn(), book.title(), book.stock() - 1));
        return new Reservation("R-" + isbn, isbn, memberId, Instant.now().plusSeconds(86_400));
    }

    @Override
    public boolean returnBook(String loanId) {
        return submitAndWait(() -> returnLocked(loanId), "return loanId=" + loanId);
    }

    private synchronized boolean returnLocked(String loanId) {
        Loan loan = loans.findById(loanId)
                .orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, loanId));
        Book book = books.findByIsbn(loan.isbn())
                .orElseThrow(() -> new LoanException(ErrorCode.BOOK_NOT_FOUND, loan.isbn()));
        books.save(new Book(book.isbn(), book.title(), book.stock() + 1));
        return true;
    }

    private <T> T submitAndWait(Callable<T> task, String what) {
        try {
            return pool.submit(task).get();
        } catch (RejectedExecutionException e) {
            throw new LoanException(ErrorCode.CONFLICT, "pool saturated: " + what);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new LoanException(ErrorCode.INTERRUPTED, what + " interrupted");
        } catch (ExecutionException e) {
            if (e.getCause() instanceof LoanException loan) {
                throw loan;
            }
            throw new IllegalStateException(what + " failed", e.getCause());
        }
    }

    @Override
    public void close() throws InterruptedException {
        pool.shutdown();
        if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
            pool.shutdownNow();
        }
    }
}

参数选择的依据:

  • core=4:常规并发下的常驻处理能力,避免每个请求都触发建线程。
  • max=8:留一倍余量吸收突发,再高就会把下游拖垮——线程池不是越大越好,瓶颈通常在数据库连接。
  • queue=64:约等于「每秒请求数 × 可接受排队秒数」再留余量,具体算法在 10.2 节。
  • keepAlive=60s:突发过后一分钟内多出来的线程退出。
  • AbortPolicy:宁可快速失败,也不让请求在队列里无声地等到超时。

两个必须说清的取舍:其一,reserve 在请求线程里 submit().get(),一个请求占两个线程,这是「舱壁隔离」——把借阅计算限制在固定线程数内保护下游,代价是线程数翻倍;上游本来就异步时应该把 Future 往上抛(第 13 章)。其二,库存的读改写仍然需要互斥,synchronized 锁住的是服务实例,线程池只负责调度,不会让非线程安全的逻辑变安全。

submitAndWait 把「提交 → 等结果 → 还原业务异常 → 拒绝时快速失败」收成一个方法,reserve 和 returnBook 只关心领域逻辑。这也是线程池落地时最该抽的一层:任务体内不出现 Future、ExecutionException,异常翻译只写一次。

常见错误与边界

错误后果改法
用 Executors.newFixedThreadPool无界队列 + core == max,max 永不生效,队列可无限增长显式 ThreadPoolExecutor + 有界队列
core=0 还配无界队列池只会有 1 个线程,max 形同虚设至少给 core 一个正数,或用 SynchronousQueue
maximumPoolSize < corePoolSize构造时抛 IllegalArgumentException校验参数,或用配置中心兜底
以为 max 是并发上限容量规划按 max 算,实际并发远低于预期记住顺序是 core → queue → max
把 newSingleThreadExecutor() 强转成 ThreadPoolExecutorClassCastException,它返回的是包装过的代理需要改参数就自己 new
在任务里改线程名/优先级线程复用时污染下一个任务属性统一在 ThreadFactory 里设
关闭时只调 shutdown() 不等进程退出,队列里的任务丢失两段式关闭,10.3 节展开
用 pool.getQueue() 做业务判断队列是内部结构,外部读写会破坏语义只读快照用于监控

什么时候不要用固定大小的平台线程池:任务大量阻塞在下游 IO 上(等 RPC、等文件)、并发量在万级、每个任务的生命周期很长。这几种情况下,线程数由「下游能承受多少并发」决定,而不是 CPU 核数,继续加大池只会把压力转嫁给下游。第 14 章的虚拟线程和 14.3 节的信号量限流是更合适的工具。

小结

  • 七个参数分成四段生效:core 决定是否直接建线程,queue 决定是否排队,max 决定队列满后能否扩容,handler 决定最后怎么办。
  • execute 的顺序是 core → queue → max → 拒绝;max 排在队列之后,所以队列没满时它一次都不会被读到。
  • 「先入队再扩容」的理由是线程创建昂贵、扩容后难回收、排队是可观测的背压;代价是长任务会在队列里等待。
  • 本机实测 core=2/max=4/queue=2:前 2 个任务建核心线程,第 3、4 个入队,第 5、6 个才扩容,第 7 个起被拒绝。
  • 核心线程默认不超时;allowCoreThreadTimeOut(true) 后实测 poolSize 从 2 归零,且要求 keepAliveTime > 0。

下一节处理队列满之后的那一步:四种拒绝策略各自适合什么场景,CallerRunsPolicy 为什么能形成反压,以及有界队列的容量到底怎么算。

阅读导航:上一节 9.3 读写锁与 StampedLock | 下一节 10.2 队列与拒绝策略

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收