Skip to content
Charles Shao
Go back

AQS 与 Lock 家族:ReentrantLock、读写锁与 Condition

–views

ReentrantLock、ReentrantReadWriteLock、Semaphore、CountDownLatch……JUC 里这一大票同步器,底层大多落在同一个核心类:AbstractQueuedSynchronizer(AQS)。Doug Lea 巧妙地用「一个 volatile 的 state + 一个 FIFO 等待队列 + CAS」把「获取/释放同步状态」抽离成了模板方法,各同步器只需定义 state 的具体语义即可。

从 synchronized 与锁升级 过来,我们会发现,当业务场景需要超时控制、可中断获取、公平锁排队、多条件等待或是读写分离时,内置锁就显得捉襟见肘了,此时就该换到基于 AQS 的显式锁登场。

本文是 并发编程 / JUC 系列的第 3 篇(AQS 与显式锁)。 全系列 6 篇:

  1. JMM 与 volatile:可见性、有序性与内存屏障
  2. synchronized 与锁升级:对象头、Monitor 与偏向、轻量、重量级锁
  3. AQS 与 Lock 家族:ReentrantLock、读写锁与 Condition
  4. 并发容器与 CAS:ConcurrentHashMap 与无锁化原子类
  5. 线程池原理与调优:ThreadPoolExecutor 核心参数与执行流程
  6. 并发协作与异步编排:CountDownLatch、Semaphore 与 CompletableFuture

一句话定位:深入剖析 AQS 的同步状态流转与队列排队机制,彻底搞懂 ReentrantLock、读写锁与 Condition 的底层原理及高并发场景下的选型取舍。

TL;DR

Table of contents

Open Table of contents

1. 为什么需要 AQS

如果让你自己手搓一把锁,你必须处理这些棘手的问题:怎么表示”锁已被占用”、抢不到锁的线程往哪里放、怎么安全地挂起与唤醒线程、怎么保证这些操作本身的原子性与可见性、要不要支持公平排队……这些排队与阻塞的机制是所有同步器共通的底层需求,只有”什么状态算获取成功”这一规则因器而异。

AQS 就是把这些共通部分抽离出来的底层框架:它负责管理同步队列、调用 park/unpark 挂起与唤醒线程、处理 CAS 竞争等脏活累活,而把”state 怎么算获取成功”的逻辑留给子类去定义。于是,ReentrantLock 只需规定”state=0 代表可获取,获取成功则 +1”,Semaphore 只需规定”state 代表剩余的可用许可数”,CountDownLatch 则只需规定”state 减到 0 才允许放行”。

2. AQS 的三根支柱

2.1 state:volatile 的同步状态

private volatile int state;                       // 同步状态,语义由子类定义

protected final int getState() { return state; }
protected final void setState(int v) { state = v; }
protected final boolean compareAndSetState(int expect, int update) {
    return U.compareAndSetInt(this, STATE, expect, update);  // CAS,底层依赖 Unsafe
}

state 的具体含义完全由子类赋予:在 ReentrantLock 里它是重入次数,在读写锁里它被高低位拆分成了读写两段计数,在 Semaphore 里它则是剩余许可数量。所有对 state 的修改都必须通过 CAS 操作,以此保证高并发下的原子性。

2.2 CLH 变体等待队列

当线程抢不到同步状态时,会被封装成 Node 节点加入到一个双向 FIFO 队列(即 CLH 锁队列的变体)中。每个 Node 节点不仅记录了线程引用,还维护了等待状态(waitStatus,如 SIGNAL、CANCELLED、CONDITION、PROPAGATE)以及前驱和后继指针:

AQS 等待队列示意图。从左到右四个节点:head(dummy)→ T1(SIGNAL)→ T2(等待)→ T3(tail);旁注仅队首可 tryAcquire,前驱释放时 unpark 后继。底部说明:volatile state + 队列 + CAS 为骨架,子类只定义获取成功语义。 AQS 同步队列结构:队首 dummy 节点代表当前持锁者,后续节点依次排队,前驱节点释放时负责唤醒后继节点。

需要注意的是,队首的 dummy 节点是一个占位符,代表当前已经持锁(或者曾经持锁)的线程。当后续节点被唤醒并尝试获取同步状态成功后,它就会把自己设置为新的 head。在这个队列中,每个节点只监视它的前驱节点——前驱节点释放锁时负责唤醒它,这正是 CLH 变体能够保持低竞争开销的关键所在。

2.3 CAS + park/unpark

为了看清底层是怎么流转的,我们来看一段获取锁的自旋与阻塞逻辑(独占模式的简化版):

final boolean acquireQueued(Node node, int arg) {
    boolean interrupted = false;
    for (;;) {
        Node p = node.predecessor();
        if (p == head && tryAcquire(arg)) {   // 只有紧跟在 head 后面的队首节点才有资格尝试获取
            setHead(node);                     // 拿到锁,成为新 head
            p.next = null;
            return interrupted;
        }
        if (shouldParkAfterFailedAcquire(p, node)) // 把前驱节点的 waitStatus 置为 SIGNAL
            interrupted |= parkAndCheckInterrupt(); // 调用 LockSupport.park() 将当前线程挂起
    }
}

在这里,LockSupport.park() 和 unpark(thread) 提供了线程挂起与唤醒的底层原语。它们基于许可(permit)机制,比传统的 wait/notify 更加灵活——unpark 甚至可以先于 park 调用,许可会被保留,从而避免了丢失唤醒信号的风险。

3. 模板方法:独占与共享

AQS 巧妙地运用了模板方法模式,将”通用骨架”与”特定语义”彻底分离。子类只需按需重写以下几个方法(未重写的方法默认抛出 UnsupportedOperationException):

模式获取同步状态释放同步状态
独占 exclusivetryAcquire(int)tryRelease(int)
共享 sharedtryAcquireShared(int)tryReleaseShared(int)
通用isHeldExclusively()——

以对外的获取入口 acquire() 为例,其内部会先调用子类实现的 tryAcquire,只有当获取失败时,才会走入入队排队与 park 挂起的通用流程:

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&                       // 子类定义"能否获取成功"的语义
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))  // 获取失败则进入同步队列排队阻塞
        selfInterrupt();
}

独占模式意味着一次只能有一个线程持有同步状态(如 ReentrantLock 和写锁);而共享模式则允许多个线程同时持有(如读锁、Semaphore 和 CountDownLatch)。特别地,共享模式在获取成功后,还会触发向后传播(propagate) 机制,依次唤醒后续的共享节点,从而实现”一个释放,唤醒一串读线程”的连锁反应。

4. ReentrantLock:可重入独占锁

ReentrantLock 内部持有一个继承自 AQS 的 Sync 组件,这里的 state 被用来表示重入次数(0 代表锁空闲,n 代表同一个线程重入了 n 次)。

4.1 非公平获取(默认策略)

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {                                 // 锁处于空闲状态
        if (compareAndSetState(0, acquires)) {    // 直接通过 CAS 抢占,完全不看队列 → 允许插队
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {  // 当前线程已经持有该锁(重入)
        setState(c + acquires);                   // 重入计数 +1,无需 CAS(因为已经持锁,是线程安全的)
        return true;
    }
    return false;
}

4.2 公平获取

对比前一种方案,公平锁在尝试获取时仅仅多了一步判断——必须先检查同步队列里是否有前驱节点正在排队等待:

protected final boolean tryAcquire(int acquires) {
    // ... 省略部分代码
    if (c == 0) {
        if (!hasQueuedPredecessors() &&           // 关键差异:只有当队列中没有前驱节点时,才允许尝试抢占
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    // ... 省略部分代码
}

取舍与权衡:非公平锁允许刚到达的线程直接”插队”抢占,这极大地减少了线程被挂起和唤醒带来的上下文切换开销,因此吞吐量显著更高,也是 ReentrantLock 的默认选择;但其代价是,处于队列尾部的线程可能会长期抢不到锁,陷入饥饿等待。公平锁虽然严格遵循 FIFO 原则,彻底杜绝了饥饿问题,但频繁的唤醒与上下文切换会导致系统吞吐量大幅下降。除非业务场景明确要求必须防止饥饿,否则一律推荐使用默认的非公平锁。

4.3 正确使用姿势

使用显式锁时,必须在 finally 块中释放锁,否则一旦 try 块中抛出异常,锁将永远无法释放,导致死锁:

private final ReentrantLock lock = new ReentrantLock();

public void doWork() {
    lock.lock();
    try {
        // 临界区业务逻辑
    } finally {
        lock.unlock();     // 必须放在 finally 块中确保释放
    }
}

// 带超时的尝试获取:拿不到就走降级逻辑,避免死等
public boolean doWorkWithTimeout() throws InterruptedException {
    if (!lock.tryLock(200, TimeUnit.MILLISECONDS)) {
        return false;      // 超时未获取到锁,快速失败(这对控制高并发场景下的尾延迟至关重要)
    }
    try {
        // 临界区业务逻辑
        return true;
    } finally {
        lock.unlock();
    }
}

tryLock(timeout) 与 lockInterruptibly() 正是 synchronized 无法提供的核心能力——它们让”获取锁”这个动作本身变得可控,是高可用架构中避免线程池被慢锁拖垮的重要手段(这与我们在 依赖韧性 中探讨的超时阻断思路一脉相承)。

5. ReentrantReadWriteLock:一个 state 装两把锁

在读多写少的业务场景下,读操作之间本可以完全并发执行,如果一味使用独占锁就造成了极大的性能浪费。读写锁的规则非常明确:读读共享、读写互斥、写写互斥。

这里的难点在于:AQS 只有一个 int state,如何同时记录读锁和写锁的状态?答案是按位拆分——将 32 位的 int 高 16 位用于记录读锁(所有读线程的总持有次数),低 16 位用于记录写锁(写锁的重入次数):

ReentrantReadWriteLock 的 state 位拆分。左侧高 16 位为读锁计数(state >>> 16),右侧低 16 位为写锁计数(state & 0xFFFF)。底部说明读读共享、读写写写互斥;支持写→读降级,不支持读→写升级。 读写锁的 state 拆分机制:高 16 位记录读状态,低 16 位记录写状态,通过位运算实现两把锁的独立计数。

需要强调的是,锁降级(从写锁降级为读锁)是被原生支持的:线程在持有写锁期间可以再次获取读锁,随后释放写锁,从而在不丢失内存可见性的前提下平滑降级为读锁。但反过来,锁升级(从读锁直接升级为写锁)是绝对不支持的,这会直接导致死锁。

风险提示:读写锁只有在读操作远多于写操作时才划算;如果写操作并不算太少,读写锁内部状态维护的复杂度和读线程排队的开销,反而可能不如直接使用 ReentrantLock 来得快。在 JDK 8 之后,如果是”读极其频繁、写极少,且业务能接受乐观读重试”的场景,强烈建议考虑使用 StampedLock(见下一节)。

5.1 StampedLock(附带一提)

StampedLock 提供了一种极具侵略性的乐观读机制:读数据之前先获取一个 stamp(时间戳),读完后再校验这个 stamp 是否仍然有效(即在此期间没有发生过写入)。如果有效,就意味着全程无需加任何锁,性能极高:

StampedLock sl = new StampedLock();
long stamp = sl.tryOptimisticRead();     // 乐观读,不加任何锁,直接返回标记
int x = shared;                          // 读取共享数据到局部变量
if (!sl.validate(stamp)) {               // 校验期间是否被写入过?如果失效,则升级为悲观读锁
    stamp = sl.readLock();
    try { x = shared; } finally { sl.unlockRead(stamp); }
}

当然,极致性能的代价是极其严苛的使用限制:StampedLock 不可重入、不支持 Condition,一旦用错极易引发死锁或 CPU 飙高。它是性能压榨到极致时的利器,绝非日常业务开发的默认选择。

6. Condition:更灵活的等待/通知

上一篇提到的 synchronized 只有一个隐式的 wait set,所有调用 wait() 的线程都混杂在一起,导致 notify 无法做到定向唤醒。而 Condition 则允许一把显式锁挂载多个条件队列,从而实现精确的线程间通信。以经典的阻塞有界缓冲区为例:

private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull  = lock.newCondition();   // "非满"条件队列
private final Condition notEmpty = lock.newCondition();   // "非空"条件队列
private final Object[] items = new Object[100];
private int count, putIdx, takeIdx;

public void put(Object x) throws InterruptedException {
    lock.lock();
    try {
        while (count == items.length) notFull.await();    // 队列已满 → 生产者在 notFull 上挂起等待
        items[putIdx] = x;
        if (++putIdx == items.length) putIdx = 0;
        count++;
        notEmpty.signal();                                // 放入元素后,精确唤醒等待"非空"的消费者
    } finally { lock.unlock(); }
}

public Object take() throws InterruptedException {
    lock.lock();
    try {
        while (count == 0) notEmpty.await();              // 队列为空 → 消费者在 notEmpty 上挂起等待
        Object x = items[takeIdx];
        if (++takeIdx == items.length) takeIdx = 0;
        count--;
        notFull.signal();                                 // 取出元素后,精确唤醒等待"非满"的生产者
        return x;
    } finally { lock.unlock(); }
}

使用约定:① 调用 await/signal 之前必须已经持有对应的锁;② 等待条件的判断必须放在 while 循环里(这是为了防止虚假唤醒,以及应对被唤醒后条件又被其他线程改变的竞态情况)。

底层原理:调用 await() 会将当前线程节点从 AQS 的同步队列中移除,转移到该 Condition 的条件队列中,并彻底释放锁;而调用 signal() 则是将条件队列的头节点重新转移回同步队列,等待它重新竞争到锁之后,才能从 await 方法中返回:

Condition 同步队列与条件队列。左面板 AQS 同步队列(竞争锁的 Node),右面板 Condition 条件队列(notFull/notEmpty 等);signal 将节点转回同步队列,await 将节点移入条件队列。底部说明 while 判断与必须持锁调用。 Condition 节点转移机制:await 使节点进入条件队列,signal 将节点移回同步队列重新参与锁竞争。

这套机制,正是我们在 异步削峰 架构中广泛使用的各类阻塞队列(如 ArrayBlockingQueue)的核心底层依赖。

7. AQS 上的常见同步器

为了更直观地理解模板方法,我们用一张表总结 state 在各核心同步器里的具体语义:

同步器模式state 的含义
ReentrantLock独占重入次数(0 代表空闲)
ReentrantReadWriteLock独占+共享高 16 位为读锁计数 / 低 16 位为写锁计数
Semaphore共享剩余的可用许可数
CountDownLatch共享剩余的倒数计数,减到 0 则放行
ThreadPoolExecutor.Worker独占是否处于运行中状态(用于区分线程是否可被中断)

后三个同步器的实战场景将在 并发协作与异步编排 里详细展开,而线程池 Worker 的状态流转则见 线程池原理与调优。

8. 常见陷阱

锁的两条核心路线——JVM 内建的 synchronized 与基于 AQS 的显式锁——到此已全部对齐。在实际工程中,我们更常打交道的是线程池:它的七个核心参数究竟该如何交互、拒绝策略又该怎么选?下一篇我们将深入探讨。

延伸阅读


–views
Share this post on:

Previous Post
线程池原理与调优:ThreadPoolExecutor 七大参数与执行流程
Next Post
synchronized 与锁升级:对象头、Monitor 与偏向、轻量、重量级锁