Skip to content

学了 ReentrantLock、读写锁、SemaphoreCountDownLatch 之后,可能有过一个疑问:这一堆工具,底层是不是各写各的?不是,它们底下全是同一个东西——AQS(AbstractQueuedSynchronizer,抽象队列同步器)。JUC 里大半的同步工具,都是在这一个框架上搭出来的。这篇把它拆开看:state 是什么、队列怎么排、线程怎么挂起又怎么被唤醒。理解了 AQS,上面那一堆工具就不再是几个孤立的 API,而是同一套机制的不同用法。

上游文章:《ReentrantLock 详解》《读写锁详解》讲用法,这篇讲它们共同的底座。并发全景看《Java 并发编程》。


一、AQS 是什么:一个"抢座位 + 排队"的框架

把 AQS 想象成一个饭馆的座位管理:

  • 一个座位标记(state):一个 volatile int,表示资源状态。0 空闲、1 被占;具体什么含义由子类定义——ReentrantLock 里是"重入次数",Semaphore 里是"剩余许可数",CountDownLatch 里是"还差几个"。
  • 一个排队队列(CLH 双向队列):抢座失败的线程,排进一条 FIFO 队列,排队期间挂起(park),不占 CPU。
  • 一套唤醒规则:座位空出来(state 变了),叫醒队头的下一个线程,让它重新去抢。

AQS 自己不定义"什么算抢到、什么算释放",它只管状态管理 + 排队 + 挂起唤醒这些通用部分;"具体怎么才算抢到锁"由子类实现。这就是类名里"Abstract"的含义——模板方法模式

二、核心三件套

1. state:一个 volatile int

java
// AQS 内部的核心字段(简化)
private volatile int state;
  • volatile 保证可见性:state 改了,所有线程立刻看到。
  • 改它用的是 CASUnsafe.compareAndSwapInt):0 → 1 只有一个线程能成功,这就是"抢锁"的原子基础。

state 的含义完全由子类定义,这是 AQS 能派生出一堆工具的原因:

工具state 的含义
ReentrantLock0 空闲,N 被占用且重入了 N 次
Semaphore剩余许可数
CountDownLatch还剩几个计数
ReentrantReadWriteLock高 16 位读线程数,低 16 位写状态

最后一条解释了读写锁怎么用一个 int 管两种锁——把 int 按位拆成两半用。

2. CLH 队列:抢不到的排进双向链表

抢 CAS 失败的线程,被包装成一个 Node,追加到队列尾部:

head → [Node 线程A·等待] ⇄ [Node 线程B·等待] ⇄ [Node 线程C·等待] ← tail
         (head 是占位/刚拿到锁的)

每个 Node 记着:线程引用、等待状态(waitStatus)、前后指针。排队期间线程被 LockSupport.park() 挂起——操作系统层面不参与调度,零 CPU 消耗,比自旋干等省资源。

3. park / unpark:线程的"停"与"叫醒"

LockSupport.park() 让当前线程挂起,unpark(thread) 叫醒指定线程。比 wait/notify 好用的地方:不需要锁、不需要先 wait 后 notify 的顺序,unpark 可以先发——叫醒信号攒着,线程 park 时直接通过。

三、独占模式:抢锁全流程

ReentrantLock 非公平锁为例,线程调 lock() 后发生的事:

java
// AQS.acquire 的骨架(简化)
public final void acquire(int arg) {
    if (!tryAcquire(arg)                  // 1. 先试着抢(子类实现怎么算抢到)
        && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // 2. 抢不到 → 入队 → 挂起等
        selfInterrupt();
}

完整走一遍:

  1. CAS 抢 state0 → 1,成功就拿到锁,完事(这就是"非公平"——不用管队列里有没有人在等,先抢一把)。
  2. 失败 → 入队:包装成 Node 追加到队尾。
  3. 挂起前再检查一次:看自己的前驱是不是 head——如果队列里自己排第一个,说明轮到自己了,再试一次抢(前驱释放锁的瞬间 state 可能刚空出来)。
  4. 还不行 → park() 挂起,安静等。
  5. 持锁线程 unlock() → state 减到 0 → unpark 队列里 head 的下一个节点
  6. 被叫醒的线程从 park 处醒来,回到第 3 步再抢。

这张图把全流程画出来:

AQS 独占模式加锁流程

公平锁和非公平锁差在哪

现在能精确回答这个问题了——差异只在第 1 步

java
// 非公平锁的 tryAcquire:上来就 CAS,插队
final boolean tryAcquire(int acquires) {
    return (compareAndSetState(0, acquires) && setExclusiveOwnerThread(...)) // 先抢再说
        || nonfairTryAcquire(...);
}

// 公平锁的 tryAcquire:多一个检查——队列里有人排我前面吗?
final boolean tryAcquire(int acquires) {
    if (compareAndSetState(0, acquires)) {
        // ...
    }
    // 关键差异:
    else if (hasQueuedPredecessors())  // 有人排队 → 我不抢,乖乖去排队
        return false;
}

非公平锁吞吐量高,就是省掉了"唤醒排队线程"这段开销;代价是刚来的线程可能一直插队,排队的可能饿死。

可重入在哪

也在 tryAcquire 里:CAS 之前先查"state 非零且持锁线程是我自己"→ 是的话 state 直接加 1,不用抢。第一篇实跑验证的 state 累加(0→1→2)就是这条路径。

四、共享模式:state 够减就放行

独占模式是"一个座位",共享模式是"一批座位"——Semaphore、读锁、CountDownLatch.await 都是共享模式:

java
// AQS.acquireShared 的骨架(简化)
public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)   // 子类实现:state 够减吗?
        doAcquireShared(arg);        // 不够 → 入队挂起(和独占类似)
}

区别在两点:

  • 判断标准:不是 state == 0 才成功,而是 tryAcquireShared 返回"剩余额度"——Semaphore(3) 只要 state > 0 就能进,进一个减一个。
  • 唤醒会传播:释放时如果资源还有剩余,会连续唤醒队里的一串线程(setHeadAndPropagate),让多个等待者一起进。

实跑验证过:Semaphore(3) 反射读底层 state 初始就是 3,acquire 一次减到 2;4 个线程同时跑,峰值并发恰好 3。

五、AQS 派生了哪些工具

看完机制就明白为什么说"JUC 半壁江山都建在 AQS 上":

工具用了 AQS 的什么
ReentrantLock独占模式,state = 重入次数
ReentrantReadWriteLock独占 + 共享混合,state 按位拆两半
Semaphore共享模式,state = 许可数
CountDownLatch共享模式,state 倒计数
ThreadPoolExecutor(Worker)独占模式做线程占用标记

子类只需要重写几个 tryXxx 方法,排队、挂起、唤醒、并发安全全由 AQS 兜底——这就是模板方法模式的教科书级应用

小结

  • AQS = volatile state(CAS 抢占)+ CLH 双向队列(FIFO 排队)+ park/unpark(挂起唤醒)
  • state 含义由子类定义:重入数 / 许可数 / 倒计数 / 读写各半——一个框架派生一个家族
  • 独占模式:CAS 抢不到 → 入队 → park → 前驱释放时 unpark 醒来再抢
  • 公平 vs 非公平只差一步:非公平上来就 CAS(可插队),公平先查 hasQueuedPredecessors
  • 可重入 = tryAcquire 里发现持锁线程是自己,state 直接累加
  • 共享模式 = state 够减就放行,释放时唤醒会传播
  • 子类只写 tryXxx,排队挂起全托管——模板方法模式

到这里,锁系列的主线就通了:用法看《ReentrantLock 详解》和《读写锁详解》,全景地图看《锁的分类与实现原理》,CAS 的底层细节在《Java 并发编程》里也有展开。单机锁管不到多台机器,跨机器的互斥看《Redis 分布式锁详解》。