从源码角度带你理解AQS
作者编写本文的方式,同时建议给您一种阅读方式:
- 本文所有的代码都是源码,可在JDK8中找到,讲解的代码无删减都在本文;
- 本文的所有代码块及其中的属性和方法,均采用从上到下顺序讲解;
- 本文代码中文注释才是重点,注释顺序也是按照从上到下阅读;
- 如有错误和建议、提问等,都可以与我联系,互相学习哦!
1. 下面这三个字段是AQS这个类自己的属性定义,我们先理解一下,AQS想要定义一个什么样的东西:
/**
* Head of the wait queue, lazily initialized. Except for
* initialization, it is modified only via method setHead. Note:
* If head exists, its waitStatus is guaranteed not to be
* CANCELLED.
*/
private transient volatile Node head;
/**
* Tail of the wait queue, lazily initialized. Modified only via
* method enq to add new wait node.
*/
private transient volatile Node tail;
/**
* The synchronization state.
*/
private volatile int state;
2. 首先,先大致理解下这些属性的意义:
- head:一个头节点;
- tail:一个尾节点。咦?!这不是LinkedList的双向链表结构嘛,其实人家真正的名字叫CLH同步队列;
- state:那这个就是链表全局的同步状态的标识喽,先透露一下,这个值可以被用来做重入锁的次数。
private static final long stateOffset; // CAS字段偏移量,对应的是 AQS中 state属性操作
// 全局唯一方法,外部类只能通过这个方法来操纵 state属性
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
请看如下代码步骤和详细注释,一个完整的加锁入口 acquire()方法(形如:Lock.lock()):
// 获取锁的开始方法,参数是一个状态次数(你也可以叫重入次数)
public final void acquire(int arg) {
if (!tryAcquire(arg) && // 先尝试获取一次锁,获取不到就是False,获取到那直接就获取了锁,就不用操作其他的了
// 当尝试一次后获取不到,addWaiter方法就开始加入CLH队列了(Node.EXCLUSIVE为独占锁模式,可先忽略)
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // acquireQueued方法在下个小节中讲解
selfInterrupt();
}
/**
* 把一个新节点(抢占线程)插入链表尾部
*(整体操作和双向链表一直,唯一区别是链表节点中的前驱和后驱节点的修改需要加 CAS锁)
*
* @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared
* @return the new node
*/
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// 先尝试一次将新节点快速插入链表尾部,如果CAS失败,则进入接下来的enq方法。
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 循环将新节点插入链表尾部,直到插入成功(其实与上面先尝试一次大致相同,唯一区别是加入了链表为空的处理,一切为了性能,哈哈哈)
enq(node);
return node;
}
把上面代码的注释看完后,我们先暂停一下,此时我们已经或多或少的知道AQS的整体是一个同步队列了,也大致可以想象一个CLH同步队列的样子了,也知道怎样将一个线程(节点)放入CLH队列中了,唯一的模糊在于同步状态 state有什么用,在哪里用?
如果只局限于AQS这个抽象类来说,其并没有定义重入的概念,但是支持重入锁的扩展设计的。
它只抽象于CLH的同步队列的处理,而重入的概念是交由像ReentrantLock、Sync等高层次类去实现的。
从抽象层面来说,它提供了status字段来让你自定义标识,同时还提供了像tryAcquire、tryRelease的扩展方法来让你实现自己的效果。
【自行脑补插入一个CLH同步队列图,其实就是一个双向链表】
3. 接下来我们先全局的看下,加入队列的节点(线程)接下来要做哪些事情:
/**
* 这个方法的目的,就是教你怎样去排队等待。
* 例:
* 就像排队买早点一样,只有一个窗口。某一天,你来的早不如来的巧,正好前面一小美女刚买完早点,你就直接问老板要了个豆腐脑,老板直接给你了(头节点一次尝试)。
* 第二天,我的妈,这么老长的队,你前面有人上班等不及了提前跑了(线程中止前移),那你就能往前走走。
* 这个早点铺呢,还有个奇葩又人人遵守的规矩,买完饭的人要向后面那个人打个招呼?!(线程等待释放通知)
*
* @param node the node
* @param arg the acquire argument
* @return {@code true} if interrupted while waiting
*/
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true; // 当前节点失败标识
try {
boolean interrupted = false; // 当前节点中断标识
for (;;) {
final Node p = node.predecessor(); // 获取前驱节点
// 如果前驱节点是头节点(或者说你马上就轮到你运行了,你在链表第二位),
// 同时再尝试一次获取锁操作(说不定头节点已经释放锁了,我直接就获取到了呢)
// 其中一个不满足则跳过(不是头节点或没获取到锁)
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 设置一个合适的链表节点位置(也可以称为合适时机)让其线程等待,(在此期间,可能会有前驱节点中断或者已运行完成等情况,所以要设置一个合理位置)
if (shouldParkAfterFailedAcquire(p, node) &&
// 如果找到合适时机(true),则触发逻辑表达式 parkAndCheckInterrupt方法调用让线程等待
// 此时,线程在此进入wait的状态,等待其他线程唤醒或者被中止,这是重点,后续让你了解如何解锁的
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node); // 执行失败移除,或通知后续节点继续,第 5 小节会细说
}
}
/**
* 设置一个合适的链表节点位置(也可以称为合适时机)让其线程等待,(在此期间,可能会有前驱节点中断或者已运行完成等情况,所以要设置一个合理位置)
*
* @param pred node's predecessor holding status
* @param node the node
* @return {@code true} if thread should block
*/
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
* 前驱节点的 waitStatus = SIGNAL状态表示:后继节点需要被通知释放(后驱节点这时候在一直阻塞等待)
*/
return true;
if (ws > 0) { // waitStatus 状态大于0,代表的是线程已经取消或终止了
/*
* 我们找到前驱节点没有被取消或终止的那一个。就像早点铺来不及排队的人走掉一样,你要接上这个队伍,然后重试获取一个等待时机
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* 大致意思就是,告知前驱节点,你执行完后,我需要被通知释放,然后重新获取一次等待时机
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
/**
* 执行线程阻塞等待,并在唤醒后检查线程是否中止
*
* @return {@code true} if interrupted
*/
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
看到这里,我们已了解到加入CLH队列的节点(线程)会自己做一些排队的处理,我们也该再整理一下Node节点的属性和作用了,这样我们再反过来回顾一下,会更加清晰!
4. Node节点的属性和意义:
- waitStatus:我们在上文中看到了大量的该状态,主要代表节点的等待状态表示方式,具体看注释;
- prev:关联前驱节点;
- next:关联后驱节点;
- thread:节点绑定的工作线程;
- nextWaiter:锁的模式,我们在起初的获取锁的入口方法里,有一个Node.EXCLUSIVE,代表是独占模式。
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
// 下面这些字段,代表着 waitStatus的一些状态值
/** 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 ,使用Condition方式形成的等待,可先忽略,后续在讲*/
static final int CONDITION = -2;
/**
* waitStatus value to indicate the next acquireShared should unconditionally propagate ,共享锁下的无条件传播模式,后续在讲
*/
static final int PROPAGATE = -3;
5. 节点(线程)中断或者失败的释放
在第 3 小节中,我们已经成功的线程持有锁或者加入CLH的等待队列中,但是还没完。
我们知道,线程会有被终止的风险,或者加锁失败,那后续这个节点还在队列中,那怎么办呢?
请看下面详细源码及注释:
/**
* 取消获取锁操作
*
* @param node the node
*/
private void cancelAcquire(Node node) {
// Ignore if node doesn't exist
if (node == null)
return;
node.thread = null; // 先将持有线程设置为 null
// 跳过所有被终止或失败的前驱节点,去找正常的前驱节点
Node pred = node.prev;
while (pred.waitStatus > 0)
node.prev = pred = pred.prev;
// 获取前驱节点的后驱节点,理论上此时在高并发情况下,该next可能已经不是node
Node predNext = pred.next;
// 这个设置不需要CAS,因为在别的节点检查时,会跳过该节点
node.waitStatus = Node.CANCELLED;
// 如果当前节点为CLH尾节点,并且可以原子性的修改尾节点,则直接完成取消操作(此时,没有人排在你后面)
if (node == tail && compareAndSetTail(node, pred)) {
compareAndSetNext(pred, predNext, null);
} else {
// 如果有人还在你后面排队,那你就要通知他释放或者前移
// 当你前面排队的人很多(当前节点不是头节点后面那个),则尝试告知前面那个人说:你一会去通知我后面那个人,我等不及买早点了撤了(前提是,你后面有人且他打算继续排队)。
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
}
}
/**
* 唤醒后驱节点
*
* @param node the node
*/
private void unparkSuccessor(Node node) {
/*
* 这种情况时正常解锁判断的,我们后续在解锁中会讲,目前已取消是必然的 waitStatus = CANCELLED > 0
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
/*
* 我们开始找后驱节点,如果后驱节点不存在或者后驱节点也处于终止状态,
* 则干脆直接从CLH尾部开始向前找合适的后驱节点,找到之后,通知其释放等待
*/
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在某种情况下,会有概率出现静默问题,但这应该是极小的情况,可以忽略。静默问题指中间链表中断,此时前驱无任务,后驱需要重新从tail找到可用节点所需时间内静默的状态,我称其为静默状态。
6. 利用AQS如何完成解锁的操作
解锁就简单很多了,比较在加锁中也有解锁的身影了?哪里哦,你个赖皮??答案就是节点终止或失败。
请看如下代码步骤和详细注释,一个完整的解锁入口 release()方法(形如:Lock.unlock()):
/**
* 释放节点(线程),tryRelease()是抽象方法,交由上层具体类实现,其意思是尝试一次解锁
*
* @param arg the release argument. This value is conveyed to
* {@link #tryRelease} but is otherwise uninterpreted and
* can represent anything you like.
* @return the value returned from {@link #tryRelease}
*/
public final boolean release(int arg) {
if (tryRelease(arg)) { // 尝试一次解锁
Node h = head;
if (h != null && h.waitStatus != 0) // 当头节点不为空且包含有信号通知时,则出发释放通知(向后驱节点发送)
unparkSuccessor(h); // 这个就是第五小节基本一致了(终止操作)
return true;
}
return false;
}
到此,整个一套加锁、解锁的流程就走通了,但上面简述的只是独占锁的方式。
7. 共享锁的了解
本章节节中,如果与独占锁相同的逻辑地方不再讲述,只会一笔带过。对于共享锁,原理大致和独占相似,
最大的不同就是共享锁中 setHeadAndPropagate方法,设置头节点,然后通知后驱共享节点释放。
// 共享方式获取,入口
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0) // 尝试一次获取锁,继承AQS实现
doAcquireShared(arg); // 循环尝试获取共享锁
}
/**
* 共享模式下,和独占锁模式先逻辑大致相同:把一个新节点(抢占线程)插入链表尾部
* @param arg the acquire argument
*/
private void doAcquireShared(int arg) {
final Node node = addWaiter(Node.SHARED); // 在独占锁中 addWaiter的入参是Node.EXCLUSIVE
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg); // 独占锁模式返回的是boolean值,共享模式返回int值,负值为获取失败
if (r >= 0) {
setHeadAndPropagate(node, r); // 最大的区别在这里,我们在下面看下这个方法
p.next = null; // help GC
if (interrupted)
selfInterrupt();
failed = false;
return;
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
/**
* Sets head of queue, and checks if successor may be waiting
* in shared mode, if so propagating if either propagate > 0 or
* PROPAGATE status was set.
*
* 由于是共享模式,极端情况下可能会同时存在于两个节点竞争head,前提是在一次尝试后为获取到锁,
* 理论上,共享模式下在最早的第一次 tryAcquireShared调用下就可以成功,除非资源不够才会调用 addWait放入队列。
*
* @param node the node
* @param propagate the return value from a tryAcquireShared
*/
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below 第一次设置前没有head节点
setHead(node); // 在独占模式中,仅仅只用调用这一个方法
// 如果尝试获取成功,则设置该节点为头节点,
// 保证 propagate资源足够、头节点为null(没有正在运行的节点)、需要通知后驱节点状态的其中一个条件的情况下,
// 且后驱节点是null或是共享模式,则依次传播释放其他正在等待的共享节点
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared(); // 共享传播,释放其他共享节点
}
}
// 共享模式下的释放通知,包括解锁,也采用该方法
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; // loop to recheck cases
unparkSuccessor(h);
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
注:doReleaseShared()方法需要结合上下文看,这玩意挺绕人,哈哈哈!
求助:
我猜测一个问题,假设没有acquireShared方法中那第一次尝试获取,当 头节点和当前节点同时进入setHeadAndPropagate方法时,当前节点会替换掉头节点,原来的头节点已经消失。此时,无法修改原来头节点的 waitStatus通知状态,且当前节点既是head又是tail,导致在通知时直接跳过,也不做任何释放,难道CLH队列一直保留着最后一个废弃的节点???
下面这个操作一方面是设置头节点,另一方面是不是移除必要信息,形成一个空头节点(只有next属性)呢?
如有大神请帮忙展开讲讲,谢谢!
/**
* Sets head of queue to be node, thus dequeuing. Called only by
* acquire methods. Also nulls out unused fields for sake of GC
* and to suppress unnecessary signals and traversals.
*
* @param node the node
*/
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}