LockSupport 与 AQS:并发工具的骨架
ReentrantLock、Semaphore、CountDownLatch——三种表面不同的工具,为什么源码都藏在同一个基类里?CyclicBarrier为什么不继承它?
第 6 章的 synchronized把互斥锁封装在 JVM 内部。开发者只有一个开关:synchronized/不 synchronized。这一章讨论的是另一条路:把锁的实现搬到 Java 代码层面,让"如何挂起线程"、"如何组织等待队列"、"如何唤醒"这些机制变得可编程。LockSupport 提供底层的线程挂起与唤醒机制,AQS 在其上封装同步状态、等待队列和获取释放流程。理解这两层之后,再看 ReentrantLock、Semaphore 和 CountDownLatch 的实现,会更容易区分它们共享的机制与各自的同步语义。
版本基线:本页按 JDK 21 的
AbstractQueuedSynchronizer实现讲解。JDK 8 时代常见资料使用waitStatus、SIGNAL、unparkSuccessor和PROPAGATE;JDK 21 已改用Node.status、WAITING、CANCELLED、COND与signalNext。概念模型仍然相通,但源码名称和传播路径不能混用。
1. synchronized 走不到的地方
1.1 五个 synchronized 做不到的诉求
synchronized 的语义是"要么拿到锁进入临界区,要么阻塞等"。真到线上,业务几乎每天都在提出更细的要求:
| 需求 | 仅使用 synchronized 这一监视器机制时的限制 |
|---|---|
| "我最多等 3 秒,拿不到就走" | 做不到 |
"我等锁的时候允许被 interrupt 打断" | 做不到 |
| "同一把锁,读线程之间不要互斥" | 做不到 |
| "等的时间越久越优先,别让新来的插队" | 做不到 |
| "生产者等'非满'、消费者等'非空',两条队列别搅在一起" | 做不到(一个 Monitor 只有一条等待队列) |
不是 synchronized 设计得差,而是它把选择权全部下放到了 JVM 内部——JVM 只做互斥这一种语义。任何超出互斥的诉求,都需要一套 Java 层可编程的锁基础设施。
1.2 把锁搬到 Java 层,需要什么
如果不再依赖 monitorenter/monitorexit,一把互斥锁至少要自己回答三个问题:
- 状态用什么表达:几把锁、有没有人持锁、重入了几次
- 争锁失败的线程放到哪里:等待队列的数据结构、入队方式
- 挂起和唤醒怎么实现:谁把线程挂起、谁把它叫醒
第一个问题的答案是:一个 volatile int + CAS。第二个问题的答案是:一条 FIFO 双向链表。第三个问题的答案是:LockSupport.park / unpark。
这三样加在一起就是 AQS 的骨架。但要理解 AQS 的挂起/唤醒是怎么发生的,得先看第三样——LockSupport。
开篇问的 CyclicBarrier 为什么不继承 AQS:它通过 ReentrantLock 和 Condition 协调一组线程到达屏障后共同放行,并支持屏障重复使用,不需要自己实现同步状态和等待队列,因此直接组合而非继承。AQS 是构建同步器的工具,不是所有并发工具都必须继承的基类。
2. LockSupport:许可证式挂起
2.1 park / unpark 的语义
java.util.concurrent.locks.LockSupport 只有两个核心静态方法:
LockSupport.park(); // 挂起当前线程
LockSupport.park(Object blocker); // 同上,附带阻塞原因,dump 时能看到
LockSupport.unpark(Thread t); // 唤醒指定线程它对每条线程维护一张许可证(permit):
unpark(t)把 t 的许可证置为"可用"park()检查当前线程的许可证:可用则消耗掉后立即返回;否则线程可能被挂起。之后park()可以因其他线程调用unpark()、当前线程被中断,或发生虚假唤醒而返回,调用方必须重新检查等待条件,不能把park()返回等同于"等待条件已满足"
许可证是一个二值状态——不是计数器。许可证已可用时,再次 unpark 不会累积额外许可证。
2.2 三点关键差异
LockSupport 与 Object.wait/notify 表面上都能挂起线程,但用途完全不同:
| 维度 | wait / notify | park / unpark |
|---|---|---|
| 是否需要持锁 | 必须持有对象的 Monitor,否则 IllegalMonitorStateException | 不需要 |
| 唤醒目标 | 从 _WaitSet 里挑一个(notify 不确定,notifyAll 全部) | 精确指定某条线程 |
unpark / notify 早于挂起 | 无效,notify 时如果没人在 wait,什么都不发生 | 有效,许可证保留,后续 park 立即返回 |
| 中断行为 | 抛 InterruptedException | park 直接返回,需自行检查 Thread.interrupted() |
"唤醒可以先于挂起" 是许可证语义带来的关键性质。用 wait/notify 实现一个"线程 A 通知 B"的原语,要小心 A 的 notify 早于 B 的 wait 的时序——一旦跑到前面,B 就会永远睡下去。park/unpark 不存在这个时序坑。
2.3 一个可以自己写的简易锁
用 park/unpark 就能拼一把最简的互斥锁:
public class SimpleLock {
private final AtomicBoolean locked = new AtomicBoolean(false);
private final Queue<Thread> waiters = new ConcurrentLinkedQueue<>();
public void lock() {
Thread current = Thread.currentThread();
while (!locked.compareAndSet(false, true)) {
waiters.offer(current);
// 二次检查:可能就在这一瞬间锁被释放了
if (locked.get()) {
LockSupport.park(this);
}
waiters.remove(current);
}
}
public void unlock() {
locked.set(false);
Thread next = waiters.peek();
if (next != null) LockSupport.unpark(next);
}
}这段代码只是演示 CAS + 等待队列 + park/unpark 的组合思路,不是可直接用于生产的锁实现。它不保证公平性,不支持重入,也没有校验释放操作的线程所有权;中断处理和队列维护同样不完整。许可证机制可以解决部分检查与挂起之间的时序问题,但不能单独保证整个锁算法的正确性。它揭示的事实是:"CAS 抢状态 + 队列排队 + park/unpark 挂起唤醒"就是一把锁的最小工作集。AQS 做的事,本质上就是把这套模式提炼成一个可扩展的框架,并用节点状态、队列协议和取消清理逻辑协调竞争线程。
2.4 底层实现的一句话交代
LockSupport.park 最终委托到 JVM 的线程挂起实现;具体系统调用会随 JDK 版本和操作系统变化,不能笼统地归结为某一个 POSIX 函数。挂起线程在 Java 层通常表现为 WAITING,在操作系统层面进入等待状态,不持续消耗 CPU。这也是 AQS 得以在未拿到锁的线程上不空转的技术底座。
3. AQS 的三件套
3.1 一个 state + 一个 CLH 队列 + 一套 Node 状态
AbstractQueuedSynchronizer(AQS)的内部结构:
┌──────────────────────────────────────────────┐
│ AbstractQueuedSynchronizer │
│ │
│ volatile int state ← 同步状态 │
│ │
│ Node head ─→ Node ─→ Node ─→ Node ← tail │
│ │ │ │ │ │
│ │ thread=B thread=C thread=D │
│ │ status=1 status=1 status=0 │
│ (虚节点) │
└──────────────────────────────────────────────┘state:volatile int。语义由子类决定——ReentrantLock里它是"重入次数",Semaphore里它是"剩余许可数",CountDownLatch里它是"未完成计数"。修改通过compareAndSetState保证原子性。- CLH 队列:一条 FIFO 双向链表,节点类型是
Node。抢锁失败的线程被封装成 Node 挂到队尾。队头是一个"虚节点"(sentinel),当前持锁线程本身不在队列中——head 是"下一个要被唤醒的候选人的前驱"。 - Node 的
status:一个volatile int,用位组合表示等待、取消和条件等待状态。
3.2 Node 的状态位(JDK 21)
| 状态位 | 值 | 含义 |
|---|---|---|
WAITING | 1 | 等待线程可能正在 park,唤醒方需要清除该位再 unpark |
CANCELLED | 0x80000000 | 节点对应的获取操作已取消,例如因超时或可中断获取被中断而退出,该节点不再参与正常获取流程 |
COND | 2 | 节点当前处于 Condition 等待 |
| 0 | 0 | 未设置上述状态位;能否安全 park 仍需配合队列位置复核 |
JDK 21 通过原子位操作避免 park/unpark 竞态:等待线程先设置 WAITING 再复核,唤醒方清除 WAITING 后调用 unpark。旧资料中的"四状态 waitStatus"对应的是另一版实现,不能直接套到这里的常量表上。上表列出的是 JDK 21 AQS 内部实现使用的状态值,不是 AbstractQueuedSynchronizer 的公共 API 契约,理解时应关注这些状态如何参与等待、取消与唤醒协议。
3.3 模板方法模式:分离"如何获取"与"失败后怎么办"
AQS 用模板方法把工作切成两半:
AQS 已经写好的(不变部分):
- CLH 队列的入队 / 出队 / 前驱状态维护
LockSupport.park/unpark挂起唤醒- 中断响应
- 超时控制
- 公平/非公平策略骨架
子类需要实现的(变化部分):
| 方法 | 语义 | 何时实现 |
|---|---|---|
tryAcquire(int) | 独占模式:尝试获取,成功 true 失败 false | ReentrantLock 等独占锁 |
tryRelease(int) | 独占模式:尝试释放,true 表示同步器已完全释放 | 独占锁 |
tryAcquireShared(int) | 共享模式:负数失败,零成功但后续可能失败,正数成功且后续可能成功 | Semaphore / CountDownLatch |
tryReleaseShared(int) | 共享模式:释放并返回是否需要传播 | 共享同步器 |
isHeldExclusively() | 当前线程是否独占持有 | 支持 Condition 时需实现 |
子类不需要碰队列、park、中断处理——那些 AQS 已经解决过一次,之后所有子类共用。AQS 只提供同步框架,子类仍必须正确实现状态转换、并发安全和释放语义;只要子类能用 state 表达同步条件,并正确实现获取、释放与必要的所有权检查,就可以复用 AQS 的队列和等待机制。
4. 独占模式与共享模式
AQS 从入口就分成两条路径:acquire / release 走独占,acquireShared / releaseShared 走共享。骨架相同,唤醒策略不同。
4.1 独占模式:先尝试,再入队,再挂起
ReentrantLock 是独占模式的典型。acquire 的骨架:
public final void acquire(int arg) {
if (!tryAcquire(arg))
acquire(null, arg, false, false, false, 0L);
}核心过程仍是“尝试、入队、复核并挂起”,但 JDK 21 将这些步骤集中到内部 acquire(...) 方法,不再沿用旧版 addWaiter + acquireQueued 的公开结构:
第一步 tryAcquire(子类实现):非公平模式直接 CAS 抢,公平模式先看队列有没有前驱。
// NonfairSync 非公平模式
final boolean nonfairTryAcquire(int acquires) {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
} else if (current == getExclusiveOwnerThread()) { // 重入
setState(c + acquires);
return true;
}
return false;
}// FairSync 公平模式:多一行前驱检查
if (c == 0) {
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}第二步(入队):把当前线程包成 Node 挂到队尾。快速路径是“tail 非空 + 一次 CAS”,慢路径用自旋 CAS 处理“队列尚未初始化”和并发入队的边界。
第三步(自旋 + 挂起):
重复:
若自己是队首候选 → 再次 tryAcquire,成功则更新 head
若前驱已取消 → 清理队列并重新检查
若 status 尚未标记 → 设置 WAITING 并重新复核
否则 → park,被 unpark 后检查中断/取消并重试这段是根据 JDK 21 源码压缩后的流程说明,不是逐行方法名对照。关键契约是:线程在确认自己仍需等待后设置 WAITING,唤醒方必须先清除此位,避免唤醒与 park 发生竞态。
最终仍通过 LockSupport.park 挂起,直到唤醒方调用 unpark。
把这三步串起来,完整的 acquire 流程如下:
获取过程可能多次自旋:如果前驱状态为 CANCELLED,线程会调用清理逻辑跳过无效节点;确认进入等待后设置 WAITING,再 park 并复核。这个过程保证取消节点不会长期阻塞唤醒链。
4.2 独占模式的 release:只干两件事
public final boolean release(int arg) {
if (tryRelease(arg)) { // state 归零?
signalNext(head); // 清 WAITING 并唤醒后继
return true;
}
return false;
}tryRelease 由子类决定“归零”的条件。ReentrantLock 里必须减到 0 才算真释放——重入了三次要 unlock 三次。归零后,signalNext 检查 head.next,原子清除后继的 WAITING 并调用 LockSupport.unpark。被唤醒的线程回到获取流程再次尝试。
JDK 21 的 signalNext 主要沿可见的 next 指针定位后继,并用 status 协调 park/unpark;取消节点的复杂清理由 cleanQueue 等路径处理。不要把旧版 unparkSuccessor “从 tail 回溯”的实现细节当作当前 JDK 的固定流程。
4.3 共享模式:唤醒之后还要接力
Semaphore.acquire(1) / CountDownLatch.await 走共享路径:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}tryAcquireShared 返回值语义与独占版不同:负数=失败,零=成功但后续共享获取可能失败,正数=成功且后续共享获取可能成功。这个返回值表达的是获取结果以及是否可能继续传播共享获取,不是普遍的"剩余资源数"。CountDownLatch 在计数归零后让等待线程通过,其返回值并不是把"剩余许可数"返回给调用方。
举一个 CountDownLatch 的场景:
CountDownLatch(3):三条线程 A / B / C 都在 await
队列: head → NodeA → NodeB → NodeC ← tail
外部调用 countDown 三次,state = 0。
最后一次 countDown 唤醒 NodeA。如果队首已经完成但仍存在其他共享等待者,就需要继续唤醒下一个共享节点。JDK 21 通过 SharedNode 类型和 signalNextIfShared 实现这一步。
共享模式与独占模式的关键区别,不是"唤醒所有等待者",而是一次成功获取或释放后,可能还有其他等待线程也具备获取条件。实现示意(概念流程,不是逐行源码对照):
共享获取失败
→ 节点入队
→ 等待并重新尝试获取
共享获取成功
→ 将成功节点设为新的队头
→ 根据共享获取结果和队列状态,判断是否需要继续唤醒共享后继signalNextIfShared 只唤醒 SharedNode 类型的后继:清除 WAITING,再调用 unpark。释放方的 releaseShared 同样调用 signalNext(head);配合重新获取后的 signalNextIfShared,让共享许可继续向后传递。是否继续传播取决于共享获取结果和队列状态,不是无条件唤醒所有后继节点。CountDownLatch 的计数归零后等待线程可以依次被唤醒并通过检查,Semaphore 则取决于剩余许可是否足够。JDK 21 的 AQS 实现不再使用旧版的 PROPAGATE 状态协议。
4.4 独占 vs 共享的核心差异
| 维度 | 独占模式 | 共享模式 |
|---|---|---|
| 同一时刻持有者 | 1 | 多个 |
state 语义 | 是否被占 / 重入计数 | 剩余许可 / 未完成计数 |
| 获取失败判据 | tryAcquire 返回 false | tryAcquireShared 返回负数 |
| 释放后唤醒 | 只唤醒队首后继 | 唤醒后继并沿链传播 |
| 典型工具 | ReentrantLock / ReadWriteLock 写锁部分 | Semaphore / CountDownLatch / 读锁部分 |
5. 本页小结
| 问题 | 根源 | 解决方案 |
|---|---|---|
| 需要超时、中断或公平获取 | synchronized 把选择权留在 JVM 内部 | LockSupport 将挂起与唤醒暴露给 Java 层 |
| 唤醒可能早于挂起造成丢失 | notify 没有许可证语义 | LockSupport.unpark 保留许可证 |
| 每种同步器都要重复实现等待队列 | 队列与业务语义耦合 | AQS 抽出 state + CLH + park/unpark 骨架 |
| 独占与共享的唤醒规则不同 | 单持有者与多持有者的传播方式不同 | 共享模式通过 SharedNode + signalNextIfShared 继续传播 |
下一页从 Condition 的条件队列开始,再集中比较基于 AQS 的常用同步工具。
下一页: Condition 与 AQS 同步工具