一、AQS架构介绍
前一批文章介绍了JUC
中为了减少锁竞争而用的CAS
和Unsafe
类,那么针对JUC中的各种同步器,例如常用的ReentrantLock
、CountDownLatch
等,他们的实现都是继承自AbstractQueuedSynchronizer
这个抽象类的,和CAS
以及Unsafe
一样,这个类是JUC中非常基础的一个类,为了在后续更好的理解各种依托它而实现的类的原理,我们很有必要先在这里介绍一下关于AQS
的实现原理,这也是在面试中会涉及到的高频点。
首先AQS
是用来构建各种同步器的基类,它内部实现是基于一个CLH
队列,队列满足FIFO
,队列形式如下:
以上是一个比较抽象的关于AQS
中CLH
队列的图片,画的卖相一般将就着看吧。队列中是一个个的Node
节点,Node
节点中封装了在队列中的前后节点,以及当前节点所属的线程,具体Node
结构待会通过源码形式展现。而state
就是这个队列中所有节点竞争的资源,它是一个volatile
类型的变量,从而保证了多线程之间修改时的可见性和有序性(禁止指令重排序)。
对于AQS
而言,我们通过查找它的实现类可以发现它主要的作用是在ReentrantLock
、Semaphore
、CountDownLatch
等这些JUC
包中的各种类中,对于这些类可以分为两大类:独占方式同步器和共享模式同步器。独占方式的典范就是ReentrantLock
,共享模式指的就是Semaphore
等。结合上图来说,对于独占模式而言,队列中同一时间只能有一个节点获取到state
资源;而对于非独占模式来说,同一时间可以有多个节点(多接点其实对应的就是多个线程)能够获取到state
资源。
对于AQS
而言,它已经实现了对于队列的维护,对于如何入队如何出队等在AQS
中都已经封装完毕,不同的实现类们只需要自己依据需求实现对于共享资源state
的释放和获取方法即可。对于AQS
的不同实现方式来说,独占模式只需要实现tryAcquire
、tryRelease
方法即可;共享模式则只需要实现tryAcquireShared
、tryReleaseShared
方法即可。这两套方法分别对应不同的模式需要实现的逻辑,而且这两套方法不是抽象方法,在子类继承实现的时候重写对应方法即可。
二、AQS源码实现
以上内容主要是停留在理论阶段,接下来我们直接深入源码,主要分析独占模式和共享模式下的实现逻辑。
2.1 独占模式
2.1.1 acquire(int)实现
独占模式的入口就是acquire
,通过acquire
进入获取锁的逻辑,代码如下:
public final void acquire(int arg) {
if (!tryAcquire(arg) && //这里tryAcquire如果获取资源成功返回true,那么就直接跳出if逻辑了。
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))//执行到这个条件说明获取资源失败了
selfInterrupt();
}
如上代码逻辑中,在if
的判断条件中,如果tryAcquire
获取资源直接成功了,那么后续的条件都不需要判断直接就结束if
判断方法结束,这种是最简单的情况。而如果tryAcquire
获取失败,那么返回是false
所以第一个条件成立,则继续判断&&
后面的条件,后面这个条件中先执行括号内的addWaiter
方法,这个方法含义很明显,因为获取资源失败了,那么我们就需要到队列中去排队了,那么这个方法的作用就是把当前需要获取资源的线程加入到队列中排队。addWaiter
加入队列中排队成功后,会返回加入队列中当前线程对应的Node
节点,然后调用acquireQueued
方法去尝试获取资源。
对于tryAcquire
方法而言,是一个需要子类重写的类,而对于addWaiter
和accquireQueued
可以接下来进一步分析源码。
2.1.2 addWaiter
对于后续这些方法中,可以看到非常多的CAS
操作,先在这个addWaiter
中来看看,代码如下:
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode); // 创建好node节点
// Try the fast path of enq; backup to full enq on failure
// 这里先尝试一种快速将node插入队列末尾的方式,这种方式没有用循环CAS的方式
// 只是先获取尾节点,尾节点如果不为空则尝试通过CAS疆node插入尾节点后,如果成功则直接返回。
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 上面那种方式有可能会遇到失败,就是要么队列是空的,所以失败;
// 又或者是在通过CAS执行插入的时候,有其它线程插足导致CAS失败了,无论哪种都会再继续执行下面的enq方法再次尝试将node加到队列中
enq(node);
return node;
}
addWaiter
逻辑看注释很明了了,那么我们继续看一下其中的enq
方法的实现如下:
private Node enq(final Node node) {
for (;;) { // 所谓典型的循环CAS执行插入,实现无锁形式的线程安全。
Node t = tail;
if (t == null) { // 如果尾节点为空,说明队列是空的,先初始化一个新节点入队作为头结点。
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t; // 设置node的前置节点为t,相当于是把node插入到t的后面
if (compareAndSetTail(t, node)) { // 然后通过CAS更新尾节点为node,即把tail指向node
t.next = node; // 更新成功之后,再设置之前的旧的尾节点t的后置位为node,形成双向队列。
return t;
}
}
}
}
以上备注部分已经详细介绍了enq
插入逻辑的实现,在这里就可以非常明白的看到经典的无限循环CAS
操作来实现无锁的线程安全操作共享数据,后续方法还会遇到非常多的这种用法。
2.1.3 acquireQueued(Node node,int arg)
在执行完2.2节介绍的addWaiter
后,最终返回了成功插入队列中节点node
的引用。然后再继续调用acquireQueued
方法,对于acquireQueued
方法的代码实现如下:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true; //标识是否计划执行失败
try {
boolean interrupted = false; // 标识线程是否被中断
for (;;) {
final Node p = node.predecessor(); //获取node节点的前置节点p
// 如果p是头结点,说明node节点处于队列中的老二位置,此时可以主动尝试去获取一次资源
// 因为有可能这个过程中前置节点已经把资源释放了出来,所以可以尝试获取看是否成功,成功则进入if逻辑中
if (p == head && tryAcquire(arg)) {
setHead(node); // 成功获取到了资源,则把head节点指向node。
p.next = null; // 前置节点获取资源完毕后,使命已经完成,可以斩断和队列的联系便于GC
failed = false; // 改变标识,标识本次计划成功了
return interrupted; // 由于是在for循环中执行的这些操作,返回当前interrupted的结果
}
// 这里先调用shouldParkAfterFailedAcquire判断前置节点的状态是否正常,目的是找到一个正常的前置节点
// 然后告诉前置节点,如果它资源使用完毕了要通知一下我,这样node对应的线程才能进行等待休眠状态
// 如果找到合适的休眠条件了,再调用parkAndCheckInterrupt让线程进入等待休眠状态,并且检查线程是否被中断。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true; // 线程是被终止过,修改标识
}
} finally {
if (failed) // 如果操作过程中,发生意外终止报错,则最终需要把当前的node节点状态改外取消状态
cancelAcquire(node);
}
}
以上就是acquireQueued
的逻辑,对于他的逻辑而言就是在一个无限for
循环中来判断是否满足获取资源的条件并获取资源,对于第一个if
中判断前置节点以及尝试获取资源的逻辑很好理解,但是对于第二个if
中关于parkAndCheckInterrupt
这一部分逻辑其实有点不好理解,这里我们对第二个if
中的两个方法进一步进行剖析。
2.1.3.1 shouldParkAfterFailedAcquire(Node pre,Node p)
这个方法的逻辑通俗来说就是,p
节点已经进入队列了,但是每个队列中的人是否能够获取资源是要依赖于它前面的人的,所以为了确保能够获取到资源,p
必须确保它前面的那个人pre
是个正常的人,而不是一个傻子(占着茅坑不拉屎,排队到自己了也不主动去获取资源,并且也不会提醒后面的人),那么p
就是通过shouldParkAfterFailedAcquire
方法来鉴别它前面人的属性是否正常,如果不正常就依次往前遍历,找到一个正常的人,然后插队排到那个正常的人的后面,这样前面那个人在获取资源使用完毕后,会告诉后面的p
可以去获取资源了,实现代码如下:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)//前面那个节点的waitStatus属性如果为Node.SIGNAL (-1),说明他是个正常的节点,这种情况下可以放心的进入等待休眠状态
return true;
if (ws > 0) { // 对应值大于0说明前置节点是被取消了的,状态不正常
do {
node.prev = pred = pred.prev; // 一直往前寻找,找到一个正常的合适的节点
} while (pred.waitStatus > 0);
pred.next = node; // 将node设置为这个正常节点的后置节点
} else {
// 对应值 <=0则说明状态是正常的,不过值不是-1,说明这个节点不知道我们的存在,所以把他的状态值设置为-1
// 意思就是告诉他,他后面还有人排队,当他资源使用完毕后记得通知后续的节点去获取资源
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
以上逻辑实现后,如果成功找到合适的地方,线程则已经准备好可以随时进入休眠等待状态,此时调用parkAndCheckInterrupt
方法。
2.1.3.2 parkAndCheckInterrupt()
这个方法看意思很好理解,就是让现场进入等待状态,这里严格来说是等待状态而不是阻塞状态,很多文章说是进入阻塞状态是有一定错误的,线程的阻塞和等待状态是不一样的状态,虽然看起来效果是一样的,但是概念还是不能混乱的(参考:http://www.cnblogs.com/waterystone/p/4920007.html)。直接上源码如下:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
到这里调用park
方法后线程就进入休眠等待状态,休眠等待状态就在此挂住了休息,一直等待它的前任节点释放资源后主动调用当前这个节点的unpark
方法来唤醒,唤醒后马上返回线程是否被中断的状态值给acquireQueued
方法,然后被唤醒的这个节点知道该去重新进行下一轮for
循环尝试获取资源了,不过这里会出现两种获取资源失败的情况:
- 第一种就是有可能当前节点的
unpark
方法其实不是被它的前置节点,而是其它地方的线程处于某个别的目的唤醒了它,此时它去尝试获取资源的时候会发现它的前置节点仍然不是head
,所以肯定是无法尝试去获取资源的,那么这个被唤醒的节点会继续走shouldParkAfterFailedAcquire
以及parkAndCheckInterrupt
的流程进入等待休眠状态; - 第二种被唤醒后获取资源失败的情况是,在
ReentrantLock
等类中,由AQS
延伸的实现有公平锁和非公平锁两类,第二种情况就是确实当前节点被唤醒确实是被前置节点调用的unpark
唤醒了,但是由于用的是ReentrantLock
中的非公平锁,就会出现当前节点被唤醒但是还没来得及获取到资源,就被其它线程给截胡把资源掠夺走了,那么当前节点在tryAcquire
的时候发现失败了,于是乎就只有继续进入休眠状态了。当然了,这种情况下,如果被截胡了,注意队列中头结点的waitStatue
值会由于shouldParkAfterFailedAcquire
重新由0又变回-1。而且,这种情况下,头结点此时是暂时失效了,下次老二被唤醒是由于截胡的线程资源使用完毕,然后释放资源的时候会通过头结点去唤醒老二节点。
到这里,关于AQS
中队列管理获取资源的流程已经基本摸清逻辑了,通过以上内容基本能够搞清AQS
内部运作的设计思想了,接下来再来看资源释放的流程,相对会容易理解很多。
2.1.4 release(int)
相比于获取资源,释放资源源码看起来就简单多了,代码如下:
public final boolean release(int arg) {
if (tryRelease(arg)) { // 子类重写方法
Node h = head;
// 头结点不为空,且头结点的waitStatus不为0说明头结点是有状态的,
// 如果是公平锁那么这里的头结点就是释放资源的线程自身对应的node节点
// 但是对于非公平锁,这个头结点就不一定是自身线程对应的node了。
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 唤醒后续节点接盘!
return true;
}
return false;
}
单单这个方法逻辑十分简单,我们直接进入unparkSuccessor
方法去看是如何唤醒继承者的,unparkSuccessor
的代码如下:
private void unparkSuccessor(Node node) {
// 获取头结点的waitStatus,如果小于0,先尝试把他设置为无状态的0
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next; // 获取头结点的下个节点,即老二~
if (s == null || s.waitStatus > 0) { // 如果老二为空或者waitStatus>0表示被取消了,进入if
// 到了这里s其实就已经不再是head的next节点了,因为head的next不满足继承者的条件
// 所以这里就开始寻找满足条件的继承者
s = null;
// 注意这里是从尾节点开始往head节点反向遍历,寻找反向的最后一个满足waitStatus<=0的节点。
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null) // s不为空,说明后继有人,则调用这个线程的unpark方法,唤醒它去获取资源
LockSupport.unpark(s.thread);
}
以上就是唤醒后继线程去获取资源的逻辑,逻辑很简单,但是需要注意的一点是它在头结点的next
节点不满足条件的时候,寻找后继节点的时候是从tail
到head
进行遍历找最后一个满足条件的节点,这里为啥不直接正向的遍历找第一个满足条件的不是更快更方便么?其实这里只能反向遍历而不能正向遍历,原因有两个:
- 在
enq
方法将node
节点插入队列的时候,它的执行逻辑是:
1>. 设置node
的pre
指向当前的尾节点;
2> 通过CAS
操作更新尾节点为node
;
3> 如果2中更新尾节点成功了,将之前的老的尾节点(当前倒数第二个节点)的next
指向最新的尾节点node
完成双向队列构造;如果2失败了,那么继续重复1、2、3一直循环直到成功。
理解了这种入队逻辑,那么我们再来看刚才的问题,如果假设正向寻找后继节点,会出现一种情况如下图:
如图刚好head
节点资源使用完毕要释放资源,调用了unparkSuccessor
方法,然后tail
对应的尾节点此时
此时队列中暂时只有一个节点在占用资源,即head
和tail
是一个节点,然后刚好有一个节点node
要入队,然后刚好执行完了enq
方法的第一步和第二步,还没来得及把倒数第二个节点的next
指向node
,那么此时如果刚好head
节点释放资源调用了unparkSuccessor
方法来寻找后续的唤醒节点,假如采用的是正向遍历寻找,那么就会出现寻找head.next
的时候发现头结点的next
为null
,但是实际上这个时候是有一个node
节点在队列中,只是刚刚好那么碰巧这个节点还没来得及完成尾节点的更新,那么这种情况就会出现问题。而此时如果是通过尾节点反向遍历,那么由于enq
方法第一步就是把node
的前置指向tail
,反向遍历的时候肯定是可以成功遍历队列中所有的节点的,但是其实如果正向遍历全部是取消了的节点不满足状态,其实对于node
而言应该是没有影响的,虽然正向遍历过程中没有找到node
节点,但是node
节点的enq
方法是在addWaiter
方法中调用的,后续node
成功执行完毕addWaiter
方法成功加入队列后,虽然错过了上一次的被告知可以获取资源的信息,但是它执行完addWaiter
后会继续调用acquireQueued
方法,这个方法的中会通过shouldParkAfterFailedAcquire
来自己跳过node
前面的各个作废的节点,最终找到head
节点,这个head
节点此时状态应该是waitStatus=0
,然后node
找到head
以后,就会发现满足了前置节点为head
的逻辑,并且此时state
资源是没有线程占据的,那么node
此时会找到head
后马上开始主动去获取资源!而且在node
这个自己寻找到head
的过程中,线程一直是处于running
状态,也一直没有进入过park
等待休眠状态,所以其实这中间是否被其它线程调用unpark
方法唤醒根本没有影响,因为它压根就没有进入等待状态!所以个人这么梳理下来感觉是unparkSuccessor
无论正向还是反向遍历都是没有区别的,因为最终的效果都是一样的,那么有小伙伴对这一块有什么想法么?欢迎留言交流~
以上2.1.1~2.1.4小节介绍的函数主要都是独占模式下常用的方法,基本介绍清楚了独占模式的设计流程,接下来来介绍共享模式的逻辑。
2.2 共享模式
2.2.1 acquireShared(int arg)
这个方法对应的就是共享模式获取资源的入口,我们来看源码如下:
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
这个源码看起来十分简单,不过要理解这个逻辑还是需要看一下tryAcquireShared
方法,它在AQS
中实现如下:
/**
* @return a negative value on failure; zero if acquisition in shared
* mode succeeded but no subsequent shared-mode acquire can
* succeed; and a positive value if acquisition in shared
* mode succeeded and subsequent shared-mode acquires might
* also succeed, in which case a subsequent waiting thread
* must check availability. (Support for three different
* return values enables this method to be used in contexts
* where acquires only sometimes act exclusively.) Upon
* success, this object has been acquired.
*/
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
这里贴出这段代码主要是为了看其中的注释部分关于返回值的说明,这个方法是需要实现共享锁的子类自己去重写的,但是重写需要注意的是要清楚明白它的返回值是如何规定的,意思很显然,返回值分为三类:
-
- 返回值>0: 说明获取资源成功,并且有剩余的资源可以让其它线程来继续尝试获取
-
- 返回值=0: 说明获取资源成功,但是没有后续资源可供其它线程来继续获取了,意思应该就是本次刚好把资源榨取干了,你们后续要获取资源的需要等待。
-
- 返回值<0: 说明获取资源失败,失败的原因可能是资源不足或者别的原因在后续代码中再进行分析。
分析完tryAcquireShared
的返回值后,就可以知道在入口acquireShared
处如果获取失败,则会进入doAcquireShared
方法的逻辑中,接下来介绍失败后的获取流程。
2.2.2 doAcquireShared(int arg)
对于获取资源失败后的处理入口就是这个doAcquireShared
方法,方法的实现如下:
private void doAcquireShared(int arg) {
// 往队列中插入一个共享模式的节点
final Node node = addWaiter(Node.SHARED);
boolean failed = true; // 成功与否的标识
try {
boolean interrupted = false; // 是否中断标识
for (;;) {
final Node p = node.predecessor();
if (p == head) {//到了这里和之前的独占模式流程是类似的,只是说如果获取完资源后发现有多的,会继续唤醒后续线程
int r = tryAcquireShared(arg);
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);
}
}
对于doAcquireShared
逻辑和之前的acquire
中是完全类似的,只是说因为是共享模式,所以资源有多的时候,在获取完资源后会马上唤醒后续的线程尝试去获取资源,具体唤醒后续节点的方法setHeadAndPropagate()
代码如下:
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node); // 更新head节点,不然后续节点无法通过判断去获取资源
// 有多余资源,调用doReleaseShared()来唤醒
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
2.2.3 doReleaseShared()
对于doReleaseShared
方法而言,作用通过无限for
循环来实现CAS
操作从而唤醒后续节点,代码实现如下:
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) { // head如果和tail一样,说明只有一个节点了无需继续
int ws = h.waitStatus;
if (ws == Node.SIGNAL) { // head节点的waitStatus只有在后续节点主动更新的情况下会变成Signal
// 到这里说明知道后续有节点在等待资源,而自己已经获取到资源并且资源有多的,那么就改变自己状态值waitStatus为0
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
// 到这里说明已经可以唤醒后续节点了
unparkSuccessor(h);
}
else if (ws == 0 && // head节点的waitStatus在释放资源的时候会自己设置为0
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // loop on failed CAS
}
if (h == head) // loop if head changed
break;
}
}
如上备注已经介绍了doReleaseShared
的逻辑,明白以上内容后,再来对releaseShared
进行解析就十分简单了。
2.2.3 releaseShared()
对于releaseShared
而言,在理解了以上内容后,基本这里可以跳过了,因为代码如下:
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
上面的逻辑中,主要涉及tryReleaseShared
方法是子类去实现的,可以忽略;而doReleaseShared
上一节已经介绍了,所以这里对于releaseShared
的理解就显得十分简单了。
以上就是关于AQS
中介绍的独占模式以及共享模式的实现流程介绍,接下来文章会对AQS
的具体应用部分进行介绍,主要有ReentrantLock
、Semaphore
以及CountDownLatch
等。