《Java 并发编程·JUC 源码与容器》1.1 AQS 的 state 与 CLH 变体队列

以 JDK 25 源码为准,拆开 AQS 的 state、head/tail 与 Node 四个字段,给出 javap 与反射实测输出,讲清旧版 waitStatus 四个常量到新版三个状态位的取舍,并解释为什么选择 CLH 变体队列以及它的适用边界。

承接的问题

AQS(AbstractQueuedSynchronizer)是 JUC 里被继承最多的类。ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock 的 Sync,以及 ThreadPoolExecutor 的 Worker,都直接或间接建立在它上面。读懂它,后面十四章才有共同的坐标系。

先交代版本。网上大量源码笔记(包括不少面试资料)描述的是旧实现(本机用 Zulu 8u482 对照):Node.waitStatus 有 CANCELLED、SIGNAL、CONDITION、PROPAGATE 四个常量,acquire 走 addWaiter → acquireQueued。JDK 25 是另一套实现。判断方法很简单:打开 AbstractQueuedSynchronizer 搜 addWaiter,有就是旧实现,没有就是新实现。本节把两套口径都摆清楚,后续只以 JDK 25 为准。

卷一第 3 章已经讲过 volatile 与 CAS 的语义,这里不再推导,只问一个问题:这些字段为什么长这样。

三个字段撑起一个同步器

去掉所有方法,AQS 的实例状态只剩三个字段:

// JDK 25 源码摘录,已删减
private transient volatile Node head;
private transient volatile Node tail;
private volatile int state;

state 是一个 32 位整数,含义完全由子类定义。AQS 只提供 getState()、setState()、compareAndSetState() 三个操作,不理解它的业务含义:

子类state 的含义
ReentrantLock重入次数,0 表示未持有
Semaphore当前可用许可数
CountDownLatch剩余计数,归零后所有等待者通过
ReentrantReadWriteLock高 16 位读锁计数,低 16 位写锁重入次数

head 和 tail 指向等待队列。队列不是构造时创建的,而是第一次发生竞争时才懒初始化:第一个失败的线程用 CAS 造一个哑头节点,自己排在它后面。head 指向的节点通常是“已经拿到锁或正在退场”的节点,真正排队的线程从 head.next 开始。

// JDK 25 源码摘录,已删减
private Node tryInitializeHead() {
    for (Node h = null, t;;) {
        if ((t = tail) != null)
            return t;
        else if (head != null)
            Thread.onSpinWait();
        else {
            if (h == null) {
                try {
                    h = new ExclusiveNode();
                } catch (OutOfMemoryError oome) {
                    return null;
                }
            }
            if (U.compareAndSetReference(this, HEAD, null, h))
                return tail = h;
        }
    }
}

这段代码暴露了三个设计点。第一,初始化用 CAS 竞争,谁赢谁同时设置 head 和 tail,输的人原地自旋。第二,分配节点可能 OOM,AQS 不抛异常,而是返回 null 让调用方进入退避路径,因为 JUC 组件在内存紧张时也不能直接失效。第三,head 与 tail 都是 volatile,入队只需要对 tail 做一次 CAS,这是一个明确的“未入队/已入队”分界点。

本机用一个只读反射的小程序打印字段清单,它不调用 setAccessible,也不修改任何字段:

import java.lang.reflect.Field;
import java.lang.reflect.Modifier;

public final class FieldProbe {
    public static void main(String[] args) throws Exception {
        for (String name : new String[] {
                "java.util.concurrent.locks.AbstractQueuedSynchronizer",
                "java.util.concurrent.locks.AbstractQueuedSynchronizer$Node" }) {
            Class<?> c = Class.forName(name);
            System.out.println("== " + c.getName());
            for (Field f : c.getDeclaredFields()) {
                System.out.printf("%-28s %-24s %s%n",
                        Modifier.toString(f.getModifiers()),
                        f.getType().getSimpleName(),
                        f.getName());
            }
        }
    }
}

JDK 25 上的真实输出:

== java.util.concurrent.locks.AbstractQueuedSynchronizer
private static final         long                     serialVersionUID
static final                 int                      WAITING
static final                 int                      CANCELLED
static final                 int                      COND
private transient volatile   Node                     head
private transient volatile   Node                     tail
private volatile             int                      state
private static final         Unsafe                   U
private static final         long                     STATE
private static final         long                     HEAD
private static final         long                     TAIL
== java.util.concurrent.locks.AbstractQueuedSynchronizer$Node
volatile                     Node                     prev
volatile                     Node                     next
                             Thread                   waiter
volatile                     int                      status
private static final         long                     STATUS
private static final         long                     NEXT
private static final         long                     PREV

javap -p -constants 给出常量的真实取值,命令与输出如下:

export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
"$JAVA_HOME/bin/javap" -p -constants \
  java.util.concurrent.locks.AbstractQueuedSynchronizer
"$JAVA_HOME/bin/javap" -p \
  'java.util.concurrent.locks.AbstractQueuedSynchronizer$Node'
(javap 输出节选,方法已省略)
Compiled from "AbstractQueuedSynchronizer.java"
public abstract class java.util.concurrent.locks.AbstractQueuedSynchronizer extends java.util.concurrent.locks.AbstractOwnableSynchronizer implements java.io.Serializable {
  static final int WAITING = 1;
  static final int CANCELLED = -2147483648;
  static final int COND = 2;
  private volatile transient java.util.concurrent.locks.AbstractQueuedSynchronizer$Node head;
  private volatile transient java.util.concurrent.locks.AbstractQueuedSynchronizer$Node tail;
  private volatile int state;
}

CANCELLED 打印成 -2147483648,就是 0x80000000,也就是符号位。这个选择不是随手写的:后面判断节点是否取消,只需比较 status < 0。

Node 的状态:从 waitStatus 四值到三个状态位

旧版(JDK 8 时代的实现)的 Node 有一个 volatile int waitStatus,用五个离散值表达节点处境。下面是对本机 Zulu 8u482 执行 javap -p -constants 的真实输出,仅作历史对照,不是 JDK 25:

(Zulu 8u482 的 javap -p -constants 输出节选,仅字段与常量)
final class java.util.concurrent.locks.AbstractQueuedSynchronizer$Node {
  static final java.util.concurrent.locks.AbstractQueuedSynchronizer$Node SHARED;
  static final java.util.concurrent.locks.AbstractQueuedSynchronizer$Node EXCLUSIVE;
  static final int CANCELLED = 1;
  static final int SIGNAL = -1;
  static final int CONDITION = -2;
  static final int PROPAGATE = -3;
  volatile int waitStatus;
  volatile java.util.concurrent.locks.AbstractQueuedSynchronizer$Node prev;
  volatile java.util.concurrent.locks.AbstractQueuedSynchronizer$Node next;
  volatile java.lang.Thread thread;
  java.util.concurrent.locks.AbstractQueuedSynchronizer$Node nextWaiter;
}

四个值的含义:

值含义谁来写
CANCELLED = 1节点已取消(超时或中断),不再参与竞争,后继会跳过它节点自己
SIGNAL = -1后继正在(或即将)阻塞,当前节点释放时必须 unpark 后继后继把前驱改成它
CONDITION = -2节点在条件队列里,不在同步队列里await 路径
PROPAGATE = -3共享模式下释放需要继续向后传播,防止唤醒丢失释放线程

注意 SIGNAL 的写法方向:它写在前驱身上,表示“我后面有人睡着,记得叫他”。这带来一个经典竞态:后继要先 CAS 把前驱改成 SIGNAL,失败就重试,成功后才能安全 park;前驱释放时又要 CAS 改回 0。两边都在改同一个字段,所以旧版的 shouldParkAfterFailedAcquire 要把失败重试交给外层循环,unparkSuccessor 要 CAS 清 SIGNAL,共享模式的 doReleaseShared 还要自己再套一层循环。

当前实现把这张“五值状态表”换成了位图。Node 只剩四个字段:

// JDK 25 源码摘录,已删减
abstract static class Node {
    volatile Node prev;       // initially attached via casTail
    volatile Node next;       // visibly nonnull when signallable
    Thread waiter;            // visibly nonnull when enqueued
    volatile int status;      // written by owner, atomic bit ops by others

    final boolean casPrev(Node c, Node v) {  // for cleanQueue
        return U.weakCompareAndSetReference(this, PREV, c, v);
    }
    final int getAndUnsetStatus(int v) {     // for signalling
        return U.getAndBitwiseAndInt(this, STATUS, ~v);
    }
    final void clearStatus() {               // for reducing unneeded signals
        U.putIntOpaque(this, STATUS, 0);
    }
}

状态位只有三个,全部定义在 AQS 里:

常量值作用
WAITING1线程已声明“我要睡了”,释放者清掉这一位并唤醒
COND2节点在条件等待;条件路径会写成 COND | WAITING
CANCELLED0x80000000符号位,status < 0 即取消

为什么能从四个常量简化成三个位?核心是把“叫醒义务”从前往后挪到了自己身上。

旧版必须由后继在前驱身上盖一个 SIGNAL 章,释放者才知道“后面有人”。新版让等待者自己在 park 前置 WAITING 位,释放者无条件清位并 unpark。如果清位发生在置位之前,等待者置位后重试获取;如果发生在置位之后,unpark 会让随后的 park 立即返回。这是典型的 Dekker 式握手,不再需要“前驱盖章、后继重试”的 CAS 拉锯。

CANCELLED 用符号位而不是普通位,是为了让 status < 0 成为一条指令的取消判断,队列清理时不用逐位比较。COND 与 WAITING 可以同时存在,源码里条件节点就是这样写的:node.setStatusRelaxed(COND | WAITING)。旧版做不到这一点,因为 waitStatus 是枚举语义,一个节点只能处于一种状态。

PROPAGATE 在新版彻底消失,原因在 1.3 展开:共享传播改由“刚成为 head 的共享节点主动唤醒下一个共享节点”驱动,不再靠释放线程在状态位里留一张“还要再传播一次”的便条。

这个设计的代价也很直接:状态位不是公开 API,字段名、位含义、唤醒协议都可能随版本变化;拿旧版 waitStatus 的模型去读 jstack 或调试器里的节点状态,会得到错误结论。

为什么用 CLH 变体

原始 CLH 队列是为自旋锁设计的:每个线程在自己的前驱节点上自旋,前驱释放时改一个标志,后继自然看到。它没有 next 指针,因为没人需要“找到”后继;它也不阻塞,因为自旋本身就是等待。

AQS 要解决的是阻塞式同步器,这两条都不成立。线程不能一直烧 CPU,必须 park;而 park 之后,只有别人能把它叫醒,所以释放者必须能定位后继。这就需要一个显式的 next 指针。取消、超时、中断又要求能把节点从队列中间摘掉,于是还需要 prev 指针做反向拼接。条件队列则是另一条链,需要额外的链接字段。

JDK 25 的类注释把这件事说得很清楚:

// JDK 25 源码摘录,已删减
The wait queue is a variant of a "CLH" (Craig, Landin, and
Hagersten) lock queue. CLH locks are normally used for
spinlocks.  We instead use them for blocking synchronizers by
including explicit ("prev" and "next") links plus a "status"
field that allow nodes to signal successors when releasing
locks, and handle cancellation due to interrupts and timeouts.
The status field includes bits that track whether a thread
needs a signal (using LockSupport.unpark). Despite these
additions, we maintain most CLH locality properties.

所以“CLH 变体”保留的是 CLH 的三个性质:入队只竞争 tail、队列天然 FIFO、每个节点只关心自己的前驱。放弃的是纯自旋和隐式后继。

维度原始 CLHAQS 变体
等待方式自旋LockSupport.park 阻塞
后继定位不需要显式 next
取消不支持prev + CAS 摘链
条件等待不支持ConditionNode 第二条链
公平性严格 FIFO允许新线程插队(barging)
自旋局部性在前驱节点上自旋保留大部分,唤醒后短暂重试

AQS 的注释自己承认,摘链时可能要求前一个 acquire 先完成,所以它不是严格 lock-free。这是为“阻塞 + 取消 + 共享模式”付出的代价,也是它比原始 CLH 复杂得多的原因。

什么时候不要用 AQS?如果你只是需要一个计数器,AtomicInteger 或 LongAdder 更合适;如果临界区极短、竞争极低,synchronized 的 JIT 优化可能更好(卷一第 4 章);如果是读多写少,StampedLock 的乐观读更划算(3.3);如果只是要一把可重入锁,直接用 ReentrantLock,不要自己继承 AQS。AQS 是给“要定义一种新的同步语义”的人用的,不是给“要用锁”的人用的。

在 libraryd-conc 里落地

conc-core 需要一个能保护库存读写的互斥点,同时把等待队列长度暴露给 conc-ops 做指标。直接用 ReentrantLock 当然可以,但这一章要看清 AQS 的骨架,所以先写一个最小的非重入版本:

package com.plume.conc.core;

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

/** 非重入库存守卫:state 只有 0/1 两种取值。 */
public final class AqsStockGuard {
    private final Sync sync = new Sync();

    private static final class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int arg) {
            return compareAndSetState(0, 1);
        }

        @Override
        protected boolean tryRelease(int arg) {
            if (!compareAndSetState(1, 0)) {
                throw new IllegalMonitorStateException("not owner");
            }
            return true;
        }

        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }

        int queueLength() {
            return getQueueLength();
        }
    }

    public int decrement(BookRepository books, String isbn) {
        sync.acquire(1);
        try {
            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 book.stock() - 1;
        } finally {
            sync.release(1);
        }
    }

    public int waitingThreads() {
        return sync.queueLength();
    }
}

这段代码只用到了 tryAcquire、tryRelease、isHeldExclusively 三个模板方法,其余全部由 AQS 处理:排队、阻塞、唤醒、取消。waitingThreads() 把 getQueueLength() 暴露给指标层,注意它只是估算值,不能拿来做业务判断。

有一个边界必须写进注释:这个守卫不可重入。同一个线程第二次调用 decrement 会永远等在自己持有的锁上,因为 tryAcquire 只认 0 → 1,不认“已经是我”。ReentrantLock 之所以要单独维护 owner 和 hold count,就是为了解决这一点(2.1、2.3)。

常见错误与边界

第一,把 state 当业务字段直接 setState。state 的所有读写都必须在同步协议内完成;tryAcquire 里如果没有 CAS,两个线程会同时认为自己拿到了锁。

第二,用 getQueueLength() 做限流或重试条件。它的注释明确写了“estimate”,遍历过程中队列会变。要做并发上限就用 Semaphore(4.1)。

第三,以为 AQS 保证公平。默认的 acquire 允许新来的线程在入队前先试一次 tryAcquire,这就是 barging;要公平必须自己实现 hasQueuedPredecessors() 检查(2.1)。

第四,跨版本套源码。JDK 25 没有 waitStatus、SIGNAL、PROPAGATE,也没有 addWaiter、acquireQueued、unparkSuccessor。读旧文章时先确认版本,否则会把已经删除的方法当成现实现。

第五,在 tryAcquire 里做阻塞操作。模板方法必须快速返回成功或失败,任何 sleep、I/O、等待都会把整个队列卡住。

第六,state 是 int,上限约 21 亿。重入次数或许够用,但把大计数直接塞进 state 之前要想清楚溢出语义。

小结

AQS 用一个 volatile int state 表达子类语义,用一对 head/tail 维护 CLH 变体队列,用 Node 的 prev/next/waiter/status 支撑阻塞、唤醒和取消。当前实现(本书引用 JDK 25)把旧版的 waitStatus 五值枚举换成了三个状态位,把“叫醒义务”从前驱转移给等待者自己,从而去掉了 SIGNAL 和 PROPAGATE。这不是细节优化,而是唤醒协议的重写;读懂它,才能读懂下一节那个统一的 acquire 循环。

阅读导航:上一节 目录与学习路径 | 下一节 1.2 独占模式的 acquire/release

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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