Skip to content
Charles Shao
Go back

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

views

ReentrantLockReentrantReadWriteLockSemaphoreCountDownLatch……JUC 里这一大票同步器,底层大多落在同一个类:AbstractQueuedSynchronizer(AQS)。Doug Lea 用「一个 volatile 的 state + 一个 FIFO 等待队列 + CAS」把「获取/释放同步状态」抽成模板方法,各同步器只需定义 state 的语义。

synchronized 与锁升级 过来:需要超时、可中断、公平、多条件或读写分离时,就该换到基于 AQS 的显式锁。

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 里是剩余许可。所有对它的修改都走 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 为骨架,子类只定义获取成功语义。

队首的 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)) {   // 只有队首才有资格尝试
            setHead(node);                     // 拿到锁,成为新 head
            p.next = null;
            return interrupted;
        }
        if (shouldParkAfterFailedAcquire(p, node)) // 把前驱置为 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 的 Syncstate 表示重入次数(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;
        }
    }
    ...
}

取舍:非公平锁允许刚到的线程”插队”,减少了线程唤醒切换,吞吐更高,是默认选择;代价是队尾线程可能长期抢不到(饥饿)。公平锁严格 FIFO,杜绝饥饿,但唤醒-切换更频繁,吞吐下降。除非明确需要防饥饿,否则用默认的非公平锁。

4.3 正确使用姿势

显式锁必须在 finally 里释放,否则异常路径会永久持锁:

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,怎么同时记读锁和写锁?答案是按位拆分——高 16 位记读锁(所有读线程总持有次数),低 16 位记写锁(写锁重入次数):

ReentrantReadWriteLock 的 state 位拆分。左侧高 16 位为读锁计数(state >>> 16),右侧低 16 位为写锁计数(state & 0xFFFF)。底部说明读读共享、读写写写互斥;支持写→读降级,不支持读→写升级。

锁降级(写锁→读锁)被支持:持写锁时可再获取读锁,然后释放写锁,从而在不释放可见性的前提下降级为读。反过来锁升级(读→写)不被支持,会死锁。

注意:读多写少才划算;写不算太少时,读写锁的复杂度和读线程排队反而不如直接用 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,用错易死锁。它是性能优化的利器而非默认选择。

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 判断与必须持锁调用。

这正是 异步削峰 里各种阻塞队列的底层机制之一。

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 与偏向 / 轻量 / 重量级锁