Skip to content

AQS 源码走读:一个 int + 一个队列撑起 JUC

本文是 Java 并发系统学习系列的 L2 核心篇。前置:volatile 可见性、有序性与使用边界。 学完可以配合面试题食用:AQS 原理与 ReentrantLock 实现CountDownLatch/CyclicBarrier/SemaphoreLockSupport park/unpark

为什么 JUC 需要一个"骨架"类

假设你要自己写一个互斥锁,最少要处理几件事?抢锁(CAS 改状态)、抢不到就排队(线程安全地把线程挂进队列)、阻塞自己(LockSupport.park)、锁释放后唤醒后继(unpark)、中途取消还不能弄坏队列。写完 Mutex 再写一个倒计时门闩,你会发现排队、阻塞、唤醒、取消这套代码几乎要原样抄一遍。

Doug Lea 的做法是把这套和"锁语义无关、但每个同步器都要"的机制抽成一个抽象类,就是 AbstractQueuedSynchronizer(AQS)。子类只需要回答一个问题:state 在什么条件下算获取成功、怎么改。排队、阻塞、唤醒、取消、公平性兜底,AQS 全包了。

回头看 JUC 的类图会很感慨:ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock、线程池的 Worker、甚至 JDK 9 后 CompletableFuture 的等待链,底下都是同一个 AQS。一个 volatile int + 一条 CLH 变体队列,撑起了半个 JUC。

骨架:state + CLH 变体队列 + 两种模式

AQS 的全部家当就三样:

  • volatile int state:同步状态的唯一权威载体。锁场景里 0 是没人持有、1 是持有、>1 是重入层数;Semaphore 里是剩余许可数;CountDownLatch 里是未完成的计数。对 state 的修改都走 CAS 或在持锁线程内进行,配合 volatile 保证可见性——上一篇 volatile 讲的 happens-before 在这里落到实战。
  • CLH 变体双向队列:等待线程排成 FIFO 双向链表,每个节点(Node)持有一个线程引用和一个 waitStatus。说它是"变体",因为原始 CLH 是自旋等前驱的隐式单向队列,AQS 改成了显式双向链表 + park 阻塞 + 前驱唤醒。
  • exclusive / shared 两种模式:独占模式同一时刻只有一个线程能持有(Mutex、ReentrantLock),共享模式允许多个线程同时通过(Semaphore、CountDownLatch、读锁)。两种模式各有一套 tryAcquire/tryRelease 与 tryAcquireShared/tryReleaseShared 钩子,队列唤醒逻辑也分叉(共享模式拿到后会传播唤醒后继)。

结构长这样:

mermaid
flowchart LR
    subgraph AQS
        S["state (volatile int)"]
        H["head (哨兵)"] <--> N1["Node: 线程A ws=SIGNAL"] <--> N2["Node: 线程B ws=SIGNAL"] <--> T["tail"]
    end
    H -. 唤醒 .-> N1

注意 head 是个哨兵节点,不持有线程;真正等锁的是 head.next 往后。waitStatus 里最关键的是 SIGNAL(-1),含义是"我释放时会负责唤醒我的后继"——这个约定是队列协作的核心,后面流程里反复出现。

acquire/release 主流程走读

以独占模式 acquire(1) 为例,JDK 源码骨架可以浓缩成下面这段伪代码(按 JDK 8 逐行注释,别怕,总共就三层):

java
// 第一层:acquire 的入口,模板方法
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&                    // ① 先试一次获取(子类实现)
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))  // ② 失败则入队并自旋+阻塞
        selfInterrupt();                       // ④ 补上中断位( park 期间被中断过)
}

// 第二层:入队
private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);
    Node pred = tail;
    if (pred != null && node.prev = pred, CAS tail(node)) {  // 快路径:直接接到尾
        pred.next = node;
        return node;
    }
    enq(node);   // 慢路径:tail 还没初始化或 CAS 失败,自旋重试(含建哨兵)
    return node;
}

// 第三层:队列里的核心循环——"坐着不动"
final boolean acquireQueued(Node node, int arg) {
    for (;;) {
        Node p = node.predecessor();
        if (p == head && tryAcquire(arg)) {    // 前驱是 head:有资格再抢一次
            setHead(node);                     // 自己晋升为新 head(哨兵)
            p.next = null;
            return false;
        }
        if (shouldParkAfterFailedAcquire(p, node) &&   // 把前驱 waitStatus 改成 SIGNAL
            parkAndCheckInterrupt())                  // park 自己,醒来检查中断
            return true;                              // ③ 等待中被中断
    }
}

三件事值得盯着看:

为什么要 shouldParkAfterFailedAcquire 先把前驱改成 SIGNAL 再 park? 防丢唤醒。释放锁的线程只会唤醒 waitStatus 是 SIGNAL 的节点的后继;如果不先立好这个标志就 park,释放方会以为"没人要我唤醒",park 的线程就永远醒不来。这是典型的"先约定、后睡觉"模式。

为什么抢锁条件是 p == head 队列保证 FIFO 顺序,但 head 可能正在释放锁。只有前驱是 head 的节点才有资格尝试 CAS,其余节点连试都不试,直接 park——这就是队列对公平性的保证来源(对入队后的线程而言)。

入队 CAS 失败怎么办? enq 里 for(;😉 自旋重试,直到 CAS 成功。多线程并发入队时总有输家,输了就再试一次,这是无锁算法的标准写法。

release 是 acquire 的镜像,短得多:

java
public final boolean release(int arg) {
    if (tryRelease(arg) &&               // 子类实现:state 减到 0 才返回 true
        headStatus == SIGNAL) {
        unparkSuccessor(head);           // 唤醒 head.next(若无效则从尾向前找第一个有效节点)
    }
    return true;
}

一个细节:unparkSuccessor 里如果 head.next 为 null 或已取消,源码是从 tail 向前遍历找第一个有效节点——因为节点入队时先设 prev 再 CAS tail,next 指针的设置可能滞后,从后往前才能保证不漏。

ReentrantLock:公平与非公平只差 tryAcquire

AQS 定了骨架,ReentrantLock 只需要实现 tryAcquire/tryRelease。JDK 源码里公平和非公平是两个内部类,差异集中在一个判断:

java
// 非公平版(NonfairSync)
final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
    // 上来直接 CAS state:0 -> acquires,抢到就是我的
}

// 公平版(FairSync)
final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&      // 唯一差异:队列里有人排队就绝不插队
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // 重入逻辑两版相同:c != 0 且 owner 是自己,state+1
}

非公平锁吞吐更高的原因就在这一处:刚释放的锁被新来的线程直接抢走,正在排队的线程继续 park,省了一次线程切换。代价是队列中的线程可能长时间抢不到(饥饿),但队列本身仍在推进,所以实践中 new ReentrantLock() 默认非公平。

还要提一句 acquireQueued 里那个 p == head && tryAcquire 的"再抢一次":即使公平锁,线程被唤醒后也要重新走 tryAcquire(重新查 hasQueuedPredecessors),不是醒了就必然拿到。AQS 唤醒的语义是"给你一次竞争资格",不是"给你锁"。

一个骨架养活四个类:各自的复用姿势

看懂骨架后,其他同步器只是换了 state 的语义:

  • ReentrantLock:state 0/1/N 表无主/持有/重入层数;独占模式;释放必须持锁线程本人(tryRelease 里校验 owner)。
  • CountDownLatch:state = 剩余计数;共享模式获取(await 是 tryAcquireShared,要求 state==0 才放行);countDown 就是 tryReleaseShared 把 state 减 1,减到 0 时唤醒队列里所有等 shared 的节点并传播唤醒(setHeadAndPropagate——一个通过就把后面连续的 shared 节点全放行)。
  • Semaphore:state = 许可数;acquire 是"减",release 是"加";同一份代码支持公平/非公平两版 tryAcquireShared。
  • ReentrantReadWriteLock:最秀的一个——一个 int 拆两半用:高 16 位存读锁持有数,低 16 位存写锁重入数。读锁用共享模式(多个读者可同时通过),写锁用独占模式,一把 AQS 同时服务两种语义。

这张对照表值得记(同样建议自己默写一遍):

  • 换语义不换机制:四个类没写一行排队/阻塞/唤醒代码,全是 state 解释不同
  • 共享 vs 独占的判据:允许多个线程同时"通过"就用 shared(await/acquire/readLock),互斥就用 exclusive
  • 传播唤醒只在 shared 出现:一个节点通过后如果还有余量(state 允许),继续唤醒后继

动手实操:30 行手写一个 Mutex

理解 AQS 最快的路是自己当一次子类。下面这个 Mutex 完整可运行,30 行内实现不可重入互斥锁:

java
import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class Mutex {
    private final Sync sync = new Sync();

    // 全部逻辑在 tryAcquire/tryRelease 里:state 0=空闲, 1=持有
    private static class Sync extends AbstractQueuedSynchronizer {
        @Override
        protected boolean tryAcquire(int acquires) {
            if (compareAndSetState(0, 1)) {          // CAS 抢锁,非公平
                setExclusiveOwnerThread(Thread.currentThread());
                return true;
            }
            return false;                             // 抢不到,AQS 会安排排队+park
        }
        @Override
        protected boolean tryRelease(int releases) {
            if (getState() == 0) throw new IllegalMonitorStateException();
            setExclusiveOwnerThread(null);
            setState(0);                              // 已在锁内,直接写,不需要 CAS
            return true;                              // 返回 true -> AQS 唤醒后继
        }
    }

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

    public static void main(String[] args) throws InterruptedException {
        Mutex m = new Mutex();
        Runnable task = () -> {
            m.lock();
            try {
                System.out.println(Thread.currentThread().getName() + " in");
                Thread.sleep(100);
            } catch (InterruptedException ignored) {
            } finally {
                m.unlock();
            }
        };
        for (int i = 0; i < 3; i++) new Thread(task, "T" + i).start();
        // 输出必然串行:T0/T1/T2 依次 in,间隔约 100ms——排队与唤醒 AQS 全包了
    }
}

两个细节呼应前文:tryRelease 里用 setState(0) 而不是 CAS,因为执行到这里必然持有锁,不存在竞争;tryAcquire 只有 CAS 一条路,所以这个锁是非公平的——想改公平,先查 hasQueuedPredecessors()

拿它和 ReentrantLock 对比着玩:把 tryAcquire 改成允许重入(state 判断 owner),就得到了一个简版 ReentrantLock;把 tryAcquire 改成读 tryAcquireShared 且 state 初始为 N,就得到了简版 Semaphore。同步器的个性全在 tryXxx 的几行里,这就是骨架的意义。

常见误区与小结

  • 误区一:AQS 队列是原始 CLH。 是变体:原始 CLH 自旋等前驱、隐式单向;AQS 是显式双向链表 + park 阻塞 + 前驱释放时唤醒后继,还处理取消与超时。
  • 误区二:被唤醒就能拿到锁。 unpark 只给竞争资格,醒来后重新走 tryAcquire,公平锁还要重查队列,非公平锁可能被新来的插队。
  • 误区三:park 之前忘了立 SIGNAL。 shouldParkAfterFailedAcquire 把前驱 waitStatus 置为 SIGNAL(-1) 是"释放方会唤醒我"的约定,跳过它 park 就可能永远没人叫醒。
  • 误区四:state 只能表示锁。 state 就是个 volatile int,语义完全由 tryAcquire/tryRelease 定义——重入数、许可数、倒计时、甚至读写锁的高低 16 位拆分。

小结:AQS 用模板方法把"排队阻塞唤醒"做成公共骨架,把"什么算获取成功"留给子类。读源码的顺序建议先 acquire 三层(入口 -> addWaiter -> acquireQueued)再 release,配合手写 Mutex 吃透。它在学习路径上是 JMM/volatile 之后的实战检验——你终于看到了 volatile + CAS + 队列怎么组装成生产级同步器。下一篇进入 并发容器全景,看 ConcurrentHashMap、CopyOnWriteArrayList 这些日常更常打交道的工具。

参考

  • OpenJDK 源码 java.util.concurrent.locks.AbstractQueuedSynchronizer(JDK 8 与 JDK 17+ 各读一遍,JDK 17 有小幅重构但主流程一致)
  • Doug Lea 的 AQS 说明文档 AbstractQueuedSynchronizer 类头 javadoc(设计动机与 CLH 变体的第一手解释)
  • 《Java 并发编程的艺术》第 5 章(AQS 章节的中文化梳理,适合对照源码阅读)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1