AQS(AbstractQueuedSynchronizer)抽象队列式同步器,从它的名字中,我们可以看出AQS本身是一个同步器,也就是发生资源竞争时,用来同步的工具。它的内部使用了队列来进行实现,而之所以叫“抽象”,是因为AQS只是一个“框架”,它提供了当发生资源竞争时,竞争失败的线程入队、出队等的方式,但没有描述竞争的具体细节,这些交由其子类来按需具体实现,AQS在JUC中许多大名鼎鼎的类中得到应用,例如ReentrantLock,CountDownLatch,Semaphore等等,可以说,AQS是JUC的基石,搞懂了AQS,就能轻松理解JUC中各种锁的api。
一. 概念
AQS的模型:
AQS在内部维护定义了一个内部类Node,用它来构建一个FIFO的双向链表,分别有Head和Tail来指向链表头尾,基于这样一套数据结构,形成了基于逻辑队列非线程饥饿的一种自旋公平锁,称为CLH锁。(由于是 Craig、Landin 和 Hagersten三位大佬的发明,因此命名为CLH锁,CLH锁是一种基于链表的可扩展、高性能、公平的自旋锁,申请线程只在本地变量上自旋,它不断轮询前驱的状态,如果发现前驱释放了锁就结束自旋)。如果说AQS是JUC的基石,那么CLH就是AQS的基石。
AQS将资源的共享方式定义为两种:1.Exclusive 独占,即只有一个线程可以访问资源。2. Share 共享,即允许同时有X个线程访问资源。AQS使用一个被volatile修饰的int变量state来代表资源。
针对两种共享方式,子类需要继承AQS,并分别实现不同的接口,主要是如下几个:
Exclusive:
tryAcquire: 尝试获取资源,成功返回true,失败返回false。
tryRelease: 尝试释放资源,成功返回true,失败返回false。
Share:
tryAcquireShared: 尝试获取资源,返回负数表示失败,0表示成功,但没有资源可用,正数表示成功,而且有剩余资源。
tryReleaseShared: 尝试释放资源,如果释放后允许唤醒后需等待的线程,则返回true,否则返回false。
以上四个方法,都有一个int参数,AQS没有明确说明这个参数的含义,可以由子类来定义。一般来讲,对于一个继承AQS的锁(同步器)而言,要么实现独占锁,要么实现共享锁,但也有同时实现这两种的,如ReentrantReadWriteLock。为了减轻开发者不必要的工作,上述的四个方法都没有设计成抽象方法,也就是说子类只需要按需实现即可,对于子类未实现,却又被调用了的方法,AQS默认会抛出一个UnsupportedOperationException异常。
典型的独占锁是ReentrantLock,state初始时默认为0,表示资源还没有被获取,当有一个线程调用lock时,lock底层会调用tryAcquire,即独占该资源,并将state + 1,此后这个线程可以重复lock,state也会递增,只有当这个线程调用unlock(底层是tryRelease)至state为0时,其他线程才有机会获得这个资源,这就是可重入的概念,所以ReentrantLock称为可重入锁,它的本质仍是一个独占锁。
下边,我们分别从独占模式和共享模式来描述AQS的执行原理。
二. 独占模式
-
acquire
acquire是独占模式下,AQS用来尝试获取资源的方法,可以想象的是,它的实现一定是调用了上文提到的tryAcquire:
public final void acquire(int arg) {
if(!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这段代码的主要流程与含义如下:
1.调用tryAcquire尝试获取资源,如果获取成功,这个方法会直接返回。
2.如果获取失败,则首先调用addWaiter,以独占模式将当前线程包装成一个Node,将它放置到CLH的末尾。
3.再调用acquireQueued,不断自旋尝试获取资源,直到获取成功,当获取成功时,会返回false,此时整个方法也会直接返回。但当自旋过程中发生线程中断时,不会立即响应中断,而是在获取资源成功后,返回一个true,此时selfInterrupt被激活,自行补一个中断。
下面我们细节看一下上边用到的addWaiter,acquireQueued方法:
addWaiter:
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(node);
return node;
}
这里首先将当前线程按照执行模式构造成了一个Node,然后先尝试通过快速的方式将Node加入队尾,当快速加入失败后,则会调用enq方法进行加入,最后返回当前线程所在的Node。下面看enq方法的实现:
private Node enq(final Node node)
{
for (;;)
{
Node t = tail;
if (t == null)
{
if (compareAndSetHead(new Node()))
tail = head;
}
else
{
node.prev = t;
if (compareAndSetTail(t, node))
{
t.next = node;
return t;
}
}
}
}
这是一段非常经典的用法,即CAS自旋volitale变量,首先判断CLH的tail是否为null,如果是null就初始化一个进去。这样做,是因为在AQS中,需要依赖前驱节点将自己唤醒,如果没有前驱,当前节点会永远无法唤醒。再次自旋时,就可以将当前Node放置在新创建的tail后,为防止高并发下tail可能变化的问题,这里通过compareAndSetTail先尝试将Node加入tail后,如果失败了,意味着有线程抢先变化了tail,下一次自旋会再次获取新的tail,再次尝试加入队尾,直到成功。类似的用法可以参考AtomicInteger.getAndIncrement()。compareAndSetTail是Unsafe类里提供的一个方法,底层是native函数,通过CPU原语实现的CAS操作。
通过以上步骤,我们的线程由于没有拿到资源,已经被包装成一 个Node加入了CLH的尾部,下边要做的事就是等待前置线程释放资源、自己被唤醒了,即acquireQueued方法做的事:
final boolean acquireQueued(final Node node, int arg)
{
boolean failed = true;// 标记是否成功拿到资源
try
{
boolean interrupted = false;// 标记等待过程中是否被中断过
for (;;)
{
final Node p = node.predecessor();// 拿到前驱
// 如果前驱是head,即该结点已成老二,那么便可以尝试获取资源(因为此时head可能已经释放了资源)。
if (p == head && tryAcquire(arg))
{
setHead(node);// 拿到资源后,将head指向该结点,同时会将node.prev置为null。
p.next = null; // 再将head.next置为null,就是为了方便GC回收以前的head结点。也就意味着之前拿完资源的结点出队了
failed = false;
return interrupted;// 返回等待过程中是否被中断过
}
// 如果自己可以休息了,就进入waiting状态,直到被unpark()
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;// 如果等待过程中被中断过,就将interrupted标记为true
}
}
finally
{
if (failed)
cancelAcquire(node);
}
}
在梳理整个流程前,我们还应该了解下shouldParkAfterFailedAcquire方法和parkAndCheckInterrupt方法:
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node)
{
int ws = pred.waitStatus;// 拿到前驱的状态
if (ws == Node.SIGNAL)
// 如果前驱已经是SIGNAL状态,那就可以安心休息了
return true;
if (ws > 0)
{
/*
* 如果前驱放弃了,那就一直往前找,直到找到最近一个正常等待的状态,并排在它的后边。
* 注意:那些放弃的结点,由于被自己“加塞”到它们前边,它们相当于形成一个无引用链,稍后就会被GC
*/
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()使线程进入waiting状态
return Thread.interrupted();// 如果被唤醒,查看自己是不是被中断的。
}
可见,这两个方法的作用就是向前寻找一个最近的、状态为SIGNAL的节点,将自己排在它的后边,然后将当前线程置为等待状态,如果被唤醒,会确认下是不是被中断的。关于这个状态,我们目前只需要知道,Node有四种状态,当当前Node的状态为SIGNAL,在他释放资源时,会唤醒其最近的、未放弃的一个后续节点(unpark),这意味着排队中的节点,是可能因为各种原因放弃的,但他们仍在队伍中。
看完这两个方法,我们就可以梳理acquireQueued的流程了:
1.调用tryAcquire尝试获取资源,成功则就直接返回,不成功则调用addWaiter将线程包装成一个Node排在CLH队尾。
2.调用acquireQueued在CLH中找到一个最近的安全点(即状态为SIGNAL的节点),把自己排在它后边,然后将线程状态置为waiting,即开始休息。
3.一旦线程被唤醒(排到第二名了,或者被中断了),就尝试去竞争资源,获取到资源后才会返回。如果整个等待过程中线程被中断过,则返回true,反之返回false。
4.如果线程等待过程中被中断过,拿到资源后会把中断补上。
梳理下acquire整个流程:
实际上,这也正是ReentrantLock.lock()的流程,整个lock函数,其实就是一个acquire(1)。
- release
release是AQS中用于释放资源的操作,他是acquire的反操作,如果资源彻底被释放了(state = 0)它会唤醒后继节点。
下边是release源码:
public final boolean release(int arg)
{
if (tryRelease(arg))
{
Node h = head;// 找到头结点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);// 唤醒等待队列里的下一个线程
return true;
}
return false;
}
首先通过调用tryRelease来实现释放资源,如果返回true,则认为释放成功,尝试唤醒后续节点。需要注意的是,如果子类是可重入锁,那么一定要确认state = 0时才能返回true!否则就会出现,明明可重入锁还没有完全释放,后续节点却被唤醒了的情况。
这里我们唯一需要看细节的,是unparkSuccessor方法,它用于在当前节点释放资源后,唤醒后续节点:
private void unparkSuccessor(Node node)
{
// node一般为当前线程所在的结点。
int ws = node.waitStatus;
if (ws < 0)// 置零当前线程所在的结点状态,允许失败。
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;// 找到下一个需要唤醒的结点s
if (s == null || s.waitStatus > 0)
{// 如果为空或已取消
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)// <=0的结点,都是还有效的结点。
s = t;
}
if (s != null)
LockSupport.unpark(s.thread);// 唤醒
}
这里的逻辑并不复杂,就是用unpark唤醒最前边的有效节点,需要注意的是,它是从tail开始向前寻找的,直到找到那个离当前节点最近的、有效的节点。被唤醒以后,就会继续走acquireQueued的逻辑了,一般来讲,这个有效节点就是当前节点的next,如果真的出现中间有无效节点,也没关系,有效节点被唤醒后,会再次尝试向前找安全点,就会把自己放置到当前节点后,下一轮自旋时,就会被顺利激活。
三.共享模式
1.acquireShared
这是共享模式下,用于获取资源的方法,获取成功则直接返回,失败则进入队列:
public final void acquireShared(int arg)
{
if (tryAcquireShared(arg) < 0)
doAcquireShared(arg);
}
tryAcquireShared同样是需要子类自己实现的,但这里AQS其实定义好了返回值的语意,负数代表失败,0代表获取成功但没有剩余资源,正数代表成功且有剩余资源。这里我们唯一需要看的是doAcquireShared,它对应独占模式下的acquireQueued方法。
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)
{// 如果到head的下一个,因为head是拿到资源的线程,此时node被唤醒,很可能是head用完资源来唤醒自己的
int r = tryAcquireShared(arg);// 尝试获取资源
if (r >= 0)
{// 成功
setHeadAndPropagate(node, r);// 将head指向自己,还有剩余资源可以再唤醒之后的线程
p.next = null; // help GC
if (interrupted)// 如果等待过程中被打断过,此时将中断补上。
selfInterrupt();
failed = false;
return;
}
}
// 判断状态,寻找安全点,进入waiting状态,等着被unpark()或interrupt()
if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt())
interrupted = true;
}
}
finally
{
if (failed)
cancelAcquire(node);
}
}
可以看到和acquireQueued区别不大,只有两点差异:1.在竞争资源时,会调用setHeadAndPropagate,唤醒自己后,如果还有剩余资源,则会继续唤醒后续节点。2.处理中断的位置与acquireQueued不同。
我们重点看下setHeadAndPropagate函数:
private void setHeadAndPropagate(Node node, int propagate)
{
Node h = head;
setHead(node);// head指向自己
// 如果还有剩余量,继续唤醒下一个线程
if (propagate > 0 || h == null || h.waitStatus < 0)
{
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared();
}
}
可以看出,流程上是先唤醒自己,再唤醒后续节点,也就是说,如果当前剩余资源不足以唤醒自己,即便能够满足后续线程使用,也不会跳过当前线程,而是严格按照FIFO。doReleaseShared方法,则是唤醒后继节点的方法,我们把它放在releaseShared方法中讲。
2.releaseShared
这是共享模式下释放资源的方法,是acquireShared的反操作:
public final boolean releaseShared(int arg)
{
if (tryReleaseShared(arg))
{// 尝试释放资源
doReleaseShared();// 唤醒后继结点
return true;
}
return false;
}
同样的逻辑,先调用子类的tryReleaseShared尝试释放资源,释放成功后,调用doReleaseShared唤醒后继节点。
跟独占模式下的release()相似,但有一点稍微需要注意:独占模式下的tryRelease()在完全释放掉资源(state=0)后,才会返回true去唤醒其他线程,这主要是基于独占下可重入的考量;而共享模式下的releaseShared()则没有这种要求,共享模式实质就是控制一定量的线程并发执行,那么拥有资源的线程在释放掉部分资源时就可以唤醒后继等待结点。例如,资源总量是13,A(5)和B(7)分别获取到资源并发运行,C(4)来时只剩1个资源就需要等待。A在运行过程中释放掉2个资源量,然后tryReleaseShared(2)返回true唤醒C,C一看只有3个仍不够继续等待;随后B又释放2个,tryReleaseShared(2)返回true唤醒C,C一看有5个够自己用了,然后C就可以跟A和B一起运行。
但这也不是绝对的,如ReentrantReadWriteLock中读锁的tryReleaseShared()只有在完全释放掉资源(state=0)才返回true,所以自定义同步器可以根据需要决定tryReleaseShared()的返回值。
下边看下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;
unparkSuccessor(h);// 唤醒后继
}
else if (ws == 0 && !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue;
}
if (h == head)// head发生变化
break;
}
}
与独占模式差不多。