【Java 并发】【九】【AQS】【二】基于AQS的互斥锁机制、底层源码深度剖析
1 前言
上一节我们从整体上分析了什么是AQS以及AQS内部的数据结构,那么这节我们就从acquire和release入手,分析一下AQS为独占锁提供的机制:到底是怎么在获取资源失败进入等待队列的?以及释放资源的时候怎么唤醒后继节点的线程竞争锁的?
2 acquire 方法源码解析
首先我们看一下AQS获取资源的入口acquire方法的源码:
public final void acquire(int arg) { // 1.调用子类的tryAcquire方法,去获取锁 if (!tryAcquire(arg) && // 2.获取资源失败调用addWaiter方法插入等待队列 // 3.然后调用acquireQueued方法在队列实现阻塞或者再去获取锁 acquireQueued( addWaiter(Node.EXCLUSIVE), arg) ) selfInterrupt(); }
我们看一下上述的acquire这个模板方法,上面规定了几个模板流程:
(1)流程1:第一个是调用子类的tryAcquire方法去获取锁,如果获取成功则不用走下面的逻辑了,直接返回
(2)流程2:如果获取失败,则调用addWaiter,封装互斥锁模式的Node节点进入等待队列
(3)流程3:进入等待队列之后,调用acquireQueued方法,是否需要阻塞等待,什么时候再去获取锁等,这些及具体的逻辑封装在这个方法里面。
接下来我们一个一个看哈,首先看一下addWaiter方法是怎么将节点插入到等待队列的。
2.1 addWaiter方法源码解析
调用addWaiter方法,将节点加入等待队列尾部:
// node模式为互斥锁模式 addWaiter(Node.EXCLUSIVE), arg)
private Node addWaiter(Node mode) { // 创建一个新的Node节点,封装当线程线程 Node node = new Node(Thread.currentThread(), mode); // 获取等待队列的尾部节点 Node pred = tail; // 如果此时pred!=null,说明等待队列不是空,可以尝试插入新的尾结点 if (pred != null) { node.prev = pred; // CAS操作插入新的尾部节点,CAS操作让tail指针指向node节点 if (compareAndSetTail(pred, node)) { // 插入成功,修改一下node的prev指针 pred.next = node; return node; } } // 如果等待队列没初始化,或者CAS插入失败则走到这里 // enq方法保证绝对将node节点插入为尾部节点 enq(node); return node; }
上面的流程大概可以归纳为如下几点:
(1)如果pre!=null,说明等待队列不是空,可以尝试cas把 tail 指向 node 节点,即cas插入一个新节点
(2)如果compareAndSetTail(prev, node)即cas将tail指针指向node节点成功,需要修改一下prev指针,保持等待队列是双向链表,然后就返回了
(3)如果等待队列为空,即tail == null;或者cas操作失败了,则进入enq方法,保证一定会将节点插入到等待队列
enq(Node node)方法源码:
private Node enq(final Node node) { for (;;) { // 获取tail节点 Node t = tail; // 如果t == null说明等待队列是空的 if (t == null) { // Must initialize // 等待队列是空的,必须要初始化一个空线程的Node节点 // 然后将head和tail都指向这个没有线程的节点 // 初始化好之后,在进入下一个循环,尝试将tail节点指向node if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; // 如果等待队列不是空的 // 则CAS尝试将tail指针指向这个新的node节点, // 如果CAS成功了则说明插入队列尾部成功了,直接返回 if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }
这里不知道你们有没有这样的疑问,为什么尝试插入节点到尾部节点都要执行CAS操作,就是上面的那compareAndSetTail?
AQS同步工具,肯定是会存在多线程并发操作的情况啊,如果不执行CAS操作保证原子性,同时tail、head使用volatile修饰,保证可见性和有序性。这几点都保证了才能保证是线程安全的。
为什么如果等待队列是空的就需要先初始化一下,搞个空的节点作为头结点,搞一个没有线程对象的节点作为头结点?直接使用我们插入的那个节点作为头结点不行吗?
其实,这个跟AQS获取锁机制的设计有关。AQS规定:头结点必须是已经获取锁的节点或者头结点必须是一个空节点,即头结点是一个不再需要等待锁的节点!!!。
然后等待队列中的第二个节点是等待队列中即将能够获取锁的节点,就比如下图:
也就是说AQS规定了第二节点是等待队列中下一个能获取锁的节点。如果插入发现等待队列是空的,于是就初始化一个空的节点,然后在插入,这样保证自己是第二。当别的线程释放锁的时候就轮到它了。
好了,我们将addWait()方法的源码就到这里,addWait其实就是将线程插入到等待队列中,我们接着上面的那个大流程继续讲解。
2.2 acquireQueue方法源码
接下来就是acquire方法中的最后的一个模板流程,也就是acquireQueue方法就讲解acquireQueued方法的源码,我们接着看。
final boolean acquireQueued(final Node node, int arg) { // 获取锁是否失败的标识,fail=true表示失败了,fail=false表示获取成功 boolean failed = true; try { // 中断标志 boolean interrupted = false; for (;;) { // 获取当前节点的前一个节点 final Node p = node.predecessor(); // 如果前一个节点p是head,则说明自己是第二节点 // 自己是第二节点则调用子类的tryAcquire再次竞争资源 if (p == head && tryAcquire(arg)) { // 获取锁成功,将自己设置为头节点 setHead(node); // 旧的头结点p可以被丢弃了,设置next=null,方便被GC p.next = null; // help GC // 获取锁成功了,所以fail自然就是false failed = false; // 返回被中断标识为false,表示获取锁成功了,没有被中断 return interrupted; } // 如果上面的操作没有成功;说明自己可能不是第二节点 // 或者自己是第二节点,但是tryAcquire方法获取锁失败了 // 这个时候就调用shouldParkAfterFailedAcquire方法判断自己是否需要被阻塞挂起 // 如果需要被挂起,则调用parkAndCheckInterrupted方法将自己挂起 // 挂起后如果被别的线程唤醒,然后继续执行,尝试去获取锁 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }
我们来画个图来理解一下:
上面就是acquireQueued方法源码的核心流程图,其实就是说自己插入等待队列之后:
(1)会一直判断自己是不是老二节点,如果自己是老二节点则调用子类的tryAcquire方法争抢锁,如果争抢成功了,则将自己设置成头节点,原来的头结点则可以出队了。
(2)如果争抢失败了,则调用shouldParkAfterFailAcquire方法判断自己需不需要被挂起
(3)如果自己需要被挂起,则调用parkAndCheckInterruptd方法将自己线程挂起
(4)当别的线程释放锁,将自己唤醒之后,自己又重复上面的(1)、(2)、(3)步骤了
2.2.1 shouldParkAfterFailAcquire方法源码
我们接下来继续看,shouldParkAfterFailAcquire内部的源码是怎么样判断的:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { // pred为当前线程节点node的前一个节点 int ws = pred.waitStatus; // 如果前一个节点的waitStatus = -1 ,即SINGAL的时候 // 自己就需要被挂起了,当pred节点释放锁的时候发现waitStatus为SINGAL // 说明后面还有人等着我唤醒,则将自己的下一个节点唤醒 if (ws == Node.SIGNAL) return true; // 如果ws>0,说明是无效状态,pred节点已经被timeout超时或者中断了 if (ws > 0) { do { // 然后继续往前找,找到一个ws <=0 的有效节点 // 中间的那些无效节点,全部删除 node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { // 设置node前面的一个节点为SINGAL // 相当于告诉它,老哥我把你的节点状态设置成SINGAL了 // 这个信号,说明后面有人等着你唤醒呢,你释放锁的时候记得唤醒一下 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }
上面的源码我们可以再画图理解一下:
(1)线程发现如果自己的前一个节点状态是 -1,说明已经告诉前一个节点一个信号了,这个信号就是等他释放锁的信号将我唤醒(也就是说前一个几点状态是 -1 的时候,它释放锁的时候会叫醒你,你放心睡觉好了)
(2)如果发现前一个节点是无效节点,如下图所示,就删除这个无效节点,然后继续往前找,直到找到waitStatus <= 0 的有效节点
也就是第三节点会被剔除掉
整体的总结下来其实就是:
(1)判断一下自己前面是不是存在waitStatus <= 0 的节点。如果前一个节点是waitStatus > 0的无效节点,则继续往前找,找的过程中删除遇到的无效节点。
(2)找到前一个有效节点之后,给前一个有效节点一个信号waitStatus = -1,告诉它,兄弟,我在后面等着你呢,等你释放锁的时候记得叫醒我,我先去睡觉去了。
2.2.2 parkAndCheckInterrupt方法源码
睡眠的代码就比较简单了我们来看下:
private final boolean parkAndCheckInterrupt() { // 到这里直接调用LockSupport的park方法将线程挂起,线程就被卡在这里了 LockSupport.park(this); // 走到这里了,说明线程被唤醒了,判断一下线程是否被中断了,返回线程的中断状态 return Thread.interrupted(); }
(1)直接调用LockSupport的park方法将线程挂起来了
(2)当线程被唤醒的时候,继续执行走到Thread.interrupted,就是返回一下自己的中断状态。如果自己被中断了,则不能继续获取锁了
我们继续回到最开始的acquire方法源码:
public final void acquire(int arg) { // 1. 调用子类的tryAcquire去尝试获取锁 if (!tryAcquire(arg) && // 2.获取失败调用addWaiter进入等待队列尾部 // 3.调用acquireQueue是否需要再尝试获取锁,还是在等待队列里面沉睡 acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
当tryAcquire返回false的时候会调用acquireQueued方法。acquireQueued方法又是返回线程的中断标志。所以啊,如果被中断了就会进行selfinterrupted里面,就打断当前线程的运行了。
static void selfInterrupt() { Thread.currentThread().interrupt(); }
2.2.3 cancelAcquire方法源码
我们可以看到方法中最后有个finally,会执行cancelAcquire方法,也就是方法返回前都会执行的,这里会判断如果获取锁失败了,就移除等待队列里面的这个节点了。我们进入cancelAcquire方法内部源码看一下:
private void cancelAcquire(Node node) { // 如果node节点为null,说明已经被移除掉了,直接返回 if (node == null) return; // 设置node的thread为null,已经该节点已经无效了 node.thread = null; // 这里不断修改prev指针,就是删除中途一样是无效的节点 Node pred = node.prev; while (pred.waitStatus > 0) node.prev = pred = pred.prev; Node predNext = pred.next; // 设置节点的状态为CANCELLED,即为1,为无效状态 node.waitStatus = Node.CANCELLED; // 如果node节点是tail尾结点,直接设置tail为null,说明队列里面没有元素了 if (node == tail && compareAndSetTail(node, pred)) { compareAndSetNext(pred, predNext, null); } else { int ws; // 如果节点pred不是头结点 if (pred != head && // 并且能pred节点是singal或者能将pred节点设置为singal // 这样让pred释放锁的时候唤醒后续线程 ((ws = pred.waitStatus) == Node.SIGNAL || (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) && // 并且pred节点是有效的,即它的thread不是空 pred.thread != null) { Node next = node.next; if (next != null && next.waitStatus <= 0) compareAndSetNext(pred, predNext, next); } else { // 走到这里说明node节点前面没人在等待锁了,可能pred是头结点或者node前面的都是无效节点 // 这个时候直接唤醒node节点的下一个节点,让他去竞争锁了 // 嘿,兄弟,前面没人等了,你别睡了,该你去获取锁了 unparkSuccessor(node); } node.next = node; // help GC } }
我们还是画个图理解一下:
如果此时要移除图中黄色的节点:
找到无效节点删除后,然后寻找前一个有效节点,修改指针,修改有效节点的waitStatus = -1,告诉它后面还有人等着:
大致就是如果要移除node节点,大概的操作就是找到node节点的前一个有效pred的节点,修改一下指针,将pred的next节点指向node节点的下一个节点。然后设置一下pred节点的状态是-1,告诉pred节点,你释放锁的时候要唤醒后面的节点。
2.3 acquire方法小结
上面的流程大概就是acquire方法内部的全部流程了,讲到这里acquire源码的全部流程就讲解完毕了,这里我们再总结一下:
(1)acquire首先直接调用子类tryAcquyire方法去获取独占锁,如果获取成功了就直接返回了
(2)然后获取失败了会调用addWaiter方法将自己封装成一个Node节点插入到等待队列里面,这个上面我们已经进行了源码深度剖析了
(3)插入等待队里之后呢,在调用acquireQueued方法,需不需要沉睡,如果不需要会在一个for循环里面一直尝试去获取锁,这个地方我们上面也画图分析了,源码也分析了
(4)最后会进入一个finally代码块里面,判断如果获取锁失败了,要从等待队里里面移除了
3 release方法源码解析
我们接下来继续,讲解一下AQS释放锁release方法的源码是怎么样的:
public final boolean release(int arg) { // 1. 首先进来直接调用子类的tryRelease方法去释放锁 if (tryRelease(arg)) { // 2. 如果释放锁成功,去到head头结点,然后去 // 唤醒head节点的下一个节点 Node h = head; if (h != null && h.waitStatus != 0) // 这里就是唤醒h节点的下一个节点的实际方法 unparkSuccessor(h); return true; } return false; }
首先,release方法作为一个模板方法,里面定义了几个模板的流程:
(1)流程1:第一步还是去调用子类的tryRelease方法去释放锁
(2)如果释放锁成功,直接找到head节点,唤醒head节点的后续节点,也就是唤醒第二节点。因为head节点是一个已经获取到锁节点、或者是一个空节点,是不再需要锁的,所以下一个等待锁的肯定是第二节点。
private void unparkSuccessor(Node node) { // 如果node节点是状态是 < 0,这是一下node节点状态为0 int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); // 这里是直接找到node节点的下一个节点s Node s = node.next; // 如果s是无效的节点,waitStatus > 0 或者s为null,继续往后找到 if (s == null || s.waitStatus > 0) { s = null; // 这里是从tail往前面找,找到一个有效的节点将它唤醒 for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) // 这里就是直接调用LockSupport.unpark方法将s节点的所在线程唤醒了 LockSupport.unpark(s.thread); }
我们再画个图来理解一下:
(1)首先就是调用子类的tryRelease方法去释放锁
(2)释放锁成功之后,调用unparkSuccessor(head)去唤醒head节点的下一个节点,由于head是头节点,head的下一个就是老二节点,所以就唤醒老二节点,让老二节点来获取锁。
(3)如果老二节点是无效节点,那就从tail 节点往前找 ,找到一个有效的节点来唤醒。
这里为什么会从tail结尾往前找呢,我的理解是我既然要唤醒某一个,尾部的肯定是新鲜的,有效节点更多,所以直接从尾部找快一些。
4 acquire、release方法汇总
我们最后画个图从整体上梳理一下acquire获取锁以及release释放锁的全流程:
5 小结
到这里,AQS获取独占锁和释放独占锁的底层源码、核心流程全部分析完毕了,有理解不对的地方欢迎指正哈。