概述
AQS(AbstractQueuedSynchronizer)是 JUC 包中大多数并发工具类实现的基础。它通过队列和自旋提供了无锁的并发操作支持。我们熟悉的 CountDownLatch、ReentrantLock,甚至 ThreadPoolExecutor 中的 Worker,都是基于 AQS 实现的。
我使用 ChatGPT 对 AQS 的类注释进行了翻译和总结,并提取了一些我们主要需要关注的部分,从这里可以大概了解 AQS 是什么:
- AQS的设计目标:
- 提供阻塞锁和同步器的框架。
- 基于先进先出(FIFO)等待队列。
- 适用于使用单个整数值表示状态的同步器。
- AQS的模式和支持:
- 支持独占和共享模式,也可同时支持两者。
- 独占模式下只有一个线程可以获取锁。
- 共享模式下多个线程可以获取锁,但不一定必须。
- AQS的子类和扩展:
- 子类作为内部辅助类来实现同步属性。
- 不实现同步接口,提供方法供具体的锁和同步器调用。
简而言之,AQS 是一套基于队列实现的锁和同步器框架。它支持共享和独占模式,并且实现类通常作为某些锁或线程同步工具的内部类提供真正的功能。
在本篇文章中,我们将结合其经典的实现类 ReentrantLock 来分析 AQS 是如何支持独占锁的。
1.AQS 的数据结构
一如既往的,我们先从数据结构开始:
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
// 来自父类 AbstractOwnableSynchronizer,表示当前独占的线程
private transient Thread exclusiveOwnerThread;
// 等待队列的头尾节点
private transient volatile Node head;
private transient volatile Node tail;
// 同步状态 & 锁状态,从0开始,每加一次锁就+1
private volatile int state;
// 节点,对应了一个线程
static final class Node {
// 独占或者共享模式
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
// 节点的状态
static final int CANCELLED = 1; // 该线程已经取消
static final int SIGNAL = -1; // 该唤醒后继节点
static final int CONDITION = -2; // 线程正在等待
static final int PROPAGATE = -3; // 下一个acquireShared需要无条件传播,即唤醒所有后继节点
volatile int waitStatus; // 节点的等待状态,默认为 0
// 前驱和后继节点
volatile Node prev;
volatile Node next;
// 竞争线程
volatile Thread thread;
// 条件队列下一节点
Node nextWaiter;
}
public class ConditionObject implements Condition, java.io.Serializable {
private static final long serialVersionUID = 1173984872572414699L;
// 条件队列的头尾节点
private transient Node firstWaiter;
private transient Node lastWaiter;
}
}
在这里,我们姑且先忽略关于 Condition ——它是一套类似 await/notify ,不过更灵活线程调度工具——相关的内容。
1.1.锁状态
首先,让我们明确一点,在 AQS 中,所谓的“锁”实际上是指state变量。当我们说“加锁”时,实际上是尝试通过 CAS 操作将 state 加 1,而“解锁”则是将 state 减1。因此,AQS 天生支持可重入锁,因为 state 可以递增。
在 AQS 中,提供了以下方法用于读取和操作 state:
protected final int getState() {
return state;
}
// 直接设置 state,一般用于已经持有锁的情况下直接 + 1
protected final void setState(int newState) {
state = newState;
}
// 通过 CAS 设置变量,也就一般意义上的“加锁”
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
1.2.同步队列
观察 AQS 的成员变量,我们可以直观地看到,通过 head 和 tail,AQS 在内部使用 Node 内部类维护了一条双向链表。
当线程竞争锁时,也就是尝试修改 state 的时候,线程会被封装到相应的 Node 实例中。如果有多个线程同时竞争并且失败,那么这些线程对应的节点将连接成一条链表。这条链表上的节点代表着等待状态的线程,因此我们称之为等待队列。
等待队列中的节点使用 waitStatus 来表示自己的状态:
- 0:新节点入队时的默认状态。
- CANCELLED:1,表示当前结点已取消等待。当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。
- SIGNAL:-1,表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。
- CONDITION:-2,表示结点等待在
**Condition**上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。 - PROPAGATE:-3,共享模式下,前驱结点不仅会仅唤醒其后继结点,同时也可能会唤醒后继所有节点。
这里的 CONDITION 涉及到 Condition 类,它是一套类似但是比传统基于 synchronized 的 await/notify 更灵活的线程调度工具,我们可以暂且先忽略。
2.独占锁的加锁流程
AQS 的同步过程其实就是同步队列节点中依次获取锁的过程。AQS 一共提供了独占和非独占两种获取资源的方法:
acquire():以独占模式获取锁;release():以独占模式释放锁;acquireShared():以共享模式获取锁;releaseShared():以共享模式释放锁;
独占锁和非独占锁两者的加锁和解锁流程上来说都差不多,只在一些实现上有区别,这里我们先关注独占锁的加锁。
2.1.加锁流程
独占锁,顾名思义,即只有占有锁的线程才能操作资源,在 synchronize 底层的锁中,独占通过锁对象对象头中的指针来声明独占的线程,而在 AQS 中则通过父类 AbstractOwnableSynchronizer 提供的 exclusiveOwnerThread 变量来声明独占的线程:
private transient Thread exclusiveOwnerThread;
此外,AQS 并未提供其他具体实现。AQS 独占锁加锁的方法是 acquire(),其中涉及到 tryAcquire()方法是一个空实现,需要由子类实现并在在里面进行具体的独占判断:
public final void acquire(int arg) {
// 尝试获取锁
if (!tryAcquire(arg) &&
// 添加到等待队列并挂起,直到被头结点唤醒
// 该方法的返回值为当前线程唤醒后的中断状态,如果被唤醒后已经中断,就会调用 Thread.currentThread().interrupt()
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
static void selfInterrupt() {
Thread.currentThread().interrupt();
}
里面还涉及到 tryAcquire,addWaiter(),acquireQueued()和 selfInterrupt()四个方法。
2.2.尝试获取锁
在 AQS 中,tryAccquire() 是一个未实现的方法:
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
他需要由实现类去实现,并完成获取资源的功能。这里我们以 ReentrantLock 为例。
在 ReentrantLock 有一个内部类 Sync 继承了 AQS,提供基本的加锁解锁方法。而 Sync 又根据公平锁和非公平锁分别提供了两个子类:
NonfairSync:非公平锁,不管当前等待队列是否为空,都先尝试获取锁,获取不到再排队;FairSync:公平锁,如果当前等待队列为空,那么就直接排队;
两种主要逻辑基本一致,这里以公平锁 FairSync 的tryAccquire()方法为例:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
// 1、如果 state 为 0,即没人获取过锁
if (c == 0) {
if (!hasQueuedPredecessors() && // 如果当前等待队列中没有线程在等待(非公平锁没有这一步)
compareAndSetState(0, acquires)) { // cas 修改 state 成功
// 将当前锁设为自己独占
setExclusiveOwnerThread(current);
return true;
}
}
// 2、已经有线程获取锁了,判断一下是否是被自己获取的
else if (current == getExclusiveOwnerThread()) {
// state + 1,即多获取一次锁
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 3、已经有非当前线程获取锁了,因此尝试加锁直接失败
return false;
}
tryAcquire 突出一个 try,主要做了这么一件事:
- 如果没人加过锁,那么我直接立刻尝试修改
state加锁,成功就吧state改成 1,失败就去排队; - 如果当前锁已经被自己拿到了,那么直接
state + 1增加一次重入次数; - 如果当前锁被自己以外的线程拿到了,那么直接去排队;
2.3.进入等待队列
当 tryAcquire 失败以后,线程就需要进入等待队列排队了,这里对应了 acquire 方法中的 addWaiter(Node.EXCLUSIVE), arg) 这段代码。
我们先看看 addWaiter 方法:
private Node addWaiter(Node mode) {
// 以共享或者独占模式创建节点
Node node = new Node(Thread.currentThread(), mode);
// 尾插法插入节点
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 如果队列为空,则先初始化头结点和尾结点,再插入该节点
enq(node);
return node;
}
这里涉及到一个 enq()方法,这个方法用于自旋初始化 AQS 中的头结点和尾节点。
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
addWaiter 执行完毕后,当前线程就作为一个 Node 进入等待队列的末尾了。
另外,值得注意的是:**等待队列的头节点实际上是一个无意义的哨兵节点,它代表的是已经获得锁的执行线程,真正的等待队列,或者说第一个正在等待的线程其实是 ****head.next**。
直接引用源码中的注释就是:
+------+ prev +-------+ +------+
| head | <---- | first | <---- | tail |
+------+ +-------+ +------+
2.4.在等待队列中
acquireQueued 则是在调用完 addWaiter 后,在队列中真正的自旋,它用于完成三件事:
- 如果当前节点的前驱节点已经是头结点,这意味着自己实际上就是等待队列的最前端了,那么再次尝试让进入等待队列的线程去获取锁,成功就移除当前节点:
- 如果再次获取锁还是失败,那么就尝试挂起当前线程,直到必要的时候再唤醒;
- 当前线程执行完了(正常获取锁或者中断了),那么移除当前节点,并唤醒后续节点;
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 1、如果前驱节点已经是头节点了,就再尝试获取一次锁
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 2、如果再次获取锁失败,就尝试挂起当前线程,直到必要的时候再唤醒
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 1、将其从队列移除,同时一并移除队列中状态为取消的节点
// 2、唤醒当前节点的下一节点
if (failed)
cancelAcquire(node);
}
}
在挂起之前
在调用 parkAndCheckInterrupt 真正的挂起线程之前,还需要调用 shouldParkAfterFailedAcquire 做一些准备工作:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus; // 前驱节点的等待状态
// 前驱节点正在等待中,说明当前节点也正常挂起即可
if (ws == Node.SIGNAL)
return true;
if (ws > 0) {
// 前驱节点已经被取消,那么向前一直找到一个状态正常的节点,然后把自己变成它的后继节点
// 即抛弃中间所有状态为取消的节点
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 前驱节点可能为 0,-2,-3,我们直接将节点状态设置为 SIGNAL
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
这里主要做两件事:
- 清除当前队列中的无效节点;
- 把前驱节点的状态设置为
SIGNAL,表明它释放锁后需要唤醒自己;
挂起当前线程
当我们使用 shouldParkAfterFailedAcquire 检查并确保当前节点的前驱节点是个正常节点后,就可以通过 parkAndCheckInterrupt 真正的挂起当前线程了:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
非常简单,就是直接调用 LockSupport.park 方法,他的效果相当于直接让线程 await,只不过不需要依赖监视器,至此,线程被挂起,直到唤醒前它不会继续执行后续方法了。
2.5.若加锁失败,取消节点
在上述过程中,若加锁失败,则需要将该节点从等待队列中取消并移除:
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
if (node == null)
return;
node.thread = null;
// Skip cancelled predecessors
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// predNext is the apparent node to unsplice. CASes below will
// fail if not, in which case, we lost race vs another cancel
// or signal, so no further action is necessary.
Node predNext = pred.next;
// Can use unconditional write instead of CAS here.
// After this atomic step, other Nodes can skip past us.
// Before, we are free of interference from other threads.
node.waitStatus = Node.CANCELLED;
// If we are the tail, remove ourselves.
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
这一段的逻辑大体是将异常节点通过修改前驱节点的 next 指针的方式,将目标节点从队列中摘除,我们会注意到,在此处由于修改了前驱节点和当前节点的 next 指针,因此若同时有线程需要唤醒下一可用节点时,就无法安全的直接通过 next 唤醒后继节点了。
3.独占锁的解锁流程
和 AQS 使用 acquire() 方法加锁的过程类似,AQS 也有一个 release()的解锁方法,他们同样需要实现类自己去实现 tryRelease()方法。
public final boolean release(int arg) {
// 尝试释放锁
if (tryRelease(arg)) {
Node h = head;
// 有头节点,且当前节点状态正常,那么处理掉头节点,然后唤醒后继节点
// 换而言之,等待队列的 first 出队
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
3.1.释放锁
和 tryAcquire()一样,AQS 不提供 tryRelease()的具体实现,而是交由子类去实现它。
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
我们依然以可重入锁 ReentrantLock 为例,这是其内部类 FairSync 公平锁的解锁流程:
protected final boolean tryRelease(int releases) {
// 可重入锁,减去一次持锁次数
int c = getState() - releases;
// 如果当前线程不是持有锁的线程则抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 如果可重入次数为0,说明确实释放锁了
if (c == 0) {
free = true;
// 独占线程设置为null
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
这个地方也很好理解,就是让 tryRelease()去执行释放锁的过程,换句话说,就是改变 state。
3.2.唤醒等待队列的后继节点
unparkSuccessor()方法的主要用途是
- 在前驱节点(其实就是等待队列的头结点)释放锁后,去唤醒等待队列中的后继节点;
- 如果后继节点处于
CANCELLED状态,说明该节点已经挂掉了,就从尾节点向前找到离最近的正常的等待节点去唤醒,否则直接唤醒后继节点。
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
// 如果头节点状态还处于等待状态,则改回初始状态
compareAndSetWaitStatus(node, ws, 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);
}
这里有一个细节,当唤醒时,先尝试直接唤醒 head.next,不行再从后队尾向前查找一个可用的节点用于唤醒,这里的做法与前文 cancelAcquire 有关:
由于取消异常节点时,会破坏 next 的引用,因此寻找可用节点时不能从头向后找,而是需要反过来。
3.3.更新头结点
注意,当我们在头结点使用 LockSupport.unpark 唤醒了后继节点后,我们将会回到 acquireQueued 方法,把因为被 LockSupport.park 挂起而没有继续执行完的方法继续执行下去,也就是:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 2、再次进入循环后,如果没人插队,那么唤醒的这个节点将成为新的头节点,等待它解锁
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 1、唤醒后如果线程不中断,那么将进入下一次循环
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 3、如果上述循环抛出异常,那么将进入 cancelAcquire 方法
if (failed)
cancelAcquire(node);
}
}
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 在这一步阻塞,直到被头结点唤醒后判断一下当前线程是否中断
return Thread.interrupted();
}
异常处理
如果上述循环中抛出了异常(比如 tryAcquire),那么说明当前节点状态异常,我们需要通过 cancelAcquire 方法移除当前节点,并唤醒下一节点,当然,在“寻找下一节点”这一步依然要移除并跳过那些状态已经为取消的节点:
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
if (node == null)
return;
// 移除节点的线程引用
node.thread = null;
// 清空无效节点,向前找到第一个有效节点
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// 找到该有效节点的后继节点
Node predNext = pred.next;
// 将当期节点设置为取消
node.waitStatus = Node.CANCELLED;
// 如果当前节点已经是尾节点了,那么直接把该后继节点设置为尾节点
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
// If successor needs signal, try to set pred's next-link
// so it will get one. Otherwise wake it up to propagate.
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
compareAndSetNext(pred, predNext, next);
} else {
// 去唤醒下一节点
unparkSuccessor(node);
}
node.next = node; // help GC
}
}
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
// 即从后往前找,找到一个离当前节点最近的正常节点
// 比如 a -> b -> c -> d -> e,b 挂了,那么将会找到 c 进行唤醒
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);
}
总结
数据结构
- 锁状态:AQS 内部维护一个
int类型的变量state,在独占模式下,它表示当前独占线程的锁状态,默认为0。当线程成功加锁一次(实际上是CAS操作成功)后,状态值加 1;解锁一次后,状态值减 1。 - 独占线程:AQS 的父类
AbstractOwnableSynchronizer中维护了一个Thread类型的变量exclusiveOwnerThread。在独占模式下,AQS 会将当前获取锁的线程引用赋值给它。后续加锁操作会根据该变量判断当前线程是否为加锁线程,从而决定是否允许重入。 - 等待队列:AQS 通过
head和tail指针,在内部使用Node内部类作为节点,维护了一条双向链表。当竞争锁的线程失败后,它会被封装为一个Node节点并加入等待队列中等待获取锁。因此,这条双向链表也被称为等待队列。 等待队列的头结点,也就是head指针指向的节点,对应的线程就是当前获取锁的线程。因此,等待队列中真正首个处于等待状态的节点实际上是头结点的后继节点,即从头结点开始的第二个节点。
加锁流程
- 公平和非公平锁:当线程尝试通过
acquire方法获取锁时,对于公平锁,它会先检查队列是否为空,如果队列不为空,则直接进入等待队列;而对于非公平锁,无论队列是否为空,它都会先尝试获取一次锁,如果失败,再进入等待队列; - 尝试加锁:如果当前独占线程已经是当前线程自身,则直接对
state加1进行重入操作。否则,通过 CAS 操作尝试将state修改为1进行加锁。如果成功获取锁,则会将exclusiveOwnerThread设置为当前线程,否则,线程将进入等待队列等待获取锁。 - 进入等待队列:在 AQS 初始化后,初始时的头结点实际上是空的。直到首个线程获取锁时,在
addWaiter方法中,该线程会被封装为一个Node节点,然后通过enq方法加入到队列中。如果此时头结点为空,才会创建一个空的哨兵节点,并将当前节点设置为它的后继节点; - 在等待队列中:当节点进入等待队列后,在
acquireQueued方法中会持续尝试以下步骤:- 如果前驱节点已经是头结点,则尝试调用
tryAcquire方法来获取锁; - 如果
tryAcquire方法获取锁失败,说明还未到可以竞争锁的时机,则调用shouldParkAfterFailedAcquire方法清除当前节点之前的无效节点,并将前驱节点的状态设置为SIGNAL,表示在前驱节点释放锁后需要唤醒自己; - 处理完前驱节点后,调用
parkAndCheckInterrupt方法,使用LockSupport.park将自己挂起,等待前驱节点释放锁后唤醒自己;
- 如果前驱节点已经是头结点,则尝试调用
解锁流程
- 当调用
release方法后,AQS 将会调用实现类的tryRelease方法进行解锁,并让state减1,如果减后state变为0,则说明解锁成功; - 若解锁成功,将会将
exclusiveOwnerThread设置为null,并调用unparkSuccessor方法唤醒下一个正常的节点,如果该节点已经取消,则从队列尾部向前查找一个离当前节点最近的那个节点进行唤醒; - 节点被唤醒后,如果对应的线程没有被中断,那么将继续执行
acquireQueued方法,如果此时成功获取到锁,那就把自己设置为新的头节点,然后等待解锁后再重复上述全部步骤; - 如果唤醒后被线程已经中断,那么就尝试中断线程,并继续唤醒后续正常的节点;
引用
等待队列是"CLH"(Craig、Landin 和 Hagersten)锁队列的一种变体。CLH 锁通常用于自旋锁。我们将其用于阻塞同步器,通过包含显式的("prev" 和 "next")链接以及一个 "status" 字段,使节点能够在释放锁时向后继节点发出信号,并处理由于中断和超时而导致的取消。状态字段包含一些位用于跟踪线程是否需要一个信号(使用 LockSupport.unpark)。尽管有这些添加,我们仍保持大多数 CLH 局部性属性。
要将节点加入到 CLH 锁队列中,您需要原子地将其作为新的尾节点进行接合。要出队,您需要设置头节点,使得下一个合适的等待者成为第一个。
在 CLH 队列中插入节点仅需要对"tail"进行一次原子操作,因此从未排队到已排队有一个简单的界限。前驱节点的"next"链接由入队线程在成功的 CAS(比较并交换)之后设置。尽管非原子性,这足以确保任何阻塞的线程在合适时通过前驱节点得到信号(尽管在取消的情况下,可能需要在 cleanQueue 方法中使用信号的辅助)。信号的实现部分基于类似 Dekker 方案,其中待等待的线程首先指示为 WAITING 状态,然后重试获取,并在阻塞之前重新检查状态。信号发出者在解除阻塞时原子性地清除 WAITING 状态。
一个节点在等待期间,其前驱节点可能由于取消而发生变化,直到该节点成为队列中的第一个节点,此后便不再改变。在等待之前,获取方法会重新检查 "prev" 字段。被取消的节点通过 CAS 在 cleanQueue 方法中修改 "prev" 和 "next" 字段。去除节点的策略类似于 Michael-Scott 队列,即在成功的 CAS 操作将其设置为前驱节点之后,其他线程会协助修复后继节点的 "next" 字段。由于取消通常以批量方式发生,并且会对必要的信号产生复杂影响,每次调用 cleanQueue 方法时都会遍历整个队列,直到清理完成。成为第一个节点的重新连接节点将无条件地被唤醒(有时可能是不必要的,但这些情况不值得避免)。
如果线程处于队列中的第一个位置(最前面),或者在它之前,它可以尝试获取锁,但成为第一个并不能保证成功;它只有争夺锁的权利。为了平衡吞吐量、开销和公平性,我们允许正在入队的线程“插队”并在入队过程中获取同步器,这种情况下,被唤醒的第一个线程可能需要重新等待。为了避免可能的重复不幸等待,每次线程解除阻塞时,我们会指数级增加重试次数(最多 256 次)以获取锁。除了这种情况外,AQS 锁不会自旋,而是将获取尝试与相关的记录步骤交替进行。(如果用户需要自旋锁,可以使用 tryAcquire 方法。)
为了提高垃圾回收的效率,尚未加入列表的节点的字段值为 null(创建并且未使用的节点在之后会被丢弃的情况并不少见)。当节点从列表中移除时,尽快将其字段值设置为 null。这增加了在外部确定第一个等待线程的难度(例如在 getFirstQueuedThread 方法中)。有时,需要通过从原子更新的 "tail" 位置向后遍历来解决这个问题(尽管在发出信号的过程中永远不会需要这样做)。
CLH 队列在启动时需要一个虚拟的头节点。但是在构造时我们并不创建它们,因为如果没有争用,这将是一种浪费。相反,当第一次出现争用时,节点会被构造并设置头节点和尾节点的指针。
共享模式的操作与独占模式不同,因为在共享模式下,如果下一个等待者也是共享模式,那么获取操作会向其发出信号以尝试获取锁。tryAcquireShared API 允许用户指定传播程度,但在大多数应用中,忽略传播程度更高效,允许后继节点在任何情况下都尝试获取锁。
在条件等待的线程使用带有额外链接的节点来维护(FIFO)条件列表。条件只需要在简单的(非并发的)链式队列中链接节点,因为只有在独占持有时才会访问它们。在等待期间,节点被插入到条件队列中。在收到信号时,节点被加入到主队列中。使用特殊的状态字段值来跟踪并原子地触发此过程。
对于字段 head、tail 和 state 的访问,以及 CAS 操作,我们使用完全的 Volatile 模式。节点字段 status、prev 和 next 在线程可以接收信号的情况下也使用完全的 Volatile 模式,但在其他情况下可能使用较弱的模式。对于字段 "waiter"(即将接收信号的线程),访问总是夹在其他原子访问之间,因此使用 Plain 模式。我们使用 jdk.internal Unsafe 版本的原子访问方法,而不是 VarHandles,以避免潜在的虚拟机引导问题。
上述大部分工作由主要的内部方法 acquire 执行,所有导出的 acquire 方法都以某种方式调用该方法。(在频繁使用时,编译器通常可以优化调用点的特殊化处理。)
在 acquire 和 await 方法中,关于何时以及如何检查中断存在一些任意决策,包括阻塞之前和/或之后。在实现更新中,这些决策并非是任意的,因为一些用户似乎以一种可能存在竞态条件的方式依赖于原始行为,因此(很少情况下)一般而言是错误的,但很难证明更改是合理的。
+------+ prev +-------+ +------+
| head | <---- | first | <---- | tail |
+------+ +-------+ +------+