前言
最近比较闲,就打算阅读学习一下java并发相关的内容,主要平常自己涉及不到高并发的业务,所以这一块也是短板,本篇文章主要是介绍AbstractQueuedSynchronizer(简称AQS)的独占模式和共享模式下的获取、释放资源的操作过程。阅读前题:
1. 了解CAS是干嘛的
2. 熟悉双向链表,最好是自己写过
3. 对多线程知识点有基本的认识,像锁、资源这些概念名称。
以上这些主要是便于去理解代码,AQS本身细节部分很多,阅读时开始先知道个大概就行,不可能一蹴而就,篇幅较长,耐心思考推敲,有什么错误的地方也欢迎大家指出。
一、AbstractQueuedSynchronizer是什么?
JDK在1.5时引入了java.util.concurrent并发包,里面相关的类给java的并发编程带来了不少便利,例如ReentrantLock、ReentrantReadWriteLock、CountDownLatch等,这些常用的类的实现都涉及到了一个核心类,即AbstractQueuedSynchronizer(简称AQS),人如其名,名称直译过来就是抽象队列式同步器,AQS定义了一套多线程访问共享资源的同步器框架(不是Spring这种级别框架)。
二、主体构造
AQS主要是维护了一个state(共享资源)和一个FIFO线程等待队列(也就是CLH队列)。
在AQS中共享资源state操作方式主要是CAS,队列用来维护竞争资源的线程对象,队列头是抢到资源的线程,队列中剩余的是未抢到资源的线程。
对于共享资源的共享方式,AQS定义了两种:SHARED(共享模式)和EXCLUSIVE(独占模式),对于资源state的获取释放,是在自定义的同步器中定义的,AQS中本身没做,所以下面就通过对于这两种模式下关于state的获取释放做一个简单的介绍。
三、源码
1、队列节点
首先得先看下队列的节点对象,通过节点对象封装了线程、等待状态等。
static final class Node {
volatile int waitStatus;
volatile Thread thread;
//...
}
waitStatus的状态值在类中已经定义好了:
- 0:新结点的默认状态。
- CANCELLED(1):表示当前结点中的线程已取消调度。例如timeout或被中断会触发变更为此状态,变更成此状态后将会被移出队列。
- SIGNAL(-1):表示当前节点的后继节点中线程处于等待状态时,如果当前节点线程释放了资源或者中断,那么后继节点线程就会唤醒。
- CONDITION(-2):表示结点线程等待其他线程调用Condition的signal()方法,CONDITION状态的结点线程将从等待队列转移到同步队列中,等待获取同步锁。
- PROPAGATE(-3):共享模式下,同步状态的获取将会无条件的传播,也就说会唤醒多个后继节点中的线程。
2、独占模式
独占模式就是指只能一个线程运行,其他线程阻塞,例如ReentrantLock,获取和释放对应的方法为:acquire(int arg)、boolean release(int arg)。
2.1 acquire(int arg)
这个方法对应独占模式下获取资源操作,忽略中断操作。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这里面主要四个方法对应四步:
- tryAcquire:尝试获取资源,若获取到那么acquire就直接结束返回;
- addWaiter:若上面获取资源失败,则将当前线程添加到等待队列,标记为独占模式;
- acquireQueued:让线程阻塞,并且当被唤醒后会尝试让线程获取资源,直到获取才返回;
- selfInterrupt:线程阻塞等待过程中不会响应中断,当获取到资源被唤醒后,如有中断则再通过此方法将中断标识补上
2.1.1 tryAcquire
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
此方法总是由执行 acquire 的线程来调用,尝试去获取资源,成功返回true,未获取到就返回false。这个方法在AQS类中并未做具体逻辑,需要其子类自行设计,毕竟每个自定义的同步器逻辑是不一样的,而且该方法没有设计成抽象方法,这样就避免子类强制去实现该方法。
2.1.2 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;
}
addWaiter方法就是当前面线程获取资源失败后,包装成Node对象添加到等待队列中。在if中会尝试用CAS添加至队尾,如果成功了就会把当前节点返回,失败的话就进入enq方法中添加。下面看qnr方法:
private Node enq(final Node node) {
// CAS自旋
for (;;) {
Node t = tail;
if (t == null) {
// 若队列为空,创建一个空节点,head和tail都指向该节点,然后接着循环插入目标节点
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 目标节点入队列,也是用到了CAS
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
2.1.3 acquireQueued
添加到队列后,下一步动作就是让线程停下来进入等待状态,然后等到下次被唤醒后再次尝试获取资源,如果获取到了那就继续干活。这个方法属于一个重点,线程的阻塞和唤醒后处理都在这里面。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
// 中断标识变量,默认false表示未中断
boolean interrupted = false;
// 自旋
for (;;) {
// 获取当前节点的前置节点
final Node p = node.predecessor();
// 这里对于理解一个关键点,倘若你的前置节点是head,那就代表你是老二,
// 并且注意此时此刻是这个老二线程在运行,那也就是说老大线程(head节点)已经是释放资源了或中断,
// 那么这个时候就该轮到老二上位,成为新的老大(head)
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);
}
}
这个方法中需要理解的就是第一个if那块,等把shouldParkAfterFailedAcquire()、parkAndCheckInterrupt()这两个方法说完,再来总结下当前这个方法的流程。
- shouldParkAfterFailedAcquire()
那前面说过获取资源失败的线程需要入队列休息,但是休息之前也还要做些事情。就好比你排队买票,如果说你就直接傻傻排在后面,前面有的人都不买票了还站在那,这是不是很浪费时间,所以在你真正要排队之前还得先去确人下前面的人是不是都是等着买票的,如果不是那就要请他出去,不要占着位置。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前置节点的等待状态
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
/*
* 前置节点是正常的等待状态,那你就安心排队等待吧
*/
return true;
if (ws > 0) {
/*
* 这个值上面有说明,大于0的就是表示已经放弃获取资源了,所以这里就通过循环遍历队列把等待状态值大于0的全部剔除出队列了,直到找到最近的一个正常等待的,排在他后面。
* 下面三行代码是基本的双向链表操作,想不清的建议画个图
*/
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
/*
* 如果你前面那个节点是没有放弃等待的,那就把它的等待状态设置成SIGNAL,这样才能保证自己安心排队。
* 这里为什么必须设置成SIGNAL,后面会在单独的文章分析说明
*/
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
- parkAndCheckInterrupt()
上面shouldParkAfterFailedAcquire返回true后,就会进入这个方法,这个方法主要就是让线程阻塞,并返回线程的中断状态。
private final boolean parkAndCheckInterrupt() {
// 阻塞
LockSupport.park(this);
return Thread.interrupted();
}
所以现在简单总结下acquireQueued的流程:
- 添加到队尾后,要去检验前置节点的等待状态,已取消等待的剔除出队列,正常等待的都设置成SIGNAL状态
- 通过park()阻塞队列,等待唤醒
- 被唤醒后就从上次被阻塞位置继续执行,检查自己是否可以去获取资源,如果可以那就去尝试获取,若获取到那当前节点将成为新的head;否则再从步骤1执行。
2.1.4 小结
最后来捋一捋整个独占模式获取锁的流程,直接上流程图吧:
再用文字总结下:
- 调用tryAcquire()尝试去获取资源,成功则直接返回;
- 若失败,则通过addWaiter()将该线程加入等待队尾;
- acquireQueued()使线程在等待队列中处于阻塞,并且被唤醒后如果符合条件会去尝试获取资源。 获取到资源后,若在整个等待过程中被中断过,则返回true,否则返回false。
- 如果线程在等待过程中被中断过,它是不响应的。而是当后面被唤醒获取资源后,才通过selfInterrupt()将中断补上,这是因为在parkAndCheckInterrupt()中的interrupted()方法会清除中断状态。
2.2 release(int arg)
独占模式下的释放资源方法,里面逻辑很简单,通过tryRelease()方法尝试释放资源,然后再去唤醒后面的节点线程。
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()方法中如果释放掉资源后,头部节点不为空且不是新节点,那就会通过unparkSuccessor()去唤醒后置节点。
2.2.1 tryRelease()
同样tryRelease()也是在自定义的同步器子类中各自实现,资源state的操作也在各自子类的tryRelease中。
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
2.2.2 unparkSuccessor(h)
private void unparkSuccessor(Node node) {
/*
* 这里的node就是目前释放资源的节点
* 会将waitStatus设置为0,但不强求允许设置失败
*/
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
/*
* 这里就是在获取当前节点的后置节点,如果后置节点为空,或者说已经取消等待了,
* 那么就会从队列尾部往前找,直到找到一个是处于等待状态的节点,然后在下面通过unpark()唤醒
*/
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);
}
2.3 小结
好了,独占模式下的获取、释放资源对应的基本流程差不多了,相比而言释放资源会简单很多。
3、共享模式
共享模式就是可以多个线程同时执行,例如CountDownLatch,获取和释放对应的方法为:acquireShared(int arg)、boolean releaseShared(int arg)。
3.1 acquireShared
共享模式下获取资源的入口方法,获取到返回,没获取到就入队列。
public final void acquireShared(int arg) {
// 获取资源
if (tryAcquireShared(arg) < 0)
// 入队列
doAcquireShared(arg);
}
3.1.1 tryAcquireShared
跟独占模式同样套路,tryAcquireShared()也是在自定义的同步器子类中设计,返回负数表示获取失败;返回0表示成功,但是后继争用线程不会成功;返回正数表示获取成功,并且后继争 用线程也可能成功。
protected int tryAcquireShared(int arg) {
throw new UnsupportedOperationException();
}
3.1.2 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);
}
}
3.1.3 setHeadAndPropagate
这个函数做的事情有两件:
- 在获取共享锁成功后,设置head节点
- 根据调用tryAcquireShared返回的状态以及节点本身的等待状态来判断是否要需要唤醒后继线程(自己不独占着,共享一下)。
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // Record old head for check below
setHead(node);
/*
* propagate是tryAcquireShared的返回值,这是决定是否传播唤醒的依据之一。
* h.waitStatus为SIGNAL或者PROPAGATE时也根据node的下一个节点共享来决定是否传播唤醒,
*/
if (propagate > 0 || h == null || h.waitStatus < 0 ||
(h = head) == null || h.waitStatus < 0) {
Node s = node.next;
if (s == null || s.isShared())
// 唤醒,下面介绍
doReleaseShared();
}
}
3.2 releaseShared
共享模式下释放指定量的资源,释放掉资源后,继续唤醒后面的节点。相比独占模式,独占模式在state=0时才会去唤醒后继节点,但是共享模式下没有这个要求。
public final boolean releaseShared(int arg) {
if (tryReleaseShared(arg)) {
doReleaseShared();
return true;
}
return false;
}
3.2.1 tryReleaseShared
老样子,还是留给子类自己去实现。
protected boolean tryReleaseShared(int arg) {
throw new UnsupportedOperationException();
}
3.2.2 doReleaseShared
这是共享锁中的核心唤醒函数,主要做的事情就是唤醒下一个线程或者设置传播状态。后继线程被唤醒后,会尝试获取共享锁,如果成功之后,则又会调用setHeadAndPropagate,将唤醒传播下去。
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; // head等待状态设置成0
unparkSuccessor(h); // 唤醒
}
else if (ws == 0 &&
!compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
continue; // 若head节点等待状态为0,则设置成PROPAGATE保证唤醒能够正确稳固传递下去
}
if (h == head)
break;
}
}
unparkSuccessor()方法在上面已经做过介绍了。
4、总结
好了,上面只是对于AQS的一个大致流程的介绍,写这篇博客也花了至少七八天的空闲时间,主要还是在去阅读源码,AQS细节部分暂时还没能力去解读。但是能知道个大概对于去阅读CountDownLatch、ReentrantLock等源码就会清晰很多了。
博客中的内容也是借鉴了以下两篇,人家的领悟比我更深。
https://www.cnblogs.com/waterystone/p/4920797.html
https://www.cnblogs.com/micrari/p/6937995.html