《Java 并发编程·JUC 源码与容器》11.1 ctl 与 Worker 生命周期

线程池把两个控制量压进一个 int:高 3 位是 runState,低 29 位是 workerCount。本节用反射读出运行中线程池的真实 ctl 并逐位拆解,解释 RUNNING 为什么取负数、五个状态为何只能单向递增,以及 Worker 为何继承 AQS 用 tryAcquire 表示正在跑任务。

承接的问题

卷一第 10 章讲的是线程池的用法:七个参数怎么配、execute 的三步流程、拒绝策略怎么选。那部分是外部视角,读者知道任务被拒绝或被执行,但看不到池内部如何判断"该不该再开一个线程"。本节只回答三个问题:

  1. runState(运行状态)和 workerCount(工作线程数)为什么被塞进同一个 int?
  2. 五个状态里 RUNNING 是唯一的负数,这个取值是随手选的还是必须的?
  3. Worker 只是跑任务的线程包装,为什么要继承 AbstractQueuedSynchronizer?

答案合起来就是线程池的控制面:用一个原子变量同时表达"池能不能接活"和"池里有几个线程",用数值顺序表达状态机,用一把不可重入的内置锁表达"这个线程此刻是否空闲"。

一个 int 装两个字段

ctl 的类型是 AtomicInteger,不是 AtomicLong——本机 javap -p java.util.concurrent.ThreadPoolExecutor 的输出里这一行是 private final java.util.concurrent.atomic.AtomicInteger ctl;。源码里的定义与拆包:

// JDK 25 源码摘录,已删减
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static final int COUNT_BITS = Integer.SIZE - 3;
private static final int COUNT_MASK = (1 << COUNT_BITS) - 1;

// runState is stored in the high-order bits
private static final int RUNNING    = -1 << COUNT_BITS;
private static final int SHUTDOWN   =  0 << COUNT_BITS;
private static final int STOP       =  1 << COUNT_BITS;
private static final int TIDYING    =  2 << COUNT_BITS;
private static final int TERMINATED =  3 << COUNT_BITS;

// Packing and unpacking ctl
private static int runStateOf(int c)     { return c & ~COUNT_MASK; }
private static int workerCountOf(int c)  { return c & COUNT_MASK; }
private static int ctlOf(int rs, int wc) { return rs | wc; }

COUNT_BITS = 32 - 3 = 29,COUNT_MASK = 0x1FFFFFFF,位布局如下:

位区间宽度含义掩码
31–293runState0xE0000000(~COUNT_MASK)
28–029workerCount0x1FFFFFFF

因为 workerCount 永远非负且不超过 COUNT_MASK,加减一不会向上进位,所以 compareAndIncrementWorkerCount 直接 ctl.compareAndSet(expect, expect + 1),不需要拆包再拼;decrementWorkerCount() 用 ctl.addAndGet(-1) 也安全——只要计数大于 0,借位不会越过第 29 位污染状态。

不碰反射,先按同样的常量合成五个状态,确认数值顺序(本机输出):

public final class CtlBits {
    static final int COUNT_BITS = Integer.SIZE - 3;
    static final int COUNT_MASK = (1 << COUNT_BITS) - 1;
    static final int RUNNING    = -1 << COUNT_BITS;
    static final int SHUTDOWN   =  0 << COUNT_BITS;
    static final int STOP       =  1 << COUNT_BITS;
    static final int TIDYING    =  2 << COUNT_BITS;
    static final int TERMINATED =  3 << COUNT_BITS;

    static String bits(int c) {
        String s = String.format("%32s", Integer.toBinaryString(c)).replace(' ', '0');
        return s.substring(0, 3) + "_" + s.substring(3);
    }

    public static void main(String[] args) {
        int[][] rows = {{RUNNING, 0}, {RUNNING, 4}, {SHUTDOWN, 4},
                        {STOP, 2}, {TIDYING, 0}, {TERMINATED, 0},
                        {RUNNING, COUNT_MASK}};
        String[] names = {"RUNNING", "RUNNING", "SHUTDOWN", "STOP", "TIDYING", "TERMINATED", "RUNNING"};
        for (int i = 0; i < rows.length; i++) {
            int c = rows[i][0] | rows[i][1];
            System.out.printf("%-10s wc=%-9d ctl=%-11d 0x%08X %s isRunning=%b%n",
                    names[i], rows[i][1], c, c, bits(c), c < SHUTDOWN);
        }
    }
}

完整输出:

RUNNING    wc=0         ctl=-536870912  0xE0000000 111_00000000000000000000000000000 isRunning=true
RUNNING    wc=4         ctl=-536870908  0xE0000004 111_00000000000000000000000000100 isRunning=true
SHUTDOWN   wc=4         ctl=4           0x00000004 000_00000000000000000000000000100 isRunning=false
STOP       wc=2         ctl=536870914   0x20000002 001_00000000000000000000000000010 isRunning=false
TIDYING    wc=0         ctl=1073741824  0x40000000 010_00000000000000000000000000000 isRunning=false
TERMINATED wc=0         ctl=1610612736  0x60000000 011_00000000000000000000000000000 isRunning=false
RUNNING    wc=536870911 ctl=-1          0xFFFFFFFF 111_11111111111111111111111111111 isRunning=true

最后一行值得记住:RUNNING 加上满员计数 ctl 恰好是 -1。这说明在 29 位计数的整个取值范围内,"ctl 为负"与"池处于 RUNNING“等价,isRunning 才敢写成 return c < SHUTDOWN;——一次与零比较,不需要掩码。

为什么不是两个字段

拆成 volatile int runState 加 AtomicInteger workerCount 看起来更清晰,但会在三处出问题。

第一处是 tryTerminate:它要回答"队列空了、线程也归零了吗”,这个判断必须和状态读在同一时刻:

// JDK 25 源码摘录,已删减
if (isRunning(c) ||
    runStateAtLeast(c, TIDYING) ||
    (runStateLessThan(c, STOP) && ! workQueue.isEmpty()))
    return;
if (workerCountOf(c) != 0) { // Eligible to terminate
    interruptIdleWorkers(ONLY_ONE);
    return;
}

两个变量的话,读完状态再读计数,中间可能插进一次 shutdown() 或一次 worker 退出,判断就建立在两个不同时刻的快照上。一个 ctl.get() 读到的是一致的一对值。

第二处是状态转移必须保留计数。advanceRunState 读到 c、算出新 ctl、CAS 回去,失败就重读:

// JDK 25 源码摘录,已删减
private void advanceRunState(int targetState) {
    for (;;) {
        int c = ctl.get();
        if (runStateAtLeast(c, targetState) ||
            ctl.compareAndSet(c, ctlOf(targetState, workerCountOf(c))))
            break;
    }
}

CAS 的期望值是整个 ctl,所以"把状态改成 STOP"和"期间计数有变化"不会互相覆盖:计数变了,CAS 失败,重读后再拼一次。源码注释还留了口子:workerCount 被限制在 2^29-1(约五亿),“如果将来不够用,可以把变量改成 AtomicLong 并调整移位常量,但在需要之前,用 int 更快也更简单”。热路径上这一点很实在:addWorker 的核心动作就是一次 CAS,拆成两个变量要么加锁、要么两次 CAS 并处理中间态。

RUNNING 为什么是负数

五个常量是 -1 << 29、0 << 29、1 << 29、2 << 29、3 << 29,按有符号 int 排序恰好是 RUNNING(-536870912) < SHUTDOWN(0) < STOP(536870912) < TIDYING(1073741824) < TERMINATED(1610612736)。

源码注释对此有一句关键的话:“The numerical order among these values matters, to allow ordered comparisons. The runState monotonically increases over time, but need not hit each state.” 三件事因此变得很便宜:

// JDK 25 源码摘录,已删减
private static boolean runStateLessThan(int c, int s) { return c < s; }
private static boolean runStateAtLeast(int c, int s)  { return c >= s; }
private static boolean isRunning(int c)               { return c < SHUTDOWN; }
  • “至少到某个状态"一次比较就够。 addWorker 判断池不再接新任务用 runStateAtLeast(c, SHUTDOWN),getTask 判断要不要直接退出用 runStateAtLeast(c, STOP)。换成枚举,这里要么两次比较,要么一次 ordinal()。
  • “状态只会往前走"由数值单调保证。 回退不可能发生,因为 advanceRunState 的目标值总不小于当前值,且 CAS 只在 c 未变时成功。
  • isRunning 退化成 c < 0。 RUNNING 编码成 111xxxxx,让所有运行中的 ctl 都是负数,与 SHUTDOWN 的 0 分界清晰,execute 热路径用最便宜的一次判断挡掉"池已关闭”。

RUNNING 取 -1 << 29 而不是别的值,本质是让符号位的语义和状态序一致:最高三位全 1 时数值最负,正好对应状态序最小;同时空出向下的编码空间,将来若要在 RUNNING 之前加状态也不必改动既有常量。

五个状态与单向转移

状态机只有五条边,源码注释列全了:

转移触发条件触发点
RUNNING → SHUTDOWN调用 shutdown()advanceRunState(SHUTDOWN)
RUNNING 或 SHUTDOWN → STOP调用 shutdownNow()advanceRunState(STOP)
SHUTDOWN → TIDYING队列为空且 workerCount == 0tryTerminate()
STOP → TIDYINGworkerCount == 0(不看队列)tryTerminate()
TIDYING → TERMINATEDterminated() 钩子执行完毕tryTerminate() 最后一步
  • shutdown() 不会走到 STOP。 它只把状态推到 SHUTDOWN,已排队的任务继续执行;只有 shutdownNow() 才推到 STOP 并中断正在跑的任务。
  • SHUTDOWN → TIDYING 要"队列空"和"计数零"同时成立。 队列空但还有 worker 在跑时,tryTerminate 用 interruptIdleWorkers(ONLY_ONE) 叫醒一个空闲线程,让它自己重新检查状态并退出,而不是原地等。
  • TIDYING 只停一瞬间。 进入 TIDYING 的线程先跑 terminated() 钩子,再在 finally 里 ctl.set(ctlOf(TERMINATED, 0)),所以外部几乎观察不到 TIDYING,能稳定看到的是 TERMINATED。

用反射看真实线程池的 ctl

常量是算出来的,真实池的 ctl 才是可信的。下面这段程序反射读私有字段,在几个关键时点各拍一张快照。--add-opens 只是实验手段,不进正文代码:

import java.lang.reflect.Field;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/** 读取运行中线程池的 ctl。仅用于观察,不要在生产代码里这么做。 */
public final class CtlProbe {
    static final Field CTL;
    static {
        try {
            CTL = ThreadPoolExecutor.class.getDeclaredField("ctl");
            CTL.setAccessible(true);
        } catch (ReflectiveOperationException e) { throw new ExceptionInInitializerError(e); }
    }

    static int ctlOf(ThreadPoolExecutor p) throws Exception {
        return ((AtomicInteger) CTL.get(p)).get();
    }

    static void dump(String label, ThreadPoolExecutor p) throws Exception {
        int c = ctlOf(p);
        System.out.printf("%-32s ctl=%-12d 0x%08X rs=%-11d wc=%d poolSize=%d%n",
                label, c, c, c & 0xE0000000, c & 0x1FFFFFFF, p.getPoolSize());
    }

    public static void main(String[] args) throws Exception {
        ThreadPoolExecutor p = new ThreadPoolExecutor(2, 6, 30, TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(2), Thread.ofPlatform()::unstarted);
        dump("新建后", p);

        CountDownLatch hold = new CountDownLatch(1);
        for (int i = 0; i < 2; i++) p.execute(() -> await(hold));
        Thread.sleep(200);
        dump("2 个核心线程在跑", p);

        for (int i = 0; i < 2; i++) p.execute(() -> await(hold));
        Thread.sleep(100);
        dump("队列塞满但未超 core", p);

        for (int i = 0; i < 2; i++) p.execute(() -> await(hold));
        Thread.sleep(200);
        dump("队列满后扩容到 4", p);

        p.shutdown();
        dump("shutdown() 之后", p);

        hold.countDown();
        p.awaitTermination(5, TimeUnit.SECONDS);
        dump("terminated", p);
    }

    static void await(CountDownLatch l) {
        try { l.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }
}
javac --release 25 -d out CtlProbe.java && java --add-opens java.base/java.util.concurrent=ALL-UNNAMED -cp out CtlProbe

本机输出:

新建后                              ctl=-536870912   0xE0000000 rs=-536870912  wc=0 poolSize=0
2 个核心线程在跑                        ctl=-536870910   0xE0000002 rs=-536870912  wc=2 poolSize=2
队列塞满但未超 core                     ctl=-536870910   0xE0000002 rs=-536870912  wc=2 poolSize=2
队列满后扩容到 4                        ctl=-536870908   0xE0000004 rs=-536870912  wc=4 poolSize=4
shutdown() 之后                    ctl=4            0x00000004 rs=0           wc=4 poolSize=4
terminated                       ctl=1610612736   0x60000000 rs=1610612736  wc=0 poolSize=0
  • 第三行 ctl 完全没变。队列满时没超核心线程数,任务只是排队,workerCount 保持 2——这正是 execute 第二步"入队成功就返回"的体现。
  • shutdown() 之后 rs=0 而 wc=4 仍是 4。同一个 int 里,状态和计数各自独立变化,这一行把"高 3 位 / 低 29 位"讲得比任何图都清楚。
  • terminated 时 ctl=1610612736(0x60000000),正是 3 << 29。

Worker 为什么要继承 AQS

Worker 继承 AbstractQueuedSynchronizer,但只用了一把最简的、不可重入的互斥锁:

// JDK 25 源码摘录,已删减
private final class Worker
    extends AbstractQueuedSynchronizer
    implements Runnable
{
    final Thread thread;
    Runnable firstTask;
    volatile long completedTasks;

    Worker(Runnable firstTask) {
        setState(-1); // inhibit interrupts until runWorker
        this.firstTask = firstTask;
        this.thread = getThreadFactory().newThread(this);
    }

    public void run() {
        runWorker(this);
    }

    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;
    }

    public void lock()        { acquire(1); }
    public boolean tryLock()  { return tryAcquire(1); }
    public void unlock()      { release(1); }
}

锁的语义是反过来的:不是"拿到锁才能跑任务”,而是"跑任务期间一直持有锁"。 runWorker 在 task.run() 之前 w.lock(),跑完在 finally 里 w.unlock()。于是 state == 1 等价于"这个 worker 正在执行任务",tryAcquire 成功等价于"这个 worker 此刻空闲、正在等任务"。

这个等价关系是关闭流程的基础。interruptIdleWorkers 只中断"能抢到锁"的 worker:

// JDK 25 源码摘录,已删减
for (Worker w : workers) {
    Thread t = w.thread;
    if (!t.isInterrupted() && w.tryLock()) {
        try {
            t.interrupt();
        } finally {
            w.unlock();
        }
    }
    if (onlyOne)
        break;
}

正在跑任务的 worker 锁在自己手里,tryLock 返回 false,不会被中断;空闲等任务的 worker 没持锁,tryLock 成功,被 interrupt 唤醒后从 workQueue.take() 抛 InterruptedException,回到 getTask 循环重新判断状态。“是否空闲"因此不需要额外的布尔字段,也不需要和任务执行做二次同步——锁状态本身就是答案。

不用 ReentrantLock 的原因,源码注释写得很直白:“we do not want worker tasks to be able to reacquire the lock when they invoke pool control methods like setCorePoolSize”。假设任务内部调用 setCorePoolSize,会走到 interruptIdleWorkers;如果锁可重入,当前 worker 的 tryLock 会成功,然后 interrupt() 自己,把正在执行的任务打断。不可重入让 state == 1 时 tryAcquire 必然失败,这一刀自动避开。

构造函数里的 setState(-1) 是另一个细节。interruptIfStarted 只在 getState() >= 0 时中断,初值 -1 表示"线程还没真正开始跑”,中断被抑制;runWorker 第一件事是 w.unlock() 把状态清回 0,中断才被允许。这堵住了"worker 刚创建、还没进入 runWorker 就被 shutdownNow 中断"的窗口。

在 libraryd-conc 里落地

PooledLoanService(卷一第 10 章)把借阅请求交给一个 ThreadPoolExecutor。ctl 是私有的,生产代码不应该反射它,但可以按同样的口径做一个只读快照,用于健康检查和告警:

package com.plume.conc.core;

import java.util.concurrent.BlockingQueue;

/** 借阅池的只读快照。各字段独立读取,不构成同一瞬间的一致视图。 */
public record PoolSnapshot(int poolSize, int activeCount, int queueSize, int remainingCapacity) {

    public boolean saturated() {
        return remainingCapacity == 0 && activeCount == poolSize;
    }
}

// 在 PooledLoanService 中新增
public PoolSnapshot snapshot() {
    BlockingQueue<Runnable> q = pool.getQueue();
    return new PoolSnapshot(pool.getPoolSize(), pool.getActiveCount(),
            q.size(), q.remainingCapacity());
}

getActiveCount() 的 javadoc 明确写了是近似值,getCompletedTaskCount() 也是"大约完成的任务数"。这几个数分别来自 workers 集合、completedTaskCount 字段和队列自身的锁,读的时刻不同,拼在一起只能当趋势看。真正的判断逻辑要留余量:saturated() 为真意味着"队列满了且所有线程都在忙",这时 submitAndWait 里的拒绝开始计数——拒绝率比瞬时队列长度更能说明问题,这是卷一第 10 章拒绝策略那套指标的自然延伸。

这个设计的代价

第一,2^29 的上限是硬编码的。 maximumPoolSize 会被 & COUNT_MASK,超过 536870911 的部分直接被截断,new ThreadPoolExecutor(…, Integer.MAX_VALUE, …) 实际上是 536870911。工程上开不到这么大,但配置校验时要知道这个上限存在。

第二,状态与计数共享一个字,所有变更互相竞争。 addWorker 的计数 CAS、shutdown 的状态 CAS、getTask 里超时退出的计数 CAS,全部落在同一个 AtomicInteger 上。线程数频繁起落的池会出现 CAS 重试,这是合并字段的代价;它换来的是一致快照,在低频的控制面上划算,在高频的数据面上不划算——这也是 LongAdder 反而要把计数拆开的原因。

第三,workerCount 不等于"活着的线程数"。 源码注释说它表示"已允许启动、且还没被允许停止"的线程数,在工厂创建线程失败、线程退出但还在收尾记账时,它会和真实线程数短暂不一致。getPoolSize() 读的是 workers 集合大小,两者也不保证相等:做容量规划用 getPoolSize(),做状态判断用 ctl,不要混。

第四,不要把这套位压缩照搬到业务代码。 它成立的前提很苛刻:两个字段取值范围固定、可以牺牲计数上限、状态序与数值序一致、所有更新走同一个 CAS。业务对象很少有这些性质,硬套只会得到一堆难调试的魔数。

最后,不要用反射读 ctl 做监控。 它依赖 --add-opens,将来可能被模块系统挡住,读到的是内部表示而非契约。要观测就用 getPoolSize / getActiveCount / getQueue(),接受它们不是同一瞬间的快照。

小结

ctl 用一个 int 同时表达池的状态和线程数:高 3 位 runState、低 29 位 workerCount,掩码保证计数加减不进位,热路径只需一次 CAS。RUNNING 取 -1 << 29 让数值序与状态序一致,runStateAtLeast、isRunning 退化成整数比较。Worker 继承 AQS 获得一把不可重入的锁,用"是否持锁"表达"是否正在跑任务",中断因此只落到空闲线程身上。

阅读导航:上一节 10.3 无锁 vs 有锁实测 | 下一节 11.2 addWorker、runWorker 与退出

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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