首页 / Java 学习笔记 / 16

JAVA · Vol.II · DAY 16 · 并发编程

AQS 框架与 ReentrantLock:独占锁、公平锁、Condition

深度极高#并发#核心#
第 1 站

面试官:你知道 Java 里的锁底层是怎么实现的吗?

"synchronized 你说了 JVM 层面的 Monitor,那 ReentrantLock 呢?CountDownLatch、Semaphore 底层又是什么?它们之间有没有共通的东西?" —— 面试官想考察的不是某个 API 用法,而是你对 java.util.concurrent 底层框架的理解深度。

java.util.concurrent(JUC)包中,几乎所有同步工具背后都站着同一个"幕后大佬"——AQS(AbstractQueuedSynchronizer,抽象队列同步器)。这个由并发大师 Doug Lea 设计的抽象框架,是整个 JUC 锁体系的基石:

  • ReentrantLock —— 可重入的独占锁,AQS 最经典的实现
  • CountDownLatch —— 倒计数器,state 从 N 递减到 0 时释放所有等待线程
  • Semaphore —— 信号量,state 表示可用许可数
  • ReentrantReadWriteLock —— 读写锁,state 高 16 位存读锁计数、低 16 位存写锁计数
  • ThreadPoolExecutor 的 Worker —— 内部也通过 AQS 实现不可重入锁

理解 AQS = 理解 JUC 的一半。核心思想极其精炼:

AQS 核心公式
一个 volatile int state(同步状态) + 一条 FIFO 双向队列(CLH 变体) + 模板方法模式(子类重写 tryAcquire / tryRelease)

本文以 ReentrantLock 为主线,先用一个「餐厅排号」类比把整套机制串起来,再逐行拆解 lock()、unlock()、公平/非公平、Condition 全流程,中途专门用一站回答一个高频追问——竞争最终都收敛到 state 那一次 CAS,AQS 凭什么还更快,最后横向串联其他 AQS 工具和生产落地要点。

第 2 站

AQS 核心结构:state + CLH 队列

AQS 的全部魔力建立在两个核心成员之上。先看源码:

AbstractQueuedSynchronizer.java · JDK 8
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer {
    // 同步状态:ReentrantLock 中 0=空闲,>0=重入次数;Semaphore 中=许可数
    private volatile int state;
    // CLH 变体队列的头尾节点
    private transient volatile Node head;
    private transient volatile Node tail;
    // 独占模式:子类重写
    protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); }
    protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
    // 共享模式:CountDownLatch / Semaphore 重写
    protected int tryAcquireShared(int arg) { ... }
    protected boolean tryReleaseShared(int arg) { ... }
}

Node 是 CLH 队列的节点单元,包含五个关键字段:

Node.java · AQS 内部静态类
static final class Node {
    static final int CANCELLED = 1;    // 已取消(超时/中断)
    static final int SIGNAL    = -1;   // 后继需要被 unpark
    static final int CONDITION = -2;   // 在 Condition 队列中
    static final int PROPAGATE = -3;   // 共享模式传播唤醒
    volatile Node prev;       // 前驱(取消时回溯找有效节点)
    volatile Node next;       // 后继(unpark 目标)
    volatile Thread thread;
    Node nextWaiter;          // Condition 后继 / 独占or共享标识
}
AQS 核心结构:volatile state + CLH 变体队列 volatile int state CAS 操作,保证可见性 head (dummy) waitStatus = 0 Node A ws = SIGNAL(-1) Thread-1 Node B ws = SIGNAL(-1) Thread-2 Node C ws = 0 Thread-3 next → ← prev(取消时回溯) head tail AQS 三板斧 ① state 通过 CAS 原子修改 ② 获取失败的线程入 CLH 队列 park 阻塞 ③ 子类重写 tryAcquire/tryRelease 实现定制同步语义
图 1AQS 核心结构:volatile state 管理同步状态,CLH 变体队列管理等待线程
为什么 state 必须是 volatile?

state 的修改通过 compareAndSetState() CAS 操作保证原子性,但 volatile 保证了可见性:当一个线程 CAS 修改 state 后,其他线程通过普通读(getState())能立刻看到最新值,而不需要额外加锁。没有 volatile,线程可能读到 CPU 缓存中的旧值。这也呼应了 JMM 篇的结论:并发工具的正确性 = 原子性(CAS)+ 可见性(volatile),一个都不能少。

第 3 站

餐厅排号:把 AQS 一次讲透

在逐行读源码之前,先花三分钟用一个餐厅把整套机制映射一遍——后面每段源码你都能对上图:

餐厅场景AQS 对应一句话解释
「今天还有没有空位」的牌子(0=满,N=还有 N 个)volatile int state所有线程都看这块牌子决定自己能不能坐下
取号 → 排队 → 等叫号CLH 变体队列抢不到位置的线程排进 FIFO 队列
顾客坐下休息(不是站着原地转圈)LockSupport.park()等叫号时线程挂起,不烧 CPU
服务员喊号、领位unpark() + acquireQueued空位出来后唤醒队首线程去抢
每家店的「座位规则」不同:单人桌一家一客 / 卡座可拼桌tryAcquire / tryAcquireSharedstate 的语义由子类定义(独占 vs 共享)
领位台(统一管排队、喊号、处理弃号)AQS 父类框架排队/挂起/唤醒逻辑写一次,所有店复用
「等位区」可以开好几个:等包间的、等卡座的分开等多个 Condition 队列同一把锁可以挂多条独立等待队列,精确唤醒

这个类比里最关键的洞察是:「座位规则」和「排队管理」被彻底分开了。一家新餐厅开张(新同步工具),只需要定义自己的座位规则(重写 4 个 tryXxx 方法),排队、喊号、处理弃号这些最繁琐最容易出错的活儿,全部交给领位台(AQS 父类)——这就是模板方法模式,第 14 站展开。

第 4 站

CLH 队列:从自旋到 park 的进化

原始 CLH(Craig, Landin, Hagersten)是一种自旋锁队列:每个节点只包含一个 locked 字段,线程通过自旋(while(node.prev.locked))检查前驱状态。自旋的优点是响应快(无需内核态切换),但在锁竞争激烈的场景下,大量线程持续空转,CPU 利用率极高但有效吞吐为零

AQS 做了三个关键改造,从根本上解决了自旋锁的缺陷:

  • 变单向为双向:增加 prev 指针,用于取消节点时向前回溯跳过已取消的前驱
  • 自旋改 park/unpark:不再忙等,调用 LockSupport.park() 将线程挂起——注意线程状态进入的是 WAITING(等通知),而不是等锁的 BLOCKED;unpark() 唤醒(恢复到 RUNNABLE)。park/unpark 底层依赖操作系统的 Mutex/Condition 或 Linux 的 futex
  • 增加 waitStatus:节点携带 SIGNAL、CANCELLED、CONDITION、PROPAGATE 等精细状态,精确控制唤醒时机
AQS 的 CLH 变体队列(双向 + park/unpark) head 虚拟哨兵 Node SIGNAL(-1) Thread-A (parked) Node SIGNAL(-1) Thread-B (parked) Node (tail) ws = 0 Thread-C (parked) next →(用于 unpark 后继) ← prev(取消时回溯跳过无效节点) 入队流程:addWaiter + enq ① 创建 Node ② CAS 将 tail 指向自己(失败重试)③ node.prev = oldTail, oldTail.next = node enq 使用 for(;;) 无限重试 CAS,保证在并发下一定能入队成功
图 2CLH 变体队列:双向链表 + park/unpark,head 是虚拟哨兵节点
为什么需要 prev 指针?

当线程被中断或超时取消时,需要从队列中"摘除"。如果只有 next,无法找到前驱修补链路。有了 prev,取消的节点沿 prev 向前找到第一个非取消前驱,把自己的 prev 指过去,同时更新前驱的 next,完成摘除。这就是 cancelAcquire() 的核心逻辑。

第 5 站

ReentrantLock.lock() 全流程拆解(NonfairSync)

ReentrantLock 默认是非公平锁(NonfairSync)。调用 lock() 时的完整流程:

ReentrantLock.NonfairSync · lock()
final void lock() {
    // ① 上来先 CAS 抢锁,不管队列有没有人 —— "插队"尝试
    if (compareAndSetState(0, 1))
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);  // ② CAS 失败,走 AQS 标准流程
}

acquire(1) 是 AQS 的模板方法,依次调用子类 tryAcquire()、失败则入队 park:

AQS.acquire() · 模板方法
public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();  // 等待中被中断过,补上中断标记
}

NonfairSync 的 tryAcquire() 委托给 Sync.nonfairTryAcquire():

ReentrantLock.Sync · nonfairTryAcquire()
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // ③ state==0 锁空闲,CAS 抢占
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {
        // ④ 当前线程已持有 → 重入,state+1
        int nextc = c + acquires;
        setState(nextc);  // 无需 CAS(只有自己能改)
        return true;
    }
    return false;  // ⑤ 被其他线程持有,获取失败
}

tryAcquire 失败后,AQS 执行 addWaiter() 入队,然后进入 acquireQueued() 自旋+park 循环:

AQS.acquireQueued() · 核心循环
final boolean acquireQueued(Node node, int arg) {
    boolean failed = true;
    try {
        boolean interrupted = false;
        for (;;) {
            final Node p = node.predecessor();
            // 前驱是 head 才有资格尝试获取锁
            if (p == head && tryAcquire(arg)) {
                setHead(node);  // 成功,自己成为新 head
                p.next = null;  // 旧 head 断开,帮助 GC
                failed = false;
                return interrupted;
            }
            // 获取失败:判断是否应 park
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed) cancelAcquire(node);
    }
}
shouldParkAfterFailedAcquire 做了什么?

检查前驱的 waitStatus。如果是 SIGNAL(-1),说明"我释放时会通知你",可以放心 park。如果前驱已取消(>0),沿 prev 跳过所有取消节点重新链接。如果前驱既不是 SIGNAL 也不是 CANCELLED,则 CAS 设为 SIGNAL,但不立即 park,再自旋一次尝试获取锁。

lock() 关键路径

lock() → CAS(0→1) 成功则直接获得锁;失败 → acquire(1)tryAcquire(CAS + 重入检查)→ 失败 → addWaiter 入队 → acquireQueued 自旋+park → 被 unpark 后重试 → 成功则将自身设为 head。

第 6 站

可重入性:同一把锁,自己人进来自如

「可重入」三个字人人都会说,但追问一句"为什么需要可重入?"就很少有人能答出场景了。

先看一个没有可重入性会直接死锁的场景:

没有可重入性 = 死锁
public void a() {
    lock.lock();
    try {
        b();          // b() 内部还要 lock.lock() —— 同一线程抢自己持有的锁
    } finally {
        lock.unlock();
    }
}
public void b() {
    lock.lock();      // 若锁不可重入:当前线程永远等不到"释放",死锁
    try {
        // ...
    } finally {
        lock.unlock();
    }
}

递归算法、持有锁的方法调用另一个也加锁的方法——Java 代码里这种嵌套极其常见。可重入性让「同一线程重复加锁」合法化,靠的就是 nonfairTryAcquire() 里那个分支:

可重入的两步账
加锁:owner == 当前线程 → state + 1(不抢 CAS,直接 setState)
解锁:state - 1;只有减到 0 才真正释放(清 owner + unpark 后继)
重入计数的完整生命周期
outerThread:  lock.lock();   // state: 0 → 1(CAS 抢占,owner = outerThread)
outerThread:      lock.lock();  // state: 1 → 2(重入检查通过,owner 还是自己)
outerThread:      lock.unlock(); // state: 2 → 1 ★ 锁没真正释放,没人被唤醒
outerThread:  lock.unlock();   // state: 1 → 0 ★ 真正释放,unparkSuccessor 唤醒队首

注意第二站源码里的细节:重入分支用的是 setState(nextc) 而不是 CAS——因为只有 owner 自己才会走这个分支改 state,不存在竞争,省掉一次 CAS。unlock() 时的 tryRelease 同样直接 setState,并先校验 Thread.currentThread() == getExclusiveOwnerThread(),不是持有者就抛 IllegalMonitorStateException(线程 A 锁了线程 B 的锁,直接拒绝)。

和 synchronized 的对比

synchronized 也是可重入的(重入计数存在 monitor 里,JVM 管),行为一致。区别在解锁:synchronized 出代码块自动减计数,ReentrantLock 必须手动 unlock()——少调一次,锁就永远不释放(第 16 站的坑)。

第 7 站

公平锁 vs 非公平锁:一行代码的差异

通过构造函数选择模式,默认非公平:

ReentrantLock · 构造函数 + FairSync.tryAcquire()
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

// FairSync 的 tryAcquire —— 比 NonfairSync 多了一个检查
protected 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;
        }
    } else if (current == getExclusiveOwnerThread()) {
        setState(c + acquires);  // 重入
        return true;
    }
    return false;
}

// AQS 提供的判断方法
public final boolean hasQueuedPredecessors() {
    Node h = head, t = tail, s;
    // 队列非空 且 我不是第一个等待者 → 有人排在我前面
    return h != t &&
        ((s = h.next) == null || s.thread != Thread.currentThread());
}

非公平锁的 lock() 第一步就是无条件 CAS——不管队列里有没有人,上来就抢。这就是"非公平"的含义:刚来的线程可以"插队"到已排队线程前面

维度非公平锁(NonfairSync)公平锁(FairSync)
lock() 第一步无条件 CAS(0→1),直接抢锁先 hasQueuedPredecessors(),没人排队才 CAS
吞吐量高(减少 park/unpark 上下文切换)较低(严格按序,即使锁刚释放也排队)
饥饿风险存在(某些线程可能长期抢不到)基本没有(FIFO 保证公平)
适用场景绝大多数场景(默认选择)资源分配需严格公平(订单处理、任务调度)
面试官追问:"为什么非公平锁性能更好?"

核心原因是减少线程上下文切换。假设线程 A 刚释放锁,线程 B 正好来请求:

  • 非公平:B 直接 CAS(0→1) 成功,立刻执行。省去 park → unpark → 调度 → 恢复的全部开销
  • 公平:B 发现队列中有线程 C,必须入队。最终 C 被 unpark → 调度 → 获锁 → 释放 → 再 unpark B,多一次完整上下文切换

还要补一句常被忽略的:非公平的「插队」是尽力而为——B 只是多了一次 CAS 机会,抢不到照样排队,不会一直插到系统崩溃;而公平锁也并非「绝对无饥饿」,它保证的是请求顺序,唤醒后仍要竞争。面试时说清「非公平 ≠ 乱来,公平 ≠ 绝对」,区分度立刻拉满。

面试回答要点

"公平和非公平的区别在于 tryAcquire 中是否调用 hasQueuedPredecessors()。非公平允许刚到的线程直接 CAS 抢锁,减少 park/unpark 开销,吞吐量更高;缺点是可能饥饿。公平严格按 FIFO,保证不饥饿但性能较低。绝大多数场景用非公平即可。"

第 8 站

lockInterruptibly / tryLock:synchronized 给不了的能力

lock() 一旦调用就是「死等」——等多久不知道,中断信号也照单不拒(只是最终补个标记)。生产里经常需要更灵活的控制,ReentrantLock 给出三个变体:

三种变体 + 典型用法
// ① 可中断:等待锁的过程中响应中断(内部 acquireInterruptibly,
//    park 后检查中断状态并抛 InterruptedException)
try {
    lock.lockInterruptibly();
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();  // 惯例:恢复中断标记,交给上层决策
    return;                              // 本轮不抢了(比如任务已被取消)
}
try {
    // ...临界区
} finally {
    lock.unlock();
}

// ② 超时:最多等 N 秒,抢不到就走兜底
if (lock.tryLock(1, TimeUnit.SECONDS)) {
    try {
        // ...临界区
    } finally {
        lock.unlock();
    }
} else {
    log.warn("抢锁超时,走降级逻辑");
    // 返回缓存 / 降级数据 / 交给重试队列
}

// ③ 非阻塞:抢到就干,抢不到立刻走人
if (lock.tryLock()) {
    try {
        // ...临界区
    } finally {
        lock.unlock();
    }
}
方法等不到锁时响应中断典型场景
lock()无限期等待不响应(事后补标记)必须拿到锁才能干,且任务可无限等
lockInterruptibly()无限期等待,但可被中断响应,抛 InterruptedException可取消的长任务(订单履约、批处理)
tryLock(timeout, unit)超时放弃响应有兜底策略的抢锁(缓存刷新、分布式协调)
tryLock()立刻放弃不等待乐观尝试:行就行,不行走别的路径
为什么这是 ReentrantLock 相对 synchronized 的核心优势

synchronized 的等待是「黑箱等待」:进去就要等,等不等到、等多久,业务代码完全无法干预。而「可中断 + 可超时」让锁等待变成业务流程的一部分——可以被取消、可以设上限、可以降级。凡是「锁等待可能很长」或「任务生命周期受外部控制」的场景(MQ 消费、定时任务、用户可取消的操作),都应该优先想到这三个变体。

第 9 站

unlock() 流程:释放锁与唤醒后继

unlock() 委托给 AQS 的 release() 模板方法:

ReentrantLock + AQS · unlock 全流程
public void unlock() { sync.release(1); }

// AQS.release() 模板方法
public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);  // 唤醒后继
        return true;
    }
    return false;
}

// ReentrantLock.Sync.tryRelease()
protected final boolean tryRelease(int releases) {
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = (c == 0);
    if (free) setExclusiveOwnerThread(null);
    setState(c);  // 无需 CAS:只有持有者会修改 state
    return free;
}

unparkSuccessor()tail 往前找第一个非取消节点并唤醒:

AQS.unparkSuccessor()
private void unparkSuccessor(Node node) {
    if (node.waitStatus < 0)
        compareAndSetWaitStatus(node, node.waitStatus, 0);
    Node s = node.next;
    if (s == null || s.waitStatus > 0) {
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0) s = t;
    }
    if (s != null) LockSupport.unpark(s.thread);
}
为什么从 tail 往前找,不直接取 head.next?

入队的 enq() 分两步:① node.prev = tail(CAS),② oldTail.next = node。两步间有时间窗口——tail 已指向新节点,但旧 tail 的 next 还没指过来。从 head.next 往后遍历可能在中间断裂。而从 tail 沿 prev 往前找是安全的,因为 prev 在 CAS 之前就已赋值。

unlock() 关键路径

unlock()release(1)tryRelease:state-1 → 若 state==0 清除 owner → unparkSuccessor:从 tail 往前找有效节点 → LockSupport.unpark() → 被唤醒线程在 acquireQueued 循环中重新竞争锁。

第 10 站

acquire / release 全景:一张图串起前后五站

前面五站把 lock 和 unlock 拆开讲了,这一站把两条路径合成一张图。面试时能徒手画出这张图,AQS 就算过关了:

AQS 独占锁:获取与释放全景 获取路径 acquire(arg) tryAcquire CAS / 重入检查 addWaiter+enq CAS 入队尾 acquireQueued 前驱==head? 再试 park WAITING 失败 该睡了 unpark 后回到 tryAcquire 重试(被中断则补标记后返回) 成功:setHead(node) —— 持有锁的线程节点成为新哨兵,旧哨兵 next=null 帮助 GC 释放路径 release(arg) tryRelease state-1,校验 owner state 减到 0? 重入未耗尽则到此为止 unparkSuccessor 从 tail 往前找有效节点
图 3独占锁 acquire / release 全景:获取失败入队 park,释放归零唤醒后继
为什么被唤醒的线程不能直接认为「锁是我的」?

unpark 只是「你被叫到号了,去抢」——被唤醒的线程要回到 acquireQueued 循环里重新 tryAcquire。因为 unpark 和它实际被调度执行之间有延迟,这期间锁可能已被别人抢走(比如释放方 unpark 了队首,队首却还没跑起来,而另一个新来的线程非公平 CAS 抢成功了)。park/unpark 是「唤醒」不是「授予」,锁永远靠竞争获得——这是 AQS 和「信号量式直接分配」的本质区别。

第 11 站

追问:锁竞争最后不还是变成 CAS 竞争吗?

学到这儿,一个很自然的反驳会冒出来:「AQS 靠 CAS 改 state 来决定谁赢,可互斥资源只有一份、state 也只有一个变量——1000 个线程抢一把锁,最后不就变成 1000 个线程抢着对同一个变量做 CAS?争抢只是从锁转移到了 CAS,这样设计凭什么有优势?」

这句话里混着三个问题,拆开才好回答:① 事实——真正对 state 发 CAS 的有几个线程、每人发几次?② 成本——一次 CAS 争抢和一次锁争抢,钱分别花在哪?③ 取舍——为什么裁决用 CAS,而排队不用 CAS?
先把结论摆出来
竞争是业务给定的约束(互斥资源只有一份),谁也消不掉,只能安排。
AQS 的安排是:把「N 个线程争一把锁」重组成「1 个线程赢 + N−1 个线程睡」——CAS 只回答「这一刻谁赢」这一个窄问题,输的人试一两次就退场去 park;
真正扛住那 N−1 份压力的是内核的等待队列,那里睡着不烧 CPU。

① 事实层:把 AQS 的争抢点一个一个数清楚

「所有线程都在抢 state」这一步推理就不成立。回到第 5 站那段循环,盯住一个此前被一笔带过的条件:

AQS.acquireQueued() · 谁有资格碰 state
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {           // ★ 全队列只有队首这 1 个线程会发 CAS(state)
    setHead(node); ...
}
if (shouldParkAfterFailedAcquire(p, node) &&
    parkAndCheckInterrupt())                    // ★ 抢不到就去睡,不在这里自旋重试
    interrupted = true;

p == head 这四个字符就是本题的一半答案:一条几千个节点的队列里,任何时刻只有队首那一个线程会去调 tryAcquire、才会对 state 发 CAS,因为它后面的人全停在 LockSupport.park() 里睡觉。Doug Lea 在类注释里把这件事写得比任何解读都清楚(JDK 8 与 JDK 17 措辞一致):

AbstractQueuedSynchronizer · 类注释原文
 * A thread may try to acquire if it is first in the queue. But being
 * first does not guarantee success; it only gives the right to contend.
   → 排到队首拿到的不是锁,只是「再抢一次」的资格。

 * ...while acquires do not "spin" in the usual sense, they may perform
 * multiple invocations of tryAcquire interspersed with other
 * computations before blocking. This gives most of the benefits of spins
 * when exclusive synchronization is only briefly held, without most of
 * the liabilities when it isn't.
   → 获取不是通常意义上的自旋:几次尝试之间穿插的是「建节点、挂 tail、改状态」这些记账动作。
     锁只被持有很短时间时,能吃到自旋的好处;持有时间长时,又不背上自旋的包袱。

 * // JDK 17 重写版补了一句更直接的:
 * Except in this case, AQS locks do not spin.
   → 除了队首那几次重试,AQS 的锁不自旋。

把整条获取路径上的原子操作全列出来,才知道「争抢」到底被摊成了几份:

争抢点谁参与每人几次争抢窗口
读 state(getState()全部 N 个线程1~2不是 CAS,普通 volatile 读,只共享缓存行
① CAS(state) 抢锁插队的新线程 + 队首 1 个1~2(非公平 lock() 先抢一次,tryAcquire 再一次)一条指令
② CAS(tail) 入队所有获取失败的线程1~2(addWaiter 快路径 + enq 重试)几条指令,一次排队只做这么一两次
③ CAS(前驱 waitStatus)每个等待者对自己的前驱1点对点,不存在全局热点变量(第 12 站)
④ setHead 换哨兵刚赢的那个线程1独占,几乎无竞争
重入 +1 / 解锁 −1只有 owner0 次 CAS直接 setState(第 6、9 站)

再补一个细节:JDK 17 把「允许多抢几次才去睡」写成了显式的指数退避,而且重试资格只发给队首

AQS 主循环(节选) · JDK 17 重写版
byte spins = 0, postSpins = 0;        // retries upon unpark of first thread
...
} else if (first && spins != 0) {
    --spins;                            // 只有队首允许多抢几次;reduce unfairness on rewaits
    Thread.onSpinWait();
} else if (node.status == 0) {
    node.status = WAITING;              // 登记「我要睡了」,再复查一次
} else {
    spins = postSpins = (byte)((postSpins << 1) | 1);   // 1,3,7,…,255 指数增长
    LockSupport.park(this);             // 这次是真睡了
}
最硬的一条定量结论:state 的 CAS 频率与排队人数无关

沿赢家视角走一遍:抢到锁之后,重入用 setState、释放也用 setState一条 CAS 都不发。所以稳态下每秒对 state 的 CAS 次数 ≈ 锁的交接次数 ×(1 + 插队人数),而交接次数被临界区时长天然限死——临界区 1ms,一秒最多交接 1000 次。队列里排 9 个人和排 999 个人,state 上的 CAS 频率一模一样。

对比 AtomicLong.getAndIncrement():1000 个线程各自在 do-while 里自旋到成功,一秒能往同一条缓存行上砸上亿次 CAS。同一个「只有一个变量的 CAS 争抢」,AQS 的频率由锁的持有周期决定,AtomicLong 的频率由请求量决定——这才是「竞争转移到 CAS」这句话只说对一半的地方。

N 个线程抢一把锁:争抢被压成三个窄口 N 个线程调用 lock() 第一步只是 volatile 普通读 getState()——不是原子指令,没有争抢 窄口 ① CAS(state, 0, 1) —— 裁决谁赢 参与者:插队者 + 队首 1 人(p == head 闸门)|每人 1~2 次,失败即退场 1 个赢家 → 临界区 此后重入 / 释放都走 setState 窄口 ② CAS(tail) —— 入队 窗口只有几条指令,且一次排队只做 1~2 次 窄口 ③ CAS(前驱 waitStatus) → park N−1 人睡进内核等待队列,CPU 占用为 0 对 state 的 CAS 频率 ≈ 锁交接频率 ×(1 + 插队人数),与排队人数无关 | 纯 CAS 自旋则是 请求量 × 线程数
图 4争抢漏斗:state 上的 CAS 被队首闸门限流,入队 CAS 每人 1~2 次,其余压力交给内核等待队列

② 成本层:争抢没消失,但换了计价方式

竞争失败的处理方式单次代价陷入内核让出 CPU随竞争人数
失败后自旋重试(AtomicLong / Semaphore 释放侧)几十纳秒,占着核继续撞线性放大
失败后入队 park(AQS)10~100 微秒是(futex_wait / futex_wake)睡 10 个和睡 1000 个一样便宜

看着像矛盾:park 贵三个数量级,为什么反而更好?因为决定系统生死的是「有没有人白占 CPU」,不是「哪条指令更快」。自旋方案里输的人不会退出,它们占着核一直撞:1000 个线程抢 1 个资源,机器上有几个核就有几个核在做无用功,浪费的 CPU 随竞争线程数线性增长;AQS 方案里那 999 个线程睡在内核等待队列上,CPU 全部留给真正在跑临界区的线程。AQS 用「赢家的几十纳秒」+「输家的几十微秒延迟」,换掉了「整台机器一起空转」——这就是同一句「竞争转移到 CAS」背后被忽略的那半句:只有一小撮竞争转移到了 CAS,大头转移到了调度器那里排队,而排队是免费的。

CAS 争抢的真实账单在缓存行上——AQS 这里有一笔没还的「伪共享税」

失败 CAS 本身只是一条指令返回 false,贵的是 lock cmpxchg 要把那条缓存行从别的核抢回来(MESI 失效 + 重新拉取),多个核轮流抢同一行就是来回弹。所以「CAS 争抢」在硬件层的真实形态是缓存行迁移次数。诚实说,AQS 并没有优化这一点:JDK 8 和 17 里 headtailstate 三个 volatile 字段紧邻声明在同一个对象上,没有 @Contended 填充,大概率落在同一条 64 字节缓存行里——入队改 tail 和抢锁改 state 会互相把对方的副本打失效。它敢这么写,是因为上面的限流效应已经把频率压下去了;而 LongAdder 面对的是真·高频竞争,所以老老实实给每个 Cell 填了 padding(CAS 篇第 14 站)。要不要为缓存行花钱,取决于你每秒撞多少次——这两个类的竞争强度根本不是一个量级。

③ 取舍层:为什么裁决用 CAS,排队却不用「一把真锁」

  • 鸡生蛋问题:AQS 本身就是用来实现锁的框架。如果用 synchronized 或内核 mutex 去保护 state,那「抢 AQS 的锁」就退化成「先进内核抢一把锁」,用户态排队当场失去意义。任何锁实现最终都必须落到一个不可再分的硬件原子操作上,lock cmpxchg 就是这个终点(CAS 篇第 3 站)。这不是 AQS 的偏好——synchronized 的轻量级锁第一步同样是 CAS 把 Mark Word 替换成指向栈上 Lock Record 的指针(synchronized 原理篇;写线程 ID 那是偏向锁的活儿,JDK 15+ 已移除),整个 Java 并发体系收敛在同一个底座上。
  • 颗粒度刚好合适。AQS 类注释开宗明义:这个框架专为 "synchronizers that can rely on int state" 设计。一个 int、一条指令就能裁完,正是 CAS 的甜区。反过来「排队」做不到——入队要改三根指针,一条指令装不下,所以只能用 CAS(tail) + 失败重试的乐观入队,窗口只有几条指令,失败方重试也没有副作用。
  • 把贵的资源留给对的事。裁决要的是「快」,等待要的是「不占 CPU」。让赢家用几十纳秒定输赢,让输家用几十微秒去睡,两套成本各归其位。反过来两种纯方案都输得很惨:全靠自旋(等待也靠抢)→ 高竞争时 CPU 打满、吞吐不涨;全靠阻塞(连裁决都要 park)→ 临界区只有 100ns 的场景会被上下文切换吃掉全部性能。JDK 8 那个 spinForTimeoutThreshold = 1000L 就是把这条分界线钉死:剩余超时不足 1 微秒时宁可自旋,也别 park。

④ 边界层:AQS 什么时候真会被争抢拖垮

前面三段讲的是「设计如何限制争抢」,不是「争抢不存在」。四种情况要心里有数:

  • 非公平锁 + 极短临界区 + 高并发 = 插队风暴。JDK 把这套默认策略叫 barging,也写作 greedy / renouncement / convoy-avoidance(避免护航效应),注释承认它 "not guaranteed to be fair or starvation-free"。队首线程醒来发现锁又被抢走,只能 rewait——这是第 7 站那句「非公平 ≠ 乱来」的另一半代价。解药就是公平锁的 hasQueuedPredecessors() 和 JDK 17 那个最多 255 次的指数退避。
  • 共享模式的释放侧没有闸门,才是 AQS 家族真正的自旋热点。获取侧的 doAcquireShared 同样要 p == head 才试;但 tryReleaseShared每个释放者都要跑的 for (;;) { … CAS(state) }(第 15 站源码里 CountDownLatch、Semaphore 都长这样,没有闸门)。1000 个线程各 countDown() 一次,就是 1000 次 CAS 自旋砸在同一个 state 上——这已经退化成 AtomicLong 的用法了。所以别拿 CountDownLatch / Semaphore 当高并发计数器,那是 LongAdder 的活。
  • 取消风暴。tryLock(timeout)lockInterruptibly 用得太密时,超时与中断会触发 cancelAcquire 反复 CAS 摘节点,JDK 17 注释写着每次清理要 "traverses the queue until a clean sweep"(一路扫到干净为止)。队列越长、超时越密集,排队管理的开销被放大得越厉害——超时值别拍脑袋设成贴着临界区的毫秒级。
  • 反过来,「争抢变成可观测的资源」是 AQS 白送的红利。hasQueuedThreads()(当前是否有人排队)、getQueueLength()hasContended()(是否曾经阻塞过,实现就一句 head != null,常数时间)都能直接接监控。JDK 注释甚至建议高吞吐路径上先预检查再决定要不要抢——反正已经有人在排队了,多发一次 CAS 大概率也抢不到。
现场诊断口诀:同样是「竞争激烈」,两种病根处方完全相反。
jstack 里一片 WAITING (parking)、栈顶是 LockSupport.park,但 CPU 不高 → 锁被长时间持有,park 兜底正在起作用,优化方向是缩短临界区(把 IO、RPC 挪出锁外)。
CPU 打满、吞吐不涨,热点栈在 compareAndSetState / lock cmpxchg → 竞争退化成了自旋,优化方向是分散热点(分段锁、LongAdder 思路)或降低抢的频率(一次批量拿多个许可)。
面试回答要点

"竞争不会消失,只能被重新安排。AQS 里对 state 的 CAS 被 p == head 这道闸门限死了:队列中只有队首那一个线程有资格再试,其余全在 park;而输的人试一两次也去排队睡觉,不自旋。赢家之后的重入和释放都走 setState,一条 CAS 都不发。所以对 state 的 CAS 频率约等于锁的交接频率,和排队人数无关——这是它和 AtomicLong 单点自旋最本质的区别。剩下的 N−1 份压力交给内核等待队列,睡着不占 CPU。

所以 AQS 的可扩展性不来自 CAS,来自那条把「抢」变成「等」的队列;CAS 在这里只是最便宜的那个仲裁器——因为 AQS 本身就是实现锁的框架,用锁去保护 state 会循环依赖,最终必须落到 lock cmpxchg 这个硬件原语上。

代价也讲得清:唤醒后仍要重新竞争、可能饥饿(所以要公平锁和指数退避),以及共享模式的释放侧没有闸门、是真自旋热点(所以高并发计数该用 LongAdder 而不是 Semaphore)。"

第 12 站

waitStatus 四状态:每个节点头上的「小纸条」

队列节点上的 waitStatus 是每个节点携带的状态标记,面试最爱考「SIGNAL 是谁的状态」。一张表说清:

状态含义谁在什么时候写它
00初始值;tail 节点默认 0;head 哨兵成功后置 0节点创建时
SIGNAL-1「我释放锁时会 unpark 你」——写在前驱节点上,给后继看的承诺后继入队/获取失败时,CAS 到前驱上
CANCELLED1节点已取消(被中断、超时、取消),必须被后续操作跳过cancelAcquire()
CONDITION-2节点此刻在 Condition 条件队列里,不在 sync 队列await() 加入条件队列时
PROPAGATE-3共享模式专用:「这次释放的唤醒已经传播完毕」,防止最后一个等待者漏唤醒doReleaseShared()

两个易错点:

  • 负数 = 正常,正数 = 异常。判断代码里到处是 waitStatus < 0(正常节点)、waitStatus > 0(取消节点),CANCELLED 是唯一正数,用大小比较而不是等值比较来筛。
  • SIGNAL 是前驱的承诺,不是自己的状态。「节点 A 的 waitStatus == SIGNAL」意思是「A 释放后会唤醒 A 的后继」。面试答反了就前功尽弃。

PROPAGATE 只在共享模式(Semaphore、ReadWriteLock 的读锁)里出现:共享释放可能要连续唤醒多个等待者,JDK 用「唤醒传播 + 最后一个节点置 PROPAGATE」的方式保证新入队的线程不会漏掉这次释放——独占锁(ReentrantLock)永远用不到它,知道「它是共享模式的补丁」即可,不必深究每个分支。

第 13 站

Condition 条件队列:两条队列的协作

newCondition() 创建的 ConditionObject 是 AQS 内部类,维护一条独立于 sync 队列的条件等待队列——AQS 最精巧的设计之一。

ConditionObject · await() / signal()
public final void await() throws InterruptedException {
    Node node = addConditionWaiter();      // ① 加入条件队列
    int savedState = fullyRelease(node);   // ② 完全释放锁(保存重入次数)
    while (!isOnSyncQueue(node)) {
        LockSupport.park(this);            // ③ park 阻塞
    }
    acquireQueued(node, savedState);        // ④ 回到 sync 队列重新竞争锁
}
public final void signal() {
    Node first = firstWaiter;
    if (first != null) doSignal(first);    // 唤醒条件队列第一个节点
}
private void doSignal(Node first) {
    do {
        if ((firstWaiter = first.nextWaiter) == null) lastWaiter = null;
        first.nextWaiter = null;
    } while (!transferForSignal(first) &&   // 转移到 sync 队列
              (first = firstWaiter) != null);
}
Condition 机制:sync 队列与条件队列的协作 AQS Sync Queue(同步队列) head dummy Thread-X ws=SIGNAL Thread-Y ws=SIGNAL signal 后转入 重新竞争锁 Condition Queue(条件队列)—— 单向链表,nextWaiter 链接 Thread-A CONDITION(-2) Thread-B CONDITION(-2) Thread-C CONDITION(-2) firstWaiter lastWaiter signal():转移节点到 sync 队列并 unpark await() / signal() 完整流程 await():释放锁 → 入条件队列 → park → 被 signal 后转入 sync 队列 → 重新竞争 signal():取条件队列 firstWaiter → 转入 sync 队列 → unpark 该线程
图 5Condition 双队列协作:await 将线程从 sync 队列移入条件队列,signal 将其转回
await() vs Object.wait()

两者都需要持锁调用、都释放锁并阻塞。关键差异:Object.wait() 只有一个 waitSet,Condition 可以创建多个实现精确唤醒(如 ArrayBlockingQueue 的 notFull/notEmpty);Condition 支持超时、不可中断等变体;底层机制不同——wait 基于 monitor,Condition 基于 AQS park/unpark。

用 ReentrantLock + 双 Condition 手写一个有界生产者-消费者,是面试白板题的高频变体,直接背下这个骨架:

生产者-消费者:ReentrantLock 版(可直接运行)
class BoundedQueue<T> {
    private final Deque<T> deque = new ArrayDeque<>();
    private final int cap;
    private final Lock lock = new ReentrantLock();
    private final Condition notFull  = lock.newCondition();  // 生产者等
    private final Condition notEmpty = lock.newCondition();  // 消费者等

    public BoundedQueue(int cap) { this.cap = cap; }

    public void put(T v) throws InterruptedException {
        lock.lock();
        try {
            while (deque.size() == cap) notFull.await();   // ★ while 不是 if
            deque.addLast(v);
            notEmpty.signal();   // 精确唤醒一个消费者
        } finally {
            lock.unlock();
        }
    }

    public T take() throws InterruptedException {
        lock.lock();
        try {
            while (deque.isEmpty()) notEmpty.await();
            T v = deque.removeFirst();
            notFull.signal();    // 精确唤醒一个生产者
            return v;
        } finally {
            lock.unlock();
        }
    }
}

两个必须答出来的细节:

  • 为什么 while 而不是 if 被唤醒 ≠ 条件成立:① 虚假唤醒(JVM 允许);② 多个消费者被唤醒后只有一个能拿到元素,其他必须重新检查。synchronized 版的经典「flag 变量 + 单一 waitSet」写法,正是用两个布尔标志模拟这种双队列——Condition 把它原生化了。
  • 为什么 signal 而不是 signalAll? put 只新增一个元素,唤醒一个消费者就够了;signalAll 会把所有等待线程都拉起来重新抢锁,多出来的全部白跑一趟。性能敏感场景,能 signal 就别 signalAll。

除了 signal() 只唤醒一个节点,signalAll() 会将条件队列中所有节点转移到 sync 队列并逐一 unpark——条件变化影响所有等待者时(比如队列容量动态调整)用它。

维度Object.wait()Condition.await()
前置条件持有 synchronized 监视器锁持有 ReentrantLock
等待队列数一个(waitSet)可创建多个 Condition
唤醒notify / notifyAllsignal / signalAll
超时支持(wait(long ms))支持(await(long, TimeUnit))
不可中断不支持支持(awaitUninterruptibly())
底层机制JVM monitorAQS park/unpark
第 14 站

模板方法模式:哪些钉死,哪些留白

回到第 3 站的餐厅类比——「领位台」和「座位规则」的分工,在代码上就是模板方法模式(Template Method):

钉死在父类(AQS)留给子类(tryXxx 钩子)
排队:addWaiter / enq(CAS 入队)独占:什么是「获取成功」→ tryAcquire(arg)
挂起/唤醒:acquireQueued / unparkSuccessor独占:什么是「释放成功」→ tryRelease(arg)
取消处理:cancelAcquire(跳过 CANCELLED 节点)共享:共享获取 → tryAcquireShared(arg)
模板骨架:acquire / release / acquireShared / releaseShared共享:共享释放 → tryReleaseShared(arg)

为什么这样切分?因为排队、挂起、唤醒是并发代码里最容易写错的部分(竞态、漏唤醒、死循环),Doug Lea 把它写成一次、调试到稳,所有同步工具复用;而「什么算拿到锁」每个工具的定义完全不同,必须留给子类。这正是开闭原则(Open-Closed Principle)的教科书级体现——对扩展开放(新工具),对修改关闭(队列逻辑不再动)。

推论:你以后自己写一个同步工具(比如限流器),不需要碰队列和 park,只需要继承 AQS,想清楚两件事——state 怎么定义(比如 state = 剩余令牌数)、tryAcquireShared 怎么写(state >= acquires 就 CAS 扣减)。Semaphore 就是这么来的(第 15 站)。

第 15 站

AQS 家族:CountDownLatch、Semaphore、ReadWriteLock

理解了 AQS 三板斧,其他工具迎刃而解——差异仅在于state 的语义tryAcquire/tryRelease 的实现

CountDownLatch.Sync · 核心逻辑
Sync(int count) { setState(count); }  // state = 初始计数值
protected int tryAcquireShared(int arg) {
    return (getState() == 0) ? 1 : -1;  // state==0 成功,否则阻塞
}
protected boolean tryReleaseShared(int arg) {
    for (;;) {
        int c = getState();
        if (c == 0) return false;
        if (compareAndSetState(c, c - 1))
            return c - 1 == 0;  // 减到 0 才释放所有等待线程
    }
}
Semaphore.NonfairSync · 核心逻辑
protected int tryAcquireShared(int acquires) {
    for (;;) {
        int available = getState();
        int remaining = available - acquires;
        if (remaining < 0 || compareAndSetState(available, remaining))
            return remaining;  // <0 表示许可不够,获取失败
    }
}
protected boolean tryReleaseShared(int releases) {
    for (;;) {
        int c = getState(), next = c + releases;
        if (compareAndSetState(c, next)) return true;
    }
}
ReentrantReadWriteLock.Sync · state 位拆分
static final int SHARED_SHIFT = 16;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;  // 0x0000FFFF
// 读锁计数 = state 高 16 位(无符号右移)
static int sharedCount(int c) { return c >>> SHARED_SHIFT; }
// 写锁计数 = state 低 16 位(位与掩码)
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
ReadWriteLock 为什么拆高 16 位和低 16 位?

在同一个 state 中同时记录读写锁持有数,只需一次 CAS 操作就能完成状态变更,避免维护两个独立变量的一致性问题和额外同步开销。代价是每种锁最多支持 65535 次重入。这和线程池的 ctl(高 3 位状态 + 低 29 位线程数)是同一个设计手法:一个 volatile int 打包多份状态,一次 CAS 原子更新——JUC 里到处都是这个套路。

工具state 语义模式tryAcquire 逻辑
ReentrantLock0=空闲,>0=重入次数独占state==0 则 CAS;持有者则 state+1
CountDownLatch倒计时计数共享state==0 成功,否则阻塞
Semaphore可用许可数共享state >= acquires 则 CAS 减少
ReadWriteLock高16位=读,低16位=写独占+共享写锁:无读无写则CAS;读锁:无写锁则CAS增加高位

除了上述四个经典工具,ThreadPoolExecutor 的 Worker 也内部继承了 AQS,实现了一个不可重入的独占锁(state 只有 0 和 1),用于保护工作线程的状态。FutureTask 的 Sync 同样基于 AQS,state 表示任务生命周期(NEW → COMPLETING → NORMAL/CANCELLED/EXCEPTIONAL)。AQS 是整个 JUC 包真正的"地基"。

第 16 站

生产落地:最大的坑是忘记 unlock

AQS 再精巧,也救不了业务代码里最常见的一手——异常路径漏掉 unlock

反例:一个异常,锁泄漏,全员死等
lock.lock();
// 业务代码抛了异常(NPE / 超时 / DB 断了……)
doSomething();          // ← 这里炸了
lock.unlock();          // ← 永远执行不到!锁被永久占用

锁泄漏的表现非常隐蔽:服务不报错、不 OOM,只是某个功能慢慢变慢——所有后续请求都排在 CLH 队列里 WAITING,jstack 一抓全是一排 waiting to acquire lock。唯一规范写法:

唯一正确姿势:try-finally
lock.lock();
try {
    // 临界区
} finally {
    lock.unlock();   // 无论成功失败、抛什么异常,必走
}

synchronized 没有这个坑(出块自动释放),这是它最实际的优势之一。生产选型速查:

维度synchronizedReentrantLock
释放自动(出块即释放,异常也安全)手动,必须 try-finally
可中断✓ lockInterruptibly()
超时✓ tryLock(timeout, unit)
公平锁✗(只能非公平)✓ new ReentrantLock(true)
多条件队列✗(单一 waitSet)✓ 多个 Condition
性能JDK 6 后与 ReentrantLock 接近;无竞争时略优竞争激烈的复杂逻辑下更灵活
代码量少,不易出错多,有漏 unlock 风险
生产选型决策

默认 synchronized:简单临界区、锁持有时间极短、没有特殊需求——自动释放让你少一类 bug。出现以下任一需求再换 ReentrantLock:① 需要可中断/超时(长等待、可取消任务);② 需要公平性(严格 FIFO);③ 需要多条件精确唤醒(自定义生产者-消费者);④ 需要 tryLock 做乐观/降级逻辑。换过去之后,try-finally 是红线,Code Review 里看到没有 finally 的 unlock 直接打回。

总结

这一篇你掌握了什么

AQS 的设计哲学归结为三个核心原则:

  • 状态驱动:所有同步语义通过一个 volatile int state 表达,CAS 保证原子修改
  • 队列兜底:获取失败的线程统一进入 CLH 变体队列,park/unpark 管理生命周期,避免忙等
  • 模板方法:AQS 定义 acquire/release 骨架,子类只需重写 tryAcquire/tryRelease 实现定制语义
AQS 工具全景图:同一框架,不同语义 AbstractQueuedSynchronizer volatile state + CLH 队列 + 模板方法 ReentrantLock 独占 | state=重入计数 CountDownLatch 共享 | state=倒计时 Semaphore 共享 | state=许可数 ReadWriteLock 独占+共享 | state 拆高低位 一个 AQS,多种同步语义 差异仅在 state 的含义和 tryAcquire/tryRelease 的实现
图 6AQS 工具全景图:所有同步工具共享同一个底层框架

核心知识点回顾

  • 三大件:volatile state(CAS 原子修改)+ CLH 变体双向队列(park/unpark 不忙等)+ 模板方法(排队逻辑钉死,语义钩子留给子类)。
  • state 语义因工具而异:ReentrantLock=重入次数、CountDownLatch=倒计时、Semaphore=许可数、ReadWriteLock=高16读+低16写。
  • lock() 路径:CAS(0→1) 抢 → 失败 tryAcquire(含重入)→ 失败入队 → acquireQueued 自旋+park → unpark 后重新竞争。
  • unlock() 路径:state-1,减到 0 才清 owner + unparkSuccessor(从 tail 往前找有效节点)。
  • 可重入:owner==自己则 state+1,解锁逐步减,减到 0 才真释放。
  • 公平 vs 非公平:差在 hasQueuedPredecessors();默认非公平吞吐更高,但有饥饿可能。
  • 争抢不会消失,只会被重新安排p == head 把 CAS(state) 的参与者限到「队首 1 人 + 插队者」,输的人试一两次就 park,重入/解锁 0 次 CAS——所以对 state 的 CAS 频率由锁的交接频率决定,与排队人数无关。可扩展性来自队列,不是来自 CAS。
  • synchronized 给不了的:lockInterruptibly / tryLock 超时 / 多 Condition。
  • waitStatus:SIGNAL 是前驱的承诺;CANCELLED 唯一正数;PROPAGATE 只管共享模式。
  • 生产红线:unlock 必须在 finally;默认 synchronized,有进阶需求再换 ReentrantLock。

一句话总结

AQS = 「state 决定你能不能赢,队列决定你输的时候怎么等,模板方法决定『赢』对每个工具意味着什么」。

🎤 面试 30 秒总结

Q:请介绍 AQS 的原理以及 ReentrantLock 的实现。

"AQS 是 JUC 包的核心框架,内部维护 volatile int state 和 CLH 改造的双向 FIFO 队列。AQS 采用模板方法模式,子类重写 tryAcquire/tryRelease 实现不同同步语义。

ReentrantLock 基于 AQS 实现独占锁。state=0 空闲,>0 重入次数。lock() 先 CAS(0→1),失败则 tryAcquire(含重入检查),仍失败则入 CLH 队列 park。unlock() 时 state-1,减到 0 释放并 unparkSuccessor 唤醒后继。

公平锁在 tryAcquire 中多了 hasQueuedPredecessors() 检查,保证 FIFO 顺序;非公平锁允许直接 CAS 插队,减少上下文切换,吞吐量更高。绝大多数场景用非公平即可。

Condition 通过独立条件队列实现 wait/notify。await() 释放锁入条件队列 park,signal() 转移节点到 sync 队列并 unpark。支持多个 Condition 实现精确唤醒。"

Q:公平锁和非公平锁如何选择?

"绝大多数场景用非公平锁(默认),性能更好。只有需要严格保证不饥饿或业务要求 FIFO 顺序时才用公平锁。"

Q:AQS 靠 CAS 改 state,那锁竞争最后不还是变成 CAS 竞争吗?这样设计有什么优势?

"竞争消不掉,只能重新安排。AQS 里对 state 的 CAS 被 p == head 限死了:队列中只有队首那一个线程有资格再试,其余线程全在 park;失败的人试一两次也去排队睡觉,不自旋,而且赢家后续的重入和释放都走 setState,不再发 CAS。所以对 state 的 CAS 频率约等于锁的交接频率,跟排队人数无关,这是它和 AtomicLong 单点自旋最本质的差别。剩下的 N−1 份压力交给内核等待队列,睡着不占 CPU。
所以 AQS 的可扩展性不是来自 CAS,而是来自那条把「抢」变成「等」的队列;CAS 在这里只是最便宜的仲裁器——AQS 本身就是实现锁的框架,用锁保护 state 会循环依赖,最终只能落到 lock cmpxchg 这个硬件原语上。代价是唤醒后仍要重新竞争、可能饥饿,以及共享模式的释放侧没有闸门、是真正的自旋热点。"

Q:ReentrantLock 和 synchronized 怎么选?

"默认 synchronized——自动释放没有锁泄漏风险。需要可中断、超时、公平、多 Condition 时用 ReentrantLock,但 unlock 必须放 finally。"

Comments · 评论