AQS(队列同步器),是用来构建锁或者其他同步组件的基础框架,它实现了同步状态的管理,线程的排队,等待与唤醒等底层操作。AQS定义两种资源访问方式:Exclusive(独占,只有一个线程能执行,如ReentrantLock)和Share(共享,多个线程可同时执行,如Semaphore/CountDownLatch)。本篇将讲解AQS对独占锁的实现!
一:AQS设计思想
1.操作与规则分离原则
AQS的设计是基于模板方法模式的(ps:定义一个操作中算法的骨架,而将一些步骤延迟到子类中,模板方法使得子类可以不改变算法的结构即可重定义该算法的某些特定步骤),即操作和规则分离,通俗的讲,AQS定义了管理同步资源的步骤,并给出了线程获取不到资源时进入同步队列等待的操作,以及线程的唤醒与阻塞。而线程能否获取资源,如何获取资源等业务规则则交由子类实现。
有了上面的分析,我们下面通过AQS中定义的方法再具体分析AQS是如何实现操作与规则分离原则的!
同步器提供的部分模板方法:
final void acquire(int arg) 独占式获取同步状态,如果当前线程获取同步状态成功,则由该方法返回,否则,将会进入同步队列等待,该方法将会调用重写的tryAcquire方法。
final boolean release(int arg) 独占式的释放同步状态,该方法会在释放同步状态后,将同步队列中的第一个节点包含的线程唤醒。
final void acquireShared(int arg) 共享式的获取同步状态,如果当前线程未获取到同步状态,则进入同步队列等待,与独占式获取的主要区别是在同一时刻可以有多个线程获取到同步状态。
final boolean releaseShared(int arg) 共享的释放同步状态。
同步器交由子类重写的方法:
protected boolean tryAcquire(int arg) 独占式获取同步状态,实现该方法需要查询当前的同步状态并判断是否可以获取同步状态,如果可以获取,则进行CAS设置同步状态。
protected boolean tryRelease(int arg) 独占式的释放同步状态,等待获取同步状态的线程将有机会获取同步状态。
protected int tryAcquireShared(int arg) 共享式的获取同步状态,返回值大于等于0,表示获取成功,反之,获取失败
protected boolean tryReleaseShared(int arg) 共享的释放同步状态。
我们下面举个例子,说明AQS如何实现操作与规则分离!Reentrantlock相比大家都比较熟悉,Reentrantlock内部包含两种锁,公平锁和非公平锁,这两种锁就是AQS的子类,通过重写tryAcquire方法来实现公平和非公平获取。
公平锁:如果同步队列中还有线程在等待获取锁,那么新来的线程除非是重入锁,否则必须进入同步队列等待。
非公平锁:不管同步队列中是否还有线程在等待获取锁,新来的线程都可以尝试获取锁,获取不成功再进入同步队列等待。
如下图所示:
2.改进的CLH队列(同步队列)
AQS里面的CLH队列是CLH同步锁的一种变形。其主要从两方面进行了改造:节点的结构与节点等待机制。在结构上引入了头结点和尾节点,他们分别指向队列的头和尾,尝试获取锁、入队列、释放锁等实现都与头尾节点相关,并且每个节点都引入前驱节点和后后续节点的引用;在等待机制上由原来的自旋改成阻塞唤醒。如下图所示:
AQS同步队列中的节点在尝试获取锁失败后,加入同步队列自旋(死循环尝试获取锁),在自旋的过程中会判断前继节点的waitStatus状态(一共五种状态),如果前继节点waitStatus状态是signal,那么就将当前线程挂起,直到前继节点释放锁来唤醒它,或者超时中断退出争夺锁。
Node的waitStatus五种状态:
1.初始状态=0
2.CANCELLED = 1:说明节点已经 取消获取 lock 了(一般是由于 interrupt 或 timeout 导致的)很多时候是在cancelAcquire 里面进行设置这个标识
3.SIGNAL = -1:表示当前节点的后继节点需要唤醒
4.CONDITION = -2:当前节点在 Condition Queue 里面
5.PROPAGATE = -3:当前节点获取到 lock 或进行 release lock 时, 共享模式的最终状态是 PROPAGATE(PS: 有可能共享模式的节点变成 PROPAGATE 之前就被其后继节点抢占 head 节点, 而从Sync Queue中被踢出掉)
waitStatus的状态变化:
1.线程进入同步队列中,首先尝试获取锁,获取失败,则将其前继节点标记为SIGNAL,然后再使用tryAquire尝试获取锁。
2.若调用tryAquire方法获取失败,则判断前继节点waitStatus是否为SIGNAL,若是的话,直接block,block前会确保前继节点被标记为SIGNAL, 因为前继节点在进行释放锁时根据是否标记为 SIGNAL 来决定唤醒后继节点与否。注意1:如果节点A刚被标记为SIGNAL,就释放了锁,并使用LockSupport.unpark()唤醒节点B中的线程,此时节点B发现节点A标记为SIGNAL就调用LockSupport.park()睡眠,那么B会直接唤醒而不会沉睡。这里涉及到LockSupport的park()和unpark()次序问题。
3.前继节点释放锁后,如果waitStatus是SIGNAL,就唤醒其后继节点。并将waitStatus置为0。
注意2:2和3可能会多次反复。即SIGNAL→0→SIGNAL→0。比如在非公平锁中,同步队列中head节点释放锁后,并唤醒后继节点(SIGNAL→0),此时新来的线程非公平的抢占了锁,而head节点的后继节点去尝试获取锁,而此时锁已经被抢占了,那么就会将head节点的waitStatus再次设置为SIGNAL(0→SIGNAL)。
二:源码分析
锁的获取
通过调用同步器的acquire(int arg)方法获取同步状态,该方法对中断不敏感,也就是由于线程获取同步状态失败后进入同步队列,后续对线程进行中断操作时,线程不会从同步队列中移除。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
1.调用tryAquire方法(由子类实现)尝试获取锁,如果成功,则直接返回。
2.获取失败就调用addWaiter将当前线程封装成Node加入到同步队列。
3.调用acquireQueued,在同步队列中采用自旋的方式获取锁,有可能会多次block和unblock,参见上面注意2。
4.根据acquireQueued的返回值判断在获取lock的过程中是否被中断, 若被中断, 则自己再中断一下(selfInterrupt)。
//注意:该入队方法的返回值就是新创建的节点
private Node addWaiter(Node mode) {
//基于当前线程,节点类型(Node.EXCLUSIVE)创建新的节点
//由于这里是独占模式,因此节点类型就是Node.EXCLUSIVE
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
//这里为了提搞性能,首先执行一次快速入队操作,即直接尝试将新节点加入队尾
if (pred != null) {
node.prev = pred;
//这里根据CAS的逻辑,即使并发操作也只能有一个线程成功并返回,其余的都要执行后面的入队操作。即enq()方法
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
//完整的入队操作
private Node enq(final Node node) {
for (;;) {
Node t = tail;
//如果队列还没有初始化,则进行初始化,即创建一个空的头节点
if (t == null) {
//同样是CAS,只有一个线程可以初始化头结点成功,其余的都要重复执行循环体
if (compareAndSetHead(new Node()))
tail = head;
} else {
//新创建的节点指向队列尾节点,毫无疑问并发情况下这里会有多个新创建的节点指向队列尾节点
node.prev = t;
//基于这一步的CAS,不管前一步有多少新节点都指向了尾节点,这一步只有一个能真正入队成功,其他的都必须重新执行循环体
if (compareAndSetTail(t, node)) {
t.next = node;
//该循环体唯一退出的操作,就是入队成功(否则就要无限重试)
return t;
}
}
}
}
这里注意:一旦线程进入到同步队列中去,就会严格按照FIFO的顺序获取锁,前继节点没获取到锁(不包括取消节点),后面的节点不可能获取锁。
ok,下面就到了AQS实现独占方式获取锁的关键方法acquireQueued。
final boolean acquireQueued(final Node node, int arg) {
//1.锁资源获取失败标记
boolean failed = true;
try {
//2.获取锁的过程中线程是否被中断的标记
boolean interrupted = false;
//3.自旋获取锁
for (;;) {
//4.当前节点的前继节点
final Node p = node.predecessor();
//5.如果前继节点是头节点,采取尝试获取锁
if (p == head && tryAcquire(arg)) {
//6.获取锁成功,将当前节点设置为头节点,注意,这里已经获取锁了,不存在并发,所以不用CAS设置头节点。
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
//7.如果获取锁失败,就调用shouldParkAfterFailedAcquire方法修改前继节点的WaitStatus,并判断
//前继节点的waitStatus是否为SIGNAL,如果是,就调用parkAndCheckInterrupt阻塞当前线程。后面详
//细讲
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
//8.如果退出了自旋for循环,说明发生了异常(包括中断和超时,当然这个方法不支持中断和超时),
//最后会执行这里的逻辑,将取消当前节点,将当前节点的waitStatus设置为CANCELLED
if (failed)
cancelAcquire(node);
}
}
第6点:如果当前线程获取到锁,那么设置头节点就不需要使用CAS设置头节点,因为这是独占方式获取锁,只有一个线程会获取到锁,其他线程都在等待。
第7点:线程自旋的方式获取锁的过程会多次block和unblock,这里涉及到前继节点的waitStatus状态变化过程。这里有点绕,当时就想了好久,所以这里需要多想想,提出问题解决问题。
假设存在这种情况,头节点A正在独占锁,头节点A的后继节点B正在等待获取锁,此时新来了一个节点C。下面分公平和非公平来分析锁的获取过程。
公平锁
在公平锁模式下,新来的节点C发现有同步队列在排队,那么就必须加入同步队列,即在节点B的后面。那么会发生如下几种情况。
情况一:
情况二:
情况三:
这里注意情况一和情况三区别 ,情况一在A释放锁之前睡眠了,情况三是B设置为节点A的waitStatus为SIGNAL之后,再自旋一次发现此时A刚好释放了独占锁,B直接获取独占锁成功!因为线程会来回切换。
非公平锁
在非公平锁模式下,不论是否有同步队列再排队等待获取独占锁,新来的节点C都会先尝试获取一下独占锁,如果获取失败再加入同步队列等待获取独占锁,加入同步队列之后就和上面的的三种情况一样,严格按照FIFO获取独占锁。
之前说过在执行acquireQueue的过程中,线程可能会多次block和unblock,就是发生在非公平锁情况下,新来的线程抢占了独占锁,导致头节点的后继节点多次block和unblock。
如下图所示:
当前继节点A是头节点,且waitStatus是SIGNAL,所以下一节点安心的睡眠,当头节点释放锁,发现自己waitStatus是SIGNAL,就会唤醒后继节点B,但如果是不公平锁,那么新来的线程C可能就会直接抢占头节点释放的锁,而不用入同步队列等待,此时刚被头节点A唤醒的线程B就会再次将头节点的waitStatus设置为SIGNAL,然后再自旋一次,还是没有获取到锁,然后判断头节点waitStatus,发现是SIGNAL,就又睡眠了。当C释放锁后,会执行release方法(后面详细讲),该方法会查看头节点的waitStatus,如果是SIGNAL,C就会唤醒后继节点B。
这里涉及到LockSupport的park和unpark方法的先后调用次序问题,LockSupport的park和unpark方法无序特性可参考我另一篇文章。
//首先说明一下参数,node是当前线程的节点,pred是它的前置节点
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
//获取前置节点的waitStatus
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
//如果前置节点的waitStatus是Node.SIGNAL则返回true,然后会执行parkAndCheckInterrupt()方法进行挂起
return true;
if (ws > 0) {
//由waitStatus的几个取值可以判断这里表示前置节点被取消
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
//这里我们由当前节点的前置节点开始,一直向前找最近的一个没有被取消的节点
//注,由于头结点head是通过new Node()创建,它的waitStatus为0,因此这里不会出现空指针问题,也就是说最多就是找到头节点上面的循环就退出了
pred.next = node;
} else {
//根据waitStatus的取值限定,这里waitStatus的值只能是0或者PROPAGATE,那么我们把前置节点的waitStatus设为Node.SIGNAL然后重新进入该方法进行判断
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
上面这个方法逻辑比较复杂,它是用来判断当前节点是否可以被挂起,也就是唤醒条件是否已经具备,即如果挂起了,那一定是可以由其他线程来唤醒的。该方法如果返回false,即挂起条件没有完备,那就会重新执行acquireQueued方法的循环体,进行重新判断,如果返回true,那就表示万事俱备,可以挂起了,就会进入parkAndCheckInterrupt()方法看下源码:
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
//被唤醒之后,返回中断标记,即如果是正常唤醒则返回false,如果是由于中断醒来,就返回true
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;
}
tryRelease()方法是用户自定义的释放锁逻辑,如果成功,就判断等待队列中有没有需要被唤醒的节点(waitStatus为0表示没有需要被唤醒的节点),一起看下唤醒操作:
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
//把标记为设置为0,表示唤醒操作已经开始进行,提高并发环境下性能
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
//如果当前节点的后继节点为null,或者已经被取消
if (s == null || s.waitStatus > 0) {
s = null;
//注意这个循环没有break,也就是说它是从后往前找,一直找到离当前节点最近的一个等待唤醒的节点
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
//执行唤醒操作
if (s != null)
LockSupport.unpark(s.thread);
}
相比而言,锁的释放操作就简单很多了,代码也比较少。
三:总结
通过上面的分析,我们会发现AQS实现了非常复杂的同步资源获取和释放、线程的唤醒与阻塞,同步队列的维护等负责的操作,而具体的业务规则,比如如何实现公平与非公平锁,读写锁等具体的业务规则则交由子类去实现,而AQS提供了一系列的基础支持,子类只需要调用即可,非常方便强大。由此可见AQS的设计者思想的强大之处。之前也写过一篇AQS的文章,但那时正在准备面试,时间很仓促,只能大体上理解AQS,现在正好有时间,于是重新分析了AQS的实现细节,从中也收获了很多,从看源码发现很多理解不同的问题,到最后一一豁然开朗。我会在后面继续分析AQS的共享锁实现等内容。
9.MySQL全文索引、联合索引、like查询、json查询速度大比拼
欢迎关注我的公众号,和我一起每天进步一点点