JAVA并发同步架构 AQS
前言
在学习并发的时候,不得不说 ReentrantLock;而说到ReentrantLock,就不得不说到 AbstractQueuedSynchronizer(AQS)。
AQS 是实现同步器的基础组件,并发包中锁的底层就是使用 AQS 实现的,当然在日常的开发中,基本不会直接使用AQS,但是了解其底层原理对于锁的理解和架构的设计是很有帮助的。
一、框架
AQS内部维护了一个 volatile int state(共享资源)和一个 FIFO 线程等待队列(多线程争用资源阻塞时会进入队列)。
AQS内部的 state 是一个单一的状态信息,可以通过 getState()、setState()、compareAndSetState() 函数修改其值。对于ReentrantLock的实现来说,state可以用来标识获取锁的可重入次数。对于AQS来说,线程同步的关键是对状态值 state 进行操作,根据 statue是否属于一个线程,AQS分为两种资源共享方式:Exclusive(独占-ReetrantLock)和 Share(共享-Semaphore/CountDownLatch)。
不同的自定义同步器争用共享资源的方式也不同。自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于线程等待队列的维护(如获取资源失败入队、唤醒出队等),AQS已经在顶层实现好了,自定义同步器主要实现以下方法:
- isHeldExclusively():该线程是否正在独占资源。只有用到condition才需要去实现它。
- tryAcquire(int):独占方式。尝试获取资源,成功则返回true,失败则返回false。
- tryRelease(int):独占方式。尝试释放资源,成功则返回true,失败则返回false。
- tryAcquireShared(int):共享方式。尝试获取资源。负数表示失败;0表示成功,但没有剩余可用资源;正数表示成功,且有剩余资源。
- tryReleaseShared(int):共享方式。尝试释放资源,如果释放后允许唤醒后续等待结点返回true,否则返回false。
Node节点是对每个等待获取资源的线程的封装,其中包含了需要同步的线程本身以及等待状态,如阻塞、等待唤醒、取消等。变量waitStatus 表示节点的等待状态,其中包含五种取值: CANCELLED、SIGNAL、CONDITION、PROPAGATE、0
- CANCELLED(1):表示当前结点已经取消调度。当timeout或被中断,会变更为此状态,进入该状态后结点将不再变化
- SIGNAL(-1):表示后继结点在等待当前结点的唤醒。后继结点入队时,会将前面结点的状态更新为SIGNAL
- CONDITION(-2):表示结点等待在 Condition上,当其他线程调用了 Condition的 signal() 方法后,CONDITION状态的结点,将从等待队列转移至同步队列中,等待获取同步锁。
- PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时会尝试唤醒其他后继的结点。
- 0:新结点入队时的默认状态。
waitStatus 负值表示结点处于有效等待状态,正值表示已被取消,源码中有很多使用 >0、<0 来判断结点状态是否正确。
二、源码解析
源码按照 acquire-release、acquireShared-releaseShared 的次序解读
2.1、acquire(int arg)
该方法的功能已经在其方法注释上给予了说明,下面具体看下每一步的功能实现
/** * 独占模式的获取资源,忽略中断。通过tryAcquired获取资源,如果成功则结束方法。否则,线程进入队列,重复的阻塞和非阻塞,
* 调用tryAcquire直到成功获取到资源,该方法可以用于实现Lock.lock 方法*/ public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }
- tryAcquire(int) :尝试获取资源,成功直接返回,AQS并没有实现 tryAcquire 而是交由具体的同步器实现该功能
- addWaiter():将获取资源的线程加入队列尾部,并标记独占模式
- acquireQueued():线程阻塞在队列中获取资源,一直到成功获取资源才返回。中途如果被中断,则返回true,否则返回false。
- 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
2.1.1、tryAcquire(int)
AQS中的实现,可以看出AQS本身对于资源的获取并没有提供实现,AQS只是一个同步器框架,资源的获取与释放是交给自定义的同步器实现的(通过state的get\set\CAS)。至于能不能重入、加塞由具体的自定义同步器设计。
protected boolean tryAcquire(int arg) { throw new UnsupportedOperationException(); }
2.1.2、addWaiter(Node)
将当前的线程按照给定的模式加入队列尾部,并返回当前线程所在节点
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() 方法入队 enq(node); return node; } private Node enq(final Node node) { // CAS自旋,直到成功写入队列末尾 for (;;) { Node t = tail; if (t == null) { // 队列为空需要初始化,并将head、tail都指向它 if (compareAndSetHead(new Node())) tail = head; } else { // 队列不为空,写入队列末尾 node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }
注意:这里有个非常经典的应用:CAS自旋volatile变量。
2.1.3、acquireQueued(Node,int)
通过tryAcquire()和addWaiter() 表明,表明线程获取资源失败,并且已经加入等待队列,那么接下来就是:进入等待状态,等待占用资源的线程释放资源并唤醒,获取到资源之后,就可以执行自己的任务了。
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; // setHead中node.prev已经设置为null,此处将原本的head结点的next设置为null,是为了方便GC回收之前的head结点 failed = false; // 成功获取到了资源 return interrupted; // 等待过程中没有被中断 } // 如果自己可以休息,就通过park()进入waiting状态,直到unpark()唤醒,如果在不可中断的情况下被中断了,就会从park()中醒来,发现拿不到资源,从而继续进入park()等待 if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true;// 如果等待过程中被中断,将interrupted标记为true } } finally { if (failed) // 如果等待过程中,没有成功获取到资源,那么取消结点在队列中的等待 cancelAcquire(node); } }
有资格获取资源的过程已经了解了,下面来具体看看 shouldParkAfterFailedAcquire(Node,Node) 和 parkAndCheckInterrupt() 做了什么。
/** * 检查并更新获取资源失败的结点状态。如果线程需要阻塞,则返回true。这个方法是所有获取资源循环中主要的信号控制方法 */ private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) { int ws = pred.waitStatus; // 前驱结点的状态 // 前驱结点已经被设置为,后面结点等待前驱结点的通知,那么当前结点就可以安心等待park if (ws == Node.SIGNAL) return true; if (ws > 0) { // 前驱结点被取消,沿着队列一直往前遍历,直到找到有效等待状态的结点,并将当前结点的前驱设置为该结点,该结点的后驱结点设置为当前结点(踢除中间无效等待状态的结点) do { node.prev = pred = pred.prev; } while (pred.waitStatus > 0); pred.next = node; } else { // 需要将前驱结点的状态赋值为SIGNAL,告诉它获取资源之后通知下自己 compareAndSetWaitStatus(pred, ws, Node.SIGNAL); } return false; }
// 如果线程找到了休息点之后,就可以安心的去休息了,此方法会让线程进入等待状态 private final boolean parkAndCheckInterrupt() { LockSupport.park(this); // 调用park() 是线程进入等待状态 return Thread.interrupted(); // 判断当前线程是不是中断状态,用于在被唤醒时候查看自己是不是被中断了 }
在整个流程中,如果前驱结点的状态不是SIGNAL,那么自己就不能够安心去休息(等待),需要找个安心的休息点(前驱结点状态为SIGNAL),同时可以尝试下有没有机会轮到自己获取资源。
总结一下 acquireQueued 函数的具体流程:
- 结点进入队列之后,检查结点状态,并寻找安全的休息点
- 调用LockSupport.park() 方法进入等待状态,等待unpark() 或 interrupt() 唤醒自己
- 被唤醒之后,查看自己是不是第二顺位,有没有资格获取资源,如果获取到 head 指向当前结点,并返回等待到获取资源的过程中,是否被中断过;如果没有获取到资源,则重复流程。
2.1.4、小结
重新梳理下来看下acquire(int) 函数的流程:
- 调用 tryAcquire(int) 尝试获取资源,如果获取到资源,流程结束
- 如果获取资源失败,通过addWaiter加入等待队列队尾,并标记为独占模式
- acquireQueued 让线程在等待队列中休息,有机会(轮到自己、unpark())就去尝试获取资源,获取到资源后才会返回。
- 如果线程在等待过程中被中断,是不响应的,只是获取资源后才进行自我中断 selfInterrupt,将中断补上。
2.2、release(int)
release(int) 是独占模式下释放资源的顶层入口,会释放定量的资源,如果彻底释放(state=0),会唤醒等待队列中的其他线程获取资源,这也正是unlock() 的语义
// 独占模式下的资源释放,如果tryRelease返回true,会释放一个或更多的线程(定量)。可以用来实现Lock.unlock public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; // 获取头结点 if (h != null && h.waitStatus != 0) unparkSuccessor(h); // 唤醒等待队列中的下一个线程 return true; } return false; }
release()调用 tryRelease() 释放资源,根据 tryRelease() 的返回值来判断 tryRelease() 的返回值是否已经完成资源的释放工作,所以自定义同步器在设计的时候要考虑到这一点。
protected boolean tryRelease(int arg) { throw new UnsupportedOperationException(); }
和tryAcquire() 一样,tryRelease() 方法是需要独占模式下的自定义同步器自己去实现的。独占模式下的资源释放,只需要减掉响应量的资源即可(state-=arg),并不存在线程安全问题。但是需要注意 tryRelease() 的返回值,在自定义同步器时,如果已经彻底释放资源(state=0),就要返回true,否则返回false。
2.2.1、unparkSuccessor(Node)
private void unparkSuccessor(Node node) { // 如果结点状态是负数,清理状态值,允许失败或者被等待线程更改状态 int ws = node.waitStatus; if (ws < 0) // 置零当前线程所在结点的状态 compareAndSetWaitStatus(node, ws, 0); // 通常来说,待唤醒的线程就在后继结点中。但是如果后继结点被取消或者为null,就从队列尾部遍历知道找到真实的未取消的后继结点(waitState < 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); // 唤醒线程 }
简单来说,就是使用unpack() 唤醒等待队列中离队列头最近的那个有效线程,下面有s表示。结合acquireQueued() ,线程被唤醒之后,进入if(p == head && tryAcquire(arg)) 的判断,然后s 会把自己设置为head,表示已经获取到了资源,acquire也返回了。
2.3、acquireShared(int)
该方法是共享模式下的获取资源顶层入口。会尝试获取指定量的资源,如果获取成功则直接返回,获取失败则进入等待队列,直到获取到资源为止,整个过程忽略中断。
public final void acquireShared(int arg) { if (tryAcquireShared(arg) < 0) // 尝试获取资源 doAcquireShared(arg); // 获取资源失败,进入等待队列,直到获取到资源为止 }
tryAcquireShared 依然需要自定义同步器去实现,查看AQS对于tryAcquireShared() 接口的注释,可以看到AQS已经对该方法的返回值做了定义,实现自定义容器的时候需要注意。
- 负值:表示获取资源失败
- 0:代表本次获取资源成功,后续的共享模式的获取资源不会成功(没有剩余资源)
- 正数:表示获取成功,并且后续的共享模式的资源获取也可能会成功(有剩余资源)
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) { // 前驱结点是头结点,当前结点是第二顺位,此时node被唤醒,很可能是head使用完资源之后触发的 int r = tryAcquireShared(arg); if (r >= 0) { // 成功获取到资源,并且可能存在剩余资源 setHeadAndPropagate(node, r); // 将head指向自己,如果还有剩余资源可以再唤醒之后的线程 p.next = null; // 原来的头结点置为null,用于帮助GC if (interrupted) // 如果等待过程中被中断,此时执行中断 selfInterrupt(); failed = false; return; } } // 判断结点状态,寻找安全的休息点,等待unpark() 或 interrupt() if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) // 如果获取资源失败,取消正在进行的资源获取尝试 cancelAcquire(node); } }
整个doAcquireShared() 和 acquireQueued() 非常相似,只不过是将等待中线程中断的处理放到了函数中,而独占模式下,线程的中断处理是放在 acquireQueued() 之外的。
private void setHeadAndPropagate(Node node, int propagate) { Node h = head; setHead(node); // 将head指向当前结点 // 如果还有剩余偏移量,继续唤醒下一个邻居线程,这里使用waitStatus 的符号判断,是因为 waitStatus的状态也许被从 PROPAGATE 转换到了SIGNAL if (propagate > 0 || h == null || h. waitStatus < 0 || (h = head) == null || h.waitStatus < 0) { Node s = node.next; if (s == null || s.isShared()) doReleaseShared(); // 唤醒 } }
此方法在setHead() 的基础上增加了一步,自己获取资源的同事,如果符合条件,还会去唤醒后继结点。
还有一点需要注意,只有线程是第二顺位的时候,才会去尝试获取资源,有剩余资源的话还会尝试唤醒之后的队伍。假设1号释放了5个资源位,2号需要6个资源,3号需要1个,4号需要2个。
1号释放资源唤醒2号,但是2号资源不够,是够会让位给3,4号执行呢。这里是否定的,其实从上面的源码可以看到,如果资源不够,2号获取资源失败,返回值为负数,会寻找安全点,继续park(),只有有充足的资源位才会获取成功,有剩余继续唤醒。
这样会阻塞在2号位一段时间,这并不算是问题,只是AQS严格按照入队顺序唤醒罢了(保证公平,但是降低了并发)。
2.4、releaseShared(int)
共享模式下的资源释放,会通过
public final boolean releaseShared(int arg) { if (tryReleaseShared(arg)) { // 尝试释放资源 doReleaseShared(); // 唤醒后继结点 return true; } return false; }
改方法和独占模式下的 release() 相似,但有一点需要注意:独占模式下的tryRelease() 在完全释放资源(state=0)之后 ,才会返回true去唤醒其他线程,主要是基于独占锁可重入的考虑;共享模式下的 releaseShared() 没有这个要求,共享模式实际是控制一定量的线程进行并发执行,那么拥有资源的线程在释放部分资源之后就会唤醒后继的等待线程。但是 ReentrantReadWirteLock 读锁的 tryReleaseShared() 只有在完全释放掉资源(state=0)才会返回true,所以自定义同步器需要根据自身的需求决定 tryReleaseShared() 的返回值。
// 正常情况下唤醒head的后继结点是没问题的,但是如果不是,那么状态将被设置为 PROPAGATE 以保证在释放时,继续传播唤醒 private void doReleaseShared() { for (;;) { Node h = head; if (h != null && h != tail) { int ws = h.waitStatus; if (ws == Node.SIGNAL) { // 头结点状态是SIGNAL,将其设置为0 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; } }
2.5、小结
以上就独占和共享模式下,分别解析了获取-释放资源(acquire-release、acquireShared-releaseShared)的源码,相信对于AQS的实现机制也有了一定的了解。