AQS 排队骨架与 Condition 等待协议
版本基线:API 契约以 Java SE 17 为准,实现分析固定到 OpenJDK 17u
jdk-17.0.12+7。本机实际编译运行版本为 Oracle JDK 17.0.12+8(x86_64)。锁对象语义、公平性与读写锁的选型权威在锁正文;JMM 推导在JMM 正文;五个 j.u.c 同步器在同步器正文。本页讲排队骨架本身:AQS 的state与 CLH 变体队列、release 协议、Condition 的两类队列转移、唤醒丢失与恢复,以及自定义同步器的责任。
AQS 17u:state、排队、阻塞与取消
AbstractQueuedSynchronizer.java 用一个 volatile int state、head/tail 和等待节点搭出全部排队逻辑;子类决定 state 含义与 tryAcquire/tryRelease 规则。ReentrantLock 的 state 是重入层数,Semaphore 是许可数,CountDownLatch 是剩余计数,ReentrantReadWriteLock 是读写两个计数的位拆分(各自语义见对应正文)——骨架不知道这些含义,这就是"框架与协议分离"。
本标签使用 ExclusiveNode、SharedNode、ConditionNode 及 WAITING、COND、CANCELLED 状态,不能用旧版 SIGNAL、PROPAGATE 节点图逐行解释。
统一 acquire 循环的教学改写如下,省略共享传播、自旋次数和内存访问细节:
循环:
检查前驱;前驱取消 -> cleanQueue后重试
是队首候选或尚未入队 -> 调用子类tryAcquire
成功 -> 更新head、断开旧节点,必要时恢复中断标记
未创建节点 -> 分配节点;下一轮再次尝试
未入队 -> CAS更新tail,再补前驱next
未设置等待状态 -> 设置WAITING,回到循环重新检查
否则park / parkNanos
取回控制权后清状态;处理超时、中断与取消"先设置等待标记再复查"连接了获取失败与睡眠之间的窗口:若解锁已经发生,下一轮可能直接获取,避免错过信号后永久睡下去。unpark 是使线程有机会继续,不是移交锁;取消节点还可能令 next 链临时不完整,所以实现包含清理和重新链接,不能只说"取 head.next 唤醒即可"。
原码:节点状态位与等待队列字段
原码连续摘录(仅统一公共缩进):AbstractQueuedSynchronizer.java,jdk-17.0.12+7,L448–L458。
// Node status bits, also used as argument and return values
static final int WAITING = 1; // must be 1
static final int CANCELLED = 0x80000000; // must be negative
static final int COND = 2; // in a condition wait
/** CLH Nodes */
abstract static class Node {
volatile Node prev; // initially attached via casTail
volatile Node next; // visibly nonnull when signallable
Thread waiter; // visibly nonnull when enqueued
volatile int status; // written by owner, atomic bit ops by othersWAITING、COND、CANCELLED 是状态位而不是互斥枚举:COND | WAITING 可以同时存在,getAndUnsetStatus 用它竞争条件转移责任。next 不是始终稳定可读的链表指针,取消与入队都可能临时留下不完整链接,因此实现需要 cleanQueue 与重新链接。
原码:hasQueuedPredecessors——公平性的公共门
公平类同步器(公平 ReentrantLock、公平 Semaphore、公平读写锁)不各自实现公平逻辑,全部汇聚到这一个骨架方法:
原码连续摘录(仅统一公共缩进,截取方法局部):AbstractQueuedSynchronizer.java,jdk-17.0.12+7,L1232–L1239。
public final boolean hasQueuedPredecessors() {
Thread first = null; Node h, s;
if ((h = head) != null && ((s = h.next) == null ||
(first = s.waiter) == null ||
s.prev == null))
first = getFirstQueuedThread(); // retry via getFirstQueuedThread
return first != null && first != Thread.currentThread();
}返回"有没有排在当前线程之前的等待者"。读法分两步:队首后继读取途中出现任何不一致(next 为空、waiter 为空、prev 未回填——都发生在入队/清理的中间态),就退回 getFirstQueuedThread() 遍历,宁可慢一点也不误判。这是锁正文公平 initialTryLock/tryAcquire、同步器正文公平 Semaphore 判定的共同落点:公平性检查是"队列状态谓词",不是不同的 CAS 指令。该方法是读取侧估算:判断与后续 CAS 之间仍可能插入新竞争者,公平门减少而无法消除插队窗口(排队后的 tryAcquire 会再查一次)。
release 协议:返回值决定是否通知
原码连续摘录(仅统一公共缩进,截取方法局部):AbstractQueuedSynchronizer.java,jdk-17.0.12+7,L1006–L1012。
public final boolean release(int arg) {
if (tryRelease(arg)) {
signalNext(head);
return true;
}
return false;
}tryRelease 是子类的资源协议;只有返回 true 才 signalNext。ReentrantLock 尚有重入层数时不做最终释放(tryRelease 返回 false,不通知),后继被通知也仍须重新竞争,不能将这个分支画成锁的直接移交。
自定义同步器的两条致命错误,都是这个协议的反面教材:
- 子类在"资源未真正归还"时错误返回
true→ 骨架会signalNext唤醒后继,后继tryAcquire却可能失败后继续睡——错误唤醒、吞吐劣化;更坏的是若子类tryAcquire也配合出错,两个线程同时"持有",互斥被破坏。 - 子类
state转换与业务资源数量不一致(如 state 减到 0 但池里还有资源,或反之)→ 通知了但无人能获取,或能获取但资源已尽。
所以自定义同步器必须把"state 每个值的含义、tryAcquire/tryRelease 在每个值上的转换、与业务资源的一一对应"一并证明,不应直接复制 ReentrantLock 当所有同步器模板。共享获取由 tryAcquireShared 表达,成功后可能继续传播到共享后继;这使 Semaphore、CountDownLatch 与独占锁使用同一排队骨架(各自的 state 语义见同步器正文)。
Condition:条件队列与锁队列的转移
每个 Condition 有自己的条件等待链,AQS 有锁竞争队列。await → enableWait → release(savedState) 先把节点加入条件链,再完整释放锁;signal 经 doSignal → enqueue 把满足条件状态的节点转到同步队列;await 结束前调用 acquire 重新获取 savedState。源码中一个关键操作是 node.getAndUnsetStatus(COND),它用原子状态变更协调通知与取消谁取得转移责任。
原码:先入条件链,再完整释放
原码连续摘录(仅统一公共缩进,截取方法局部):AbstractQueuedSynchronizer.java,jdk-17.0.12+7,L1501–L1517。
private int enableWait(ConditionNode node) {
if (isHeldExclusively()) {
node.waiter = Thread.currentThread();
node.setStatusRelaxed(COND | WAITING);
ConditionNode last = lastWaiter;
if (last == null)
firstWaiter = node;
else
last.nextWaiter = node;
lastWaiter = node;
int savedState = getState();
if (release(savedState))
return savedState;
}
node.status = CANCELLED; // lock not held or inconsistent
throw new IllegalMonitorStateException();
}isHeldExclusively 是前置条件;nextWaiter 连接的是当前 Condition 的条件链,不是同步队列。保存整个 state 后 release(savedState),重入两层就完整释放两层。未持锁或释放协议失败时标记 CANCELLED 并抛异常,不能静默进入等待。
假设线程重入两次后 await,只 unlock 一次仍会保留所有权,生产者就无法修改条件。因此 enableWait 保存整个 state 并 release(savedState),之后按这个数重新取得。signal 的调用者仍持锁,等待者不能在 signal 后立即越过临界区;只有通知者后续释放,竞争者才有机会继续。
原码:signal 通过 COND 位竞争转移责任
原码连续摘录(仅统一公共缩进,截取方法局部):AbstractQueuedSynchronizer.java,jdk-17.0.12+7,L1448–L1460。
private void doSignal(ConditionNode first, boolean all) {
while (first != null) {
ConditionNode next = first.nextWaiter;
if ((firstWaiter = next) == null)
lastWaiter = null;
if ((first.getAndUnsetStatus(COND) & COND) != 0) {
enqueue(first);
if (!all)
break;
}
first = next;
}
}先把节点摘出条件链;getAndUnsetStatus 原子清除 COND 并返回旧值,只有旧值含 COND 才 enqueue 到同步队列。如果取消先取得清除责任,通知会跳过并继续找后继;signalAll 用 all 控制是否继续。这里没有直接把资源所有者设为等待线程,仍需后续获取。
若中断先成功清除 COND,等待路径在重新获取锁后抛 InterruptedException 并清理条件链;若 signal 已先完成转移,中断可能改为在重获锁后恢复标记。不能简化成"只要中断就一定立刻退出 await"。无论正常通知、超时或中断,都要区分退出条件等待与取得锁这两个阶段;限时 await 不是整个方法返回时间的硬上界,因为重新竞争锁还需时间。
唤醒丢失:一个可钉住的确定性反例
丢失唤醒的经典成因不是 API 撒谎,而是谓词检查移出了锁的保护范围:
// 错误版本:锁外检查谓词,锁内无条件等待
if (queue.isEmpty()) { // (1) 锁外
lock.lock();
notEmpty.await(); // (2) 无条件——(1)(2) 之间的信号永久丢失
...
}时序上把两步钉开(实验第 2 节用两个闩锁):消费者在 (1) 读到空队列后暂停;生产者完整执行 lock → put → signal → unlock(信号发出,但没有"通知余额"可存);消费者恢复,进入锁内执行 (2)——await 从此无人再叫醒它,直到超时兜底。修复版本把谓词检查放回锁内并循环判断:
// 正确版本:谓词、等待、通知共用同一把锁;while 而非 if
lock.lock();
try {
while (queue.isEmpty())
notEmpty.await(); // 醒来后重查谓词
... // 消费
notFull.signal(); // 状态变更后通知对侧条件
} finally {
lock.unlock();
}修复之所以有效有三层:谓词检查与等待在同一临界区内,信号不可能落在两者之间;while 让被唤醒者重新验证条件(其他消费者可能先取走、也可能虚假唤醒);生产者修改条件后在同一锁内 signal。这同时解释了 notify 选择(锁正文的监视器协议):混合角色共用一个条件队列时,signal 可能唤醒错误角色(它回到 while 继续睡),signalAll 唤醒全部让正确角色推进——确定性更强、竞争更大。实验第 2 节两组断言:钉住交错的丢失唤醒版 await 直到超时(awaitNanos 返回负值、谓词其实早已满足);修复版正常推进;两个消费者一个元素时 signal 只放行一个(另一个仍在 while 中等待),signalAll 后按剩余资源数放行。
多个条件可以减少无关唤醒。例如有界队列用 notEmpty 与 notFull:put 在满时循环等待 notFull,入队后 signal notEmpty;take 在空时等待 notEmpty,取出后 signal notFull。状态检查、修改和通知必须受同一把锁保护。使用 signal 前应明确一次操作释放多少资源;批量变更可能需要多次 signal 或 signalAll。Condition 契约 说明等待、中断与虚假唤醒边界。
图:Condition 的两类队列转移
图中要区分三条线:条件链由 nextWaiter 连接;同步队列由 prev/next 连接;release(savedState) 释放的是整个重入层数。signal 只完成中间那次队列转移,最终执行仍要等通知者释放锁、等待者重新 acquire。
竞争时间线:重入 await 与中断竞争
| 时间 | 线程 A(重入 2 层) | 线程 B | AQS state / 队列 |
|---|---|---|---|
| t1 | 持锁,state=2 | 等锁 | state=2,owner=A |
| t2 | await:入条件链,保存 2,release(2) | 获得锁 | state=0,A 在条件链 |
| t3 | 睡眠 | 修改条件,signal | COND 清除,A 转同步队列 |
| t4 | — | 继续持锁 | state=1,A 尚未执行 |
| t5 | — | unlock | state=0,通知后继 |
| t6 | 重新 acquire 两层 | — | state=2,owner=A,await 返回 |
中断竞争要分两种顺序:
| 顺序 | 谁先清 COND | A 的返回 |
|---|---|---|
| 中断先赢 | 取消路径 | 重获锁后抛 InterruptedException,清理条件链 |
| signal 先赢 | 通知路径 | 重获 savedState 后返回,中断标记按实现恢复 |
这张表说明"中断立即退出 await"不成立;退出条件等待与重新取得锁是两个阶段。
实验:排队骨架与条件协议的公开入口
公开入口(仓库根):
JAVA_HOME=/Library/Java/JavaVirtualMachines/jdk-17.jdk/Contents/Home \
bash labs/java-senior/run.sh sync探针为 labs/java-senior/LocksProbe.java。本节相关输出(2026-10-25,Oracle JDK 17.0.12+8,macOS 26.7.1 x86_64):
== 1. monitor identity and ownership ==
different objects independent=0ms; same object waited=254ms(同身份互斥、不同身份独立——观察值)
wait() without holding monitor -> IllegalMonitorStateException
non-owner ReentrantLock.unlock() -> IllegalMonitorStateException
== 2. lost wakeup: pinned interleaving and recovery ==
lost wakeup: await waited full 800ms though signal already fired(信号无余额,等待到超时兜底)
recovered: while-predicate inside lock consumed immediately(谓词回锁内后不等待)
signal with one item woke exactly 1 of 2 consumers(signal 精确唤醒一个)
signalAll after second item woke the remaining consumer(signalAll 唤醒其余)断言口径:丢失唤醒用闩锁钉成确定性交错后断言"等待到超时";恢复版、signal/signalAll 唤醒数断言;重入 await 的层数恢复由 SeniorProbe 有界缓冲历史输出承载(总和不丢、缓冲不变量)。等待全部有 awaitNanos 上限。
边界:signal 精确唤醒数依赖固定两个消费者与一个元素的构造,不是一般调度证明;getAndUnsetStatus 的取消竞争路径未在实验中强制触发(正文以源码为据);完整 12 节输出与红绿记录见实测证据。
纸面推演(保留自基线)
推演一:线程 A 重入两次 await,B 取得锁修改条件并 signal,C 也参与争抢。逐步记录 AQS state、owner、A 所在队列和中断标记;分别把中断插在 signal 之前与之后。必须得到"重获两层后再处理返回/异常"的结论,不能把通知画成直接执行。
推演二:消费者用 if 等待,生产者 notifyAll,两个消费者醒来而只有一个元素。第一个取走后第二个必须再次检查条件,否则从空队列读取。该反例不依赖虚假唤醒,正常竞争就能暴露问题。
追问链
第一轮:为什么 while 等待而不是 if?
唤醒不承诺谓词仍成立:其他消费者可能先取走资源、可能是虚假唤醒、可能被 signalAll 顺带叫醒。while 把"重新检查"变成协议的一部分;if 只在"单条件、单消费者、无 signalAll"的理想世界里勉强成立。评分点:同锁保护、重复检查、资源被别人消费。
第二轮:Condition 为何不能只"把线程 unpark"?
线程必须从条件链转入锁竞争队列,通知与取消要确定转移责任(getAndUnsetStatus(COND) 的原子清除),最终恢复原持锁层数(savedState)。只 unpark 一个线程不进入同步队列,既没有获取资格也没有层数恢复,等于跳过锁协议。评分点:两类队列、COND 竞争、重新获取。
第三轮:AQS 换成"每个同步器自己写队列"会丢什么?
丢掉的是被验证过的排队/取消/中断/超时骨架与统一的公平门(hasQueuedPredecessors)。自研队列要重新回答:取消节点如何摘链、next 不完整时如何遍历、中断与超时如何归还队列位置、公平性谓词在哪检查——每一项 AQS 源码里都有专门的清理与重试逻辑。评分点:骨架与协议分离、四类边界、复用理由与自定义责任。
闭卷验收
本页 10 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。
来源与版本
| 来源 | 版本 | 用途 |
|---|---|---|
| Java SE 17 AbstractQueuedSynchronizer API | Java SE 17 | state/队列/条件队列契约与自定义同步器要求 |
| Java SE 17 Condition API | Java SE 17 | 条件等待、虚假唤醒与中断边界 |
| Java SE 17 LockSupport API | Java SE 17 | park/unpark 许可与语义 |
| OpenJDK 17u AbstractQueuedSynchronizer | jdk-17.0.12+7 | 节点状态位(L448–L458)、hasQueuedPredecessors(L1232–L1239)、release(L1006–L1012)、doSignal(L1448–L1460)、enableWait(L1501–L1517) |
本地 LocksProbe 实验 | Oracle JDK 17.0.12+8 | 监视器身份、丢失唤醒钉住交错与恢复、signal/signalAll 唤醒数实测 |
本地 SeniorProbe locks 历史 | Oracle JDK 17.0.12 | 有界缓冲两条件协议(总和不丢)历史输出 |
访问日期:2026-10-25。源码行号以 jdk-17.0.12+7 标签为准;运行环境为 Oracle JDK 17.0.12+8。队列清理、取消与共享传播的完整分支以源码为准,本文摘录只覆盖教学主链。