前情回顾
JUC(
java.util.concurrent)中提供了大量的并发工具类,其中大部分的同步器(例如锁,屏障等等)都依赖于一个公共基础组件——AQS(AbstractQueuedSynchronizer)。简单来说,AQS完成了锁的主要工作(比如线程排队、阻塞/解除阻塞、管理同步状态),并对外提供了大量扩展功能,好比是锁的一个基础核心组件,后续同步器的具体实现类只需继承AQS类便可省略大量工作。可以说AQS就是JUC中锁的基石, 掌握AQS的设计原理对后续JUC的学习至关重要。
上集主要描述了无锁队列的实现与发展,本文则主要描述AQS的具体实现细节,如有错误,恳请指正。
AQS简述
AQS(AbstractQueuedSynchronizer)框架的关键就是如何管理阻塞线程的队列和同步状态,其设计可见 Doug Lea 的论文《The java.util.concurrent Synchronizer Framework》。AQS维护一个双向链表,由head和tail代表头尾节点,state代表可用资源数。线程尝试加锁失败后,会将信息包成一个Node节点加到队列。节点的抢占模式分为两种:独占和共享。AQS主要负责加锁解锁过程中的队列管理部分,实际加锁细节由子类实现。结构如图所示:
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer {
private transient volatile Node head;
private transient volatile Node tail;
private volatile int state; // 资源计数
}
static final class Node {
static final Node SHARED = new Node();
static final Node EXCLUSIVE = null;
// 节点状态有4种
static final int CANCELLED = 1; // 加锁失败,且超时,需要移出队列
static final int SIGNAL = -1; // 后继节点的线程需要被唤醒
static final int CONDITION = -2; // 节点位于条件队列,而非同步队列
static final int PROPAGATE = -3; // 仅用于head节点,表示在共享模式下需要往后传递唤醒
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
}
AQS作为CLH队列的变种,由于严格遵循了FIFO,所以相较于CLH本体缺少了优先级队列功能。但总体来说功能还是更加丰富,如下:
| 方法 | 描述 |
|---|---|
| acquire | 加锁,支持响应中断 |
| acquireInterruptibly | 加锁,支持响应中断 |
| tryAcquireNanos | 加锁,支持超时放弃 |
| acquireShared | 加锁,支持共享模式 |
| acquireSharedInterruptibly | 加锁,支持共享模式 + 响应中断 |
| tryAcquireSharedNanos | 加锁,支持共享模式 + 超时放弃 |
| release | 解锁,支持唤醒 |
| releaseShared | 解锁,支持共享模式 |
AQS将上述功能结合,并使用模版方法模式提供给子类。子类只需定义状态更新相关的方法。
问题:并没有支持双向链表节点的cas原子指令,若要用双向链表实现FIFO必定有next为空的不一致情况,代码中需要做额外判断,那为什么还要用双向链表呢?
The next link is treated only as an optimized path. If a node's successor does not appear to exist (or appears to be cancelled) via its next field, it is always possible to start at the tail of the list and traverse backwards using the pred field to accurately check if there really is one.
论文中提到,next链接并不是必须的,算是一种优化 (比如唤醒和删除节点时不用从tail一直往前找),即使真的出现next==null这种看似没有后继节点的情况,也能从tail往前遍历来检查是否真的有后继节点
AQS源码分析
加锁-支持挂起/唤醒
自旋尝试加锁是最基础的功能,AQS加入了挂起机制,流程如下:
-
尝试直接加锁,若成功则结束
-
往队列尾增加等待节点,进行自旋
-
如果前驱是
head,说明有可能资源已经释放,此时尝试获取资源- 获取成功,则当前节点成为新的
head - 获取失败,则根据前驱节点的
waitStatus决定是否要挂起
- 获取成功,则当前节点成为新的
-
被唤醒后继续上一步
-
问题
-
为什么需要先加锁
tryAcquire()而不是直接入队列,这样做是否破坏公平tryAcquire()由子类实现,子类若想保证公平,可自行判断队列是否有等待中的节点- 若资源有剩余,直接尝试可省去多余的入队列操作,这种可闯入的策略通常有更好的性能
-
子类如何判断队列有等待节点?
AQS提供
hasQueuedPredecessors(),用于判断是否有其他线程更早进入队列等待获取锁。它主要用于实现公平锁的机制,避免线程“插队”。其逻辑为head != tail && (head.next == null || head.next.thread != Thread.currentThread())head == tail说明队列为空,当前线程可以直接尝试获取锁。head.next == null则一定是并发操作导致的临时状态(上一步已经确定了有节点),此时有其他线程正在竞争入队,当前线程需要等待head.next.thread != Thread.currentThread()说明后继节点关联的线程不是当前线程,说明有其他线程更早进入队列
-
初始队列为空时,为什么需要插入空节点?
首先明确一点,空节点对于无锁队列并不是必须的,可以通过其他方式实现相同的功能,但使用空节点有独特的优势。插入空节点后,队列始终有一个“哨兵节点”,各种操作(如加锁、入队列,出队列)都可以统一处理,无需特殊判断逻辑
加锁-支持超时放弃
引入超时放弃功能后流程如下:
-
当前等待节点加入队列尾
-
若前节点是
head,说明前节点正在临界区或已经完成,当前线程可以尝试cas加锁 -
若前节点非
head,当前线程暂时不能加锁- 若已经超时,则本次加锁失败,废弃当前节点
- 跳过已取消的前驱节点
- 标记当前节点为已取消
- 当前节点是尾节点
- 尝试
cas将tail指向前驱节点prev cas将前驱节点的后继指针置空。
- 尝试
- 当前节点不是尾节点
- 检查前驱节点是否需要唤醒后继节点(
SIGNAL状态)。 - 如果前驱节点需要唤醒,尝试将前驱节点的后继指针指向当前节点的后继节点(
next)。 - 否则,唤醒后继节点
- 检查前驱节点是否需要唤醒后继节点(
- 若还未超时,需要考虑挂起一段时间
- 剩余可等待时间<1μs,继续重复for循环
- 剩余可等待时间≥1μs,先将线程挂起一段时间,直到即将超时时唤醒,并再尝试一次加锁
- 若已经超时,则本次加锁失败,废弃当前节点
问题
-
为什么剩余时间<1μs不用挂起线程?
极短时间的挂起和恢复的成本开销可能更大,自旋的代价反而更低
-
移除节点时,将前驱节点的
next指针置空时为什么需要用cas?考虑这样一种场景:
- 线程A将
tail从curr指向prev - 线程B在
prev.next = null执行前,插入一个新节点到prev之后 - 线程A的
prev.next = null会覆盖线程B的操作,导致新节点丢失。
- 线程A将
加锁-共享模式
共享模式就是指资源数量不止1个,队列中多个线程可以同时拿到资源。实现的思路:
- 首先需要增加
SHARED节点类型 - A线程获得资源后,检查后继节点B是否为
SHARED节点,是则唤醒后继节点B。 - 如果有线程释放了资源,则由其负责从
head唤醒后继节点
问题: 为什么需要Node.PROPAGATE状态。Node中已经有SIGNAL表示需要唤醒下一个线程,PROPAGATE什么时候需要用到?
static final class Node {
/** waitStatus value to indicate thread has cancelled */
static final int CANCELLED = 1;
/** waitStatus value to indicate successor's thread needs unparking */
static final int SIGNAL = -1;
/** waitStatus value to indicate thread is waiting on condition */
static final int CONDITION = -2;
/** waitStatus value to indicate the next acquireShared should unconditionally propagate */
static final int PROPAGATE = -3;
}
到JDK里看了下,发现PROPAGATE初次引入是在
6801020: Concurrent Semaphore release may cause some require thread not signale
改动原因是:
When AbstractQueuedSynchronizer#release are called, head.waitStatus may be 0 because the previous acquire thread may run at AbstractQueuedSynchronizer#doAcquireShared before setHeadAndPropagate is called
这个bug会导致:资源已足够,但线程却被挂起无法获得
先梳理下改动前的代码:
- 结合原因我们先找到
release(),h.waitStatus == 0会导致无法唤醒head后继节点。 - 什么情况会导致
h.waitStatus == 0?比如当head获得资源发现后继是shared节点会尝试唤醒后继节点(doAcquireShared() -> setHeadAndPropagate() -> unparkSuccessor()),并将head.waitStatus置0 - 新节点获取资源失败,会先判断前面节点状态,如果
waitStatus==0会尝试cas将其置为SIGNAL,无论成功与否都会重新进入for循并尝试获取资源,由于第一步中已经释放了1个资源,新节点按理说是能获取资源的。
// 1.线程释放资源
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
// 2.某线程成为head,唤醒后继节点
private void setHeadAndPropagate(Node node, int propagate) {
setHead(node);
if (propagate > 0 && node.waitStatus != 0) {
Node s = node.next;
if (s == null || s.isShared())
unparkSuccessor(node); // 唤醒后继节点,其中会执行compareAndSetWaitStatus(node, Node.SIGNAL, 0);
}
}
// 3.新节点获取资源失败,考虑是否要阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int s = pred.waitStatus;
if (s < 0) // ...
if (s > 0) // ...
else compareAndSetWaitStatus(pred, 0, Node.SIGNAL); // 新节点将前节点waitStatus置为SIGNAL
return false;
}
正常流程下不会有问题,应该是在极端的场景才会触发这个bug。再结合这次commit中的RacingReleases.java文件,大致逻辑是创建线程b1,b2执行semaphore.acquire(),线程s1,s2执行semaphore.release(),乱序执行,60s后还有存活的线程则视为触发了bug,循环尝试1000次。
我们可以假设这样一种情况,执行顺序为: b1 -> b2 -> s1 -> s2,队列中现有b1,b2排队
- s1释放资源,
status+1,需要唤醒head的后继,将head.waitStatus置为0 - b1执行
doAcquireShared()获得资源,status-1,但未执行到setHeadAndPropagate(),暂时没成为head - s2释放资源,
status+1,发现head.waitStatus=0,不进行唤醒操作 - b1进入
setHeadAndPropagate(),由于剩余资源<=0,不再往后唤醒
所以即使资源status已经足够,b2线程也处于挂起状态无法获取。那么引入的新状态PROPAGATE是如何修复这种问题的?修改后的代码中可以看到releaseShared()走进了新方法doReleaseShared(),s1线程释放资源后,会通过for循环将waitStatus==0修正为PROPAGATE。即使被唤醒的线程跑的比较快,又切换了head,s1还会继续循环直到不再有线程切换head为止
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) {
int ws = h.waitStatus;
if (ws == Node.SIGNAL) {
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
if (h == head)
break;
}
}
解锁
上面分析了加锁的实现,而解锁作为最主要的基本功能,主要分为两步:
-
cas尝试释放资源 -
成功后根据
head节点的waitStaus决定是否进行唤醒操作- 清除
head节点的waitStatus。通过cas将其重置为 0。表示该节点已完成唤醒责任,避免重复唤醒,若cas失败表示其他线程可能已修改状态 - 查找有效的后继节点
next==null可能是后继节点尚未被正确链接(并发插入时的临时状态),需要往后找next!=null也需要判断后继节点是否已取消waitStatus=cancel,若已取消,继续往后找- 从
tail往前遍历找到当前节点后面最靠前的有效节点
- 如果找到有效的后继节点,唤醒其线程。
- 被唤醒的线程会从
park状态恢复,继续执行acquireQueued中的逻辑。非阻塞通知,让线程继续竞争锁。
- 被唤醒的线程会从
- 清除
问题:
-
什么情况下头节点的
waitStatus会是 0?- 新创建的节点,未被后继节点设置唤醒职责
- 已经开始进行或已完成唤醒动作
-
为什么需要判断
head.waitStatus != 0?h.waitStatus != 0其实代表是否需要进行唤醒,如果去掉该判断:即使没有线程需要唤醒也会调用unparkSuccessor,导致不必要的性能开销 -
为什么从后向前遍历?
新节点入队列,
cas只能保证prev的一致性,在设置前驱节点的next指针前可能会有其他并发操作,从后向前遍历可以避免因并发插入导致的误判。 -
遍历查出后面待唤醒的节点时,如果恰好后继节点都是废弃节点,又恰好加入了新节点,新节点在非公平情况下被队列外线程抢走资源导致加锁失败,新节点会被卡住无法唤醒吗?
新节点加锁失败,会判断前驱节点的状态决定是否挂起线程。前驱节点在找后继节点唤醒前已经
casWaitStatus(curr, waitStatus, 0)改了状态,新节点不会被挂起,而是会将前驱节点的状态改为SIGNAL并进入下一次for循环尝试加锁
总结
理解AQS的主干逻辑相对简单,但多线程真正的复杂性源于乱序执行引发的混乱竞争场景。代码中各处细节的处理可能都对应着特定并发问题的精妙对策,真正吃透这些设计细节需付出时间,深入源码和并发场景分析,否则易陷入“知其然不知其所以然”的困境。