如果说CAS操作,是J.U.C包的灵魂,那么AbstractQueuedSynchronizer
(抽象队列同步器,简称AQS),就是J.U.C包的骨架,基于AQS,J.U.C包得以实现了经典的重入锁、读写锁、CountDownLatch(计数锁)、Semaphore(信号量)和FutureTask这种实现了异步回调机制的类。
如果文章中由任何不妥或者谬误之处,请批评指正。
首先AQS内部维护了一个变形的CLH队列,一个基于AQS实现的同步器,这个同步器管理的所有线程,都会被包装成一个结点,进入队列中,所有所有线程结点,共享AQS中的state
(同步状态码)。
AQS中的state
状态码是一个volatile
变量,而对状态码的修改操作,全部都是CAS操作,这样就保证了多线程间对状态码的同步性,这种方式也是我们之前所说的CAS常用的操作模式。
一个时间段内,只能有一个线程可以操作共享资源,这就是独占模式。我们常见的同步锁就是一种独占模式。
AQS定义两种资源共享方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。
AQS中有四个核心的顶层入口方法:
acquire(int)
、release(int)
、acquireShared(int)
、releaseShared(int)
以AQS为基础实现的同步器类,只需要合理使用这些方法,就可以实现需要的功能。
显而易见:acquire(int)
、release(int)
是独占模式中的方法。
而acquireShared(int)
、releaseShared(int)
是共享模式中的方法。
首先来看acquire(int)
这个入口方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
首先,这个方法的根本意思是尝试获得共享资源的操作权
分析代码逻辑,如果tryAcquire(int)
方法返回了true
,那么后续的代码就不会执行,也就是说直接回跳转到整个方法结束,那么tryAcquire(int)
又是什么方法呢?
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
可以看到tryAcquire(int)
方法只抛出了一个异常,实际上,这个方法AQS并不会实现,而是它的具体实现类来完成这个方法,也就是说,不同子类对于实现这个方法可以有不同的逻辑,但是需要注意的是,无论什么逻辑去实现这个方法,如果成功获得了共享资源的操作权,那么一定要返回true
,否则返回false
。
再回头来看acquire(int)
这个方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
显然,如果tryAcquire(arg)
返回了false
,也就是说当前线程没有直接获取共享资源的操作权,那么根据逻辑,就会执行addWaiter(Node.EXCLUSIVE)
方法。
private Node addWaiter(Node mode) {
// 新建了一个含有当前线程对象的改造CLH队列节点
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(Node)方法入队
enq(node);
return node;
}
再来看一下enq(Node)
方法
private Node enq(final Node node) {
for (;;) { // CAS操作自旋,基本操作
Node t = tail;
if (t == null) { // 队尾必须初始化
if (compareAndSetHead(new Node()))
// 如果队列还没有初始化,就采用CAS的方式构建队列
// CAS保证了多线程间数据一致性(不会同时创建多个队列)
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
// 如果队列已经初始化,就采用CAS的方式添加结点到队尾
// CAS在这保证了不会出现两个结点同时连接到同一个结点后面
t.next = node;
return t;
}
}
}
}
综上所述,可以看出addWaiter(Node.EXCLUSIVE)
方法是用来把一个竞争资源失败的线程,包装成一个独占模式的结点,然后添加到CLH队列中,同时其中的CAS操作,避免了多线程并发操作带来的数据不一致问题
入队后的结点,会作为参数,传给acquireQueued(final Node node, int arg)
方法。
这个方法的实现十分的精妙,可以说是整个获取资源操作的核心。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false; // 中断标志位,如果被中断唤醒则为true
for (;;) {
// 首先获取传入结点的前一个结点
final Node p = node.predecessor();
// 如果前一个结点是头结点,那么就说明,这次节点有机会竞争到共享资源
// 所以尝试竞争共享资源,如果竞争失败,则说明头结点还没有释放资源
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false; // 成功获取资源
return interrupted;
}
// 如果当前线程的节点处于队列中,会有两种情况
// 1.前一个结点不是头结点,则说明自己在等待队列中,则判断是否可以休眠
// 2.如果前一个结点是头结点,但竞争资源失败,也判断是否可以休眠
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
到这里,可能你会发现一些问题,AQS确实提供了线程等待队列,线程获取共享资源操作权,以及线程包装成CLH节点入队等操作,但是作为一个同步器,最核心的功能就是让没有获得共享资源操作权的线程进入等待状态(阻塞,挂起都可,只不过AQS中是使用了JVM中的线程等待状态)。
而shouldParkAfterFailedAcquire
方法就会做到我们期望的去睡眠线程。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 首先获取前一个节点的状态码
int ws = pred.waitStatus;
// 如果前一个节点处于SIGNAL状态,则说明可以安全的休眠该节点包含的线程
if (ws == Node.SIGNAL)
return true;
if (ws > 0) { // 如果状态码大于0,则说明前面的节点已经处于无效状态
do { // 这个循环会把当前节点不断前移,直到它前面的节点处于有效状态
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 使用CAS操作把这个节点的状态码置为SIGNAL
// 这样以来,后面的节点就能继续连接到该节点
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
再回头来看自旋里的这段代码,我们已经知道,shouldParkAfterFailedAcquire
是用来确保某个节点内的线程可以安全的休眠,同时起到了一个整理CLH队列的作用。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
如果shouldParkAfterFailedAcquire
返回了false
,则不会进入parkAndCheckInterrupt
方法,因为此时并不能休眠线程,但是如果返回true
,则会直接休眠这个线程。
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 直接让当前线程进入等待状态
return Thread.interrupted(); // 返回是否被中断唤醒
}
至此,我们对线程竞争资源,以及竞争资源失败以后的入队,乃至入队以后线程的休眠,已经有了一个了解。
还是从顶层接口开始分析
public final boolean release(int arg) {
if (tryRelease(arg)) { // 尝试直接释放资源
Node h = head; // 因为占用资源的一定是队列中头节点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 进行实际意义上的解锁操作
return true;
}
return false;
}
释放资源的过程相比起获得资源,逻辑上要简单很多,而且代码也便于理解。
我们来看一下唤醒线程的关键方法,unparkSuccessor
private void unparkSuccessor(Node node) {
/*
* If status is negative (i.e., possibly needing signal) try
* to clear in anticipation of signalling. It is OK if this
* fails or if status is changed by waiting thread.
*/
int ws = node.waitStatus;
if (ws < 0) // 清除了该节点的状态码,这个节点由回到了初始化状态
compareAndSetWaitStatus(node, ws, 0);
/*
* Thread to unpark is held in successor, which is normally
* just the next node. But if cancelled or apparently null,
* traverse backwards from tail to find the actual
* non-cancelled successor.
*/
Node s = node.next; // 获取头节点后的下一个节点
if (s == null || s.waitStatus > 0) {// 如果节点为null或者已经失效(取消
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);
}
这时候,我们已经唤醒了下一个有效的节点的线程对象,这个线程等待时,是阻塞在acquireQueued
方法内的自旋for循环
,在回到acquireQueued
方法后,此时该线程发现,自己已经是头节点后面的节点了。于是又去tryAcquire
尝试获取资源,这次它就可以顺利获取共享资源了(因为头节点所含的线程释放了资源的使用权)
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) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
以上就是独占模式下的释放资源过程,可以看出,释放资源以后,后面被阻塞进入等待状态的线程,又要回到获取资源的方法中,这种设计完美的保证了多线程间不会出现共享数据的访问问题,而事实上ReentrantLock
就是完全使用了这种独占模式的AQS设计,只不过自己依据AQS状态码实现了可重入。
所谓共享模式,就是多个线程可以同时对一个资源进行操作,你可能说这样会出现数据不一致的问题,但是往往涉及到数据读的操作,才会使用共享模式,但是涉及到写数据,就需要独占模式来实现了。
了解了独占模式下的操作,共享模式下的操作就变得简答了许多。
首先还是从顶层入口方法看起。acquireShared
方法用来在共享模式下尝试获取资源。
public final void acquireShared(int arg) {
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
这个方法很容易理解,tryAcquireShared
也是一个由子类重写的方法,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);
}
}
独占锁模式获取成功以后设置头结点然后返回中断状态,结束流程。而共享锁模式获取成功以后,调用了setHeadAndPropagate
方法,从方法名就可以看出除了设置新的头结点以外还有一个传递动作,一起看下代码:
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node); // 将这个节点设置为头节点
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
// 这里体现出了共享模式的概念,如果propagate > 0 ,说明后继节点也要唤醒
// 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) {
//这里需要控制并发,因为入口有setHeadAndPropagate跟release两个,避免两次unpark
if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
continue;
//执行唤醒操作
unparkSuccessor(h);
}
//如果后继节点暂时不需要唤醒,则把当前节点状态设置为PROPAGATE确保以后可以传递下去
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
//如果头结点没有发生变化,表示设置完成,退出循环
//如果头结点发生变化,比如说其他线程获取到了锁,为了使自己的唤醒动作可以传递,必须进行重试
if (h == head)
break;
}
}
也就是说setHeadAndPropagate
方法会重新设置一个头,然后doReleaseShared
会从头向后遍历,如果是处于共享模式的节点,都会唤醒。
了解了这个以后,再回头看releaseShared
就很简单了。
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
上面的setHeadAndPropagate()方法表示等待队列中的线程成功获取到共享锁,这时候它需要唤醒它后面的共享节点(如果有),但是当通过releaseShared()方法去释放一个共享锁的时候,接下来等待独占锁跟共享锁的线程都可以被唤醒进行尝试获取。