1.概述
AQS: AbstractQueuedSynchronizer ,顾名思义,翻译过来叫抽象队列同步器 ,它是JUC并发编程的基石,定义了一套多线程访问共享资源的同步器框架,众多同步类底层都是基于AQS实现的,如常用的ReentrantLock、Semaphore、CountDownLatch等。
AQS使用一个volatile的int类型的成员变量来表示同步状态,通过内置的先进先出的CLH队列来完成获取资源线程的排队工作,将每条要去抢占资源的线程封装成一个Node节点来实现锁的分配,通过CAS完成对State值的修改。
抢到资源的线程直接使用,处理业务逻辑,抢不到资源的必然涉及一种排队等候机制。抢占资源失败的线程继续去等待(类似银行业务办理窗口都满了,暂时没有受理窗口的顾客只能去候客区排队等候),但等候线程仍然保留获取锁的可能且获取锁流程仍在继续(候客区的顾客也在等着叫号,轮到了再去受理窗口办理业务)。
既然说到了排队等候机制,那么就一定会有某种队列形成,这样的队列是什么数据结构呢?
如果共享资源被占用,就需要一定的阻塞等待唤醒机制来保证锁分配。这个机制主要用的是CLH队列的变体实现的,将暂时获取不到锁的线程加入到队列中,这个队列就是AQS的抽象表现。它将请求共享资源的线程封装成队列的结点(Node),通过CAS、自旋以及LockSupport.park()的方式,维护state变量的状态,使并发达到同步的效果。
项目推荐:基于SpringBoot2.x、SpringCloud和SpringCloudAlibaba企业级系统架构底层框架封装,解决业务开发时常见的非功能性需求,防止重复造轮子,方便业务快速开发和企业技术栈框架统一管理。引入组件化的思想实现高内聚低耦合并且高度可配置化,做到可插拔。严格控制包依赖和统一版本管理,做到最少化依赖。注重代码规范和注释,非常适合个人学习和企业使用
Github地址 :github.com/plasticene/...
Gitee地址 :gitee.com/plasticene3...
微信公众号 :Shepherd进阶笔记
交流探讨群:Shepherd_126
2.AQS结构
AQS就是一个抽象类,其主要维护一个资源同步状态变量的值(state) 和一个存放排队线程的CLH双向队列 ,同时线程阻塞等待以及被唤醒时锁分配的机制,当state = 0
表示无锁状态,资源不被占有, 当state>0
表示有锁状态,资源被占有。其源码大概如下:
scala
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {
private transient volatile Node head;
private transient volatile Node tail;
/**
* The synchronization state.
* 同步状态变量
*/
private volatile int state;
.......
}
这里主要展示了同步状态变量state和将请求共享资源的线程封装成队列的结点(Node),只有相关逻辑方法后续会讲到,敬请期待~~
AQS其思想逻辑实现架构图如下:
AQS有一个内部类Node
,Node结点是对每一个等待获取资源的线程的封装,其包含了需要同步的线程本身及其等待状态,如是否被阻塞、是否等待唤醒、是否已经被取消等。
arduino
static final class Node {
volatile int waitStatus;
volatile Node prev;
volatile Node next;
volatile Thread thread;
Node nextWaiter;
.......
}
这里可以直观地看出该node节点会存储当前被阻塞的请求资源线程,变量waitStatus则表示当前Node结点的等待状态,共有5种取值如下:
- CANCELLED(1):表示当前结点已取消调度。当timeout或被中断(响应中断的情况下),会触发变更为此状态,进入该状态后的结点将不会再变化。
- SIGNAL(-1):表示后继结点在等待当前结点唤醒。后继结点入队时,会将前继结点的状态更新为SIGNAL。
- CONDITION (-2):表示结点等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
- PROPAGATE(-3):共享模式下,前继结点不仅会唤醒其后继结点,同时也可能会唤醒后继的后继结点。
- 0:新结点入队时的默认状态。
3.从ReentrantLock入手分析AQS实现源码
ReentrantLock
是JUC并发包中一个重要常用的同步器,其底层就是依赖AQS实现的。下面我们将从模拟三个客户(线程)到银行窗口(只有一个窗口)办理业务取号排队等候场景入手,基于ReentrantLock
实现该逻辑,其固定流程步骤实现代码如下:
csharp
class bank {
// ReentrantLock 可重入锁,默认为非公平锁
Lock lock = new ReentrantLock();
public void handle(){
lock.lock(); // 加锁
try {
// 业务处理代码
......
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock(); // 解锁
}
}
}
3.1 加锁占位资源
假设三个客户分别为线程A、线程B、线程C ,线程A执行lock.lock()
加锁占位,这时候跳转到ReentrantLock
的内部类NonfairSync(NonfairSync继承自AQS)
的lock()
方法:
scss
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
可以看到先通过CAS设置同步状态state
值从0变为1,设置成功,就代表线程A获得资源成功(占有窗口,接下来可以办理业务啦),紧接着线程B、线程C也来办理业务,同样会执行lock.lock()
方法尝试加锁占位资源,这里假设线程A一直占有窗口处理业务中,半天处理不完(pg:取款几千万...),state
的值为1,与此同时线程B尝试加锁占位资源执行上面代码compareAndSetState(0, 1)
失败返回false,就会来到acquire(1)
方法,该方法执行效果等于在银行取个号然后去大厅排队等候叫号。
acquire()
方法是AQS中的方法,这里就是线程加锁占位资源不成功,把线程放到CLH队列等待通知唤醒的核心入口处
scss
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
核心流程步骤如下:
- tryAcquire()尝试直接去获取资源,如果成功则直接返回(这里体现了非公平锁,每个线程获取锁时会尝试直接抢占加塞一次,而CLH队列中可能还有别的线程在等待);
- addWaiter()将该线程加入等待队列的尾部,并标记为独占模式;
- acquireQueued()使线程阻塞在等待队列中获取资源,一直获取到资源后才返回。如果在整个等待过程中被中断过,则返回true,否则返回false。
- 如果线程在等待过程中被中断过,它是不响应的。只是获取资源后才再进行自我中断selfInterrupt(),将中断补上。
3.1.1 tryAcquire(int)
此方法定义在AQS中由子类实现尝试获取锁独占资源,如果获取成功,则直接返回true,否则直接返回false
arduino
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
子类NonfairSync
实现如下:
arduino
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
java
final boolean nonfairTryAcquire(int acquires) {
// 获取当前线程
final Thread current = Thread.currentThread();
// 获取当前同步状态state值
int c = getState();
// state=0 代表当前资源没有被锁住,此时当前线程可以尝试加锁占有资源
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// state值不等于0 判断当前线程和持有当前资源线程是不是同一个线程,是,那就是可重入锁逻辑,就累加
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 加锁占有资源失败
return false;
}
按照上面的核心入口代码逻辑,如果tryAcquire()
返回false,那么就会执行acquireQueued(addWaiter(Node.EXCLUSIVE), arg)
方法
3.1.2 addWaiter(Node)
此方法用于将当前线程加入到等待队列CLH的队尾,并返回当前线程所在的结点
ini
private Node addWaiter(Node mode) {
//以给定模式构造结点。mode有两种:EXCLUSIVE(独占)和SHARED(共享)
Node node = new Node(Thread.currentThread(), mode);
//尝试快速方式直接放到队尾,第一个节点(这里是客户线程B)插入队列,这时候尾节点是空,会跳到下面的enq()入队,自此后面的tail节点不 再为空
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//上一步失败则通过enq入队。
enq(node);
return node;
}
ini
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;
}
}
}
}
3.1.3 acquireQueued(Node, int)
通过tryAcquire()和addWaiter(),该线程获取资源失败,已经被放入等待队列尾部了。这时候线程需要被挂起进入等待状态休息,直到其他线程彻底释放资源后唤醒自己,自己再拿到资源,然后就可以去干自己想干的事了。是不是跟银行取号排队等待有点相似~~acquireQueued()就是干这件事:在等待队列中排队拿号(中间没其它事干可以休息),直到拿到号后再返回。这个函数非常关键
java
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;//标记是否成功拿到资源
try {
boolean interrupted = false;//标记等待过程中是否被中断过
//又是一个"自旋"!
for (;;) {
final Node p = node.predecessor();//拿到前驱
//如果前驱是head,即该结点已成老二,那么便有资格去尝试获取资源(可能是老大释放完资源唤醒自己的,当然也可能被interrupt了)。
if (p == head && tryAcquire(arg)) {
setHead(node);//拿到资源后,将head指向该结点。所以head所指的标杆结点,就是当前获取到资源的那个结点或null。
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) // 如果等待过程中没有成功获取资源(如timeout,或者可中断的情况下被中断了),那么取消结点在队列中的等待。
cancelAcquire(node);
}
}
这里先来看看shouldParkAfterFailedAcquire()
,主要是设置当前节点的前驱节点的waitStatus
为SIGNAL
,其意义就是让前驱拿完号后通知自己一下
arduino
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;//拿到前驱的状态
if (ws == Node.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;
}
客户线程B、线程C经过上面操作之后节点状态就如下图所示了:
如果前驱节点的 waitStatus 是 SIGNAL状态,即 shouldParkAfterFailedAcquire
方法会返回 true 程序会继续向下执行 parkAndCheckInterrupt
方法,用于将当前线程挂起:
arduino
private final boolean parkAndCheckInterrupt() {
2 LockSupport.park(this);//调用park()使线程进入waiting状态
3 return Thread.interrupted();//如果被唤醒,查看自己是不是被中断的。
4 }
以上就是线程B、线程C执行lock.lock()
尝试加锁占位失败,进入CLH队列,被挂起,需要等待后面通知唤醒的全部流程。
3.2 解锁释放资源
当线程A处理完业务之后,就会执行lock.unlock()
解锁释放资源。又会来到NonfairSync(NonfairSync继承自AQS)
的unlock()
方法:
csharp
public void unlock() {
sync.release(1);
}
java
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(arg)
释放资源:该方法也是定义在AQS中,需要子类去实现,典型的模版方法设计模式的应用
arduino
protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}
ini
protected final boolean tryRelease(int releases) {
// 同步状态值变更
int c = getState() - releases;
// 释放线程和占有线程不是同一线程,报错
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 如果同步状态值减为0,说明当前资源不再被占有,如果不是就是可重入锁逻辑,需要造次被释放
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
如果上面释放资源成功返回true,那么就会执行unparkSuccessor()
唤醒等待队列里的下一个线程
ini
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;
// 为什么从后向前找,而不是从前往后找?
// 由于并发问题,addWaiter()入队操作和cancelAcquire()取消排队操作都会造成next链的不一致,而prev链是强一致的,所以这时从后往前找是最安全的。
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);//唤醒
}
这时候线程B被唤醒,又回到上面线程B被挂起在acquireQueued()
方法自旋逻辑中执行tryAquire()
加锁占位资源
3.3 公平与非公平锁
我们知道ReentrantLock
分为公平锁和非公平锁,默认构造方法是非公平锁,如果我们需要构建公平锁,只需要传参true即可:
ini
Lock lock = new ReentrantLock(true);
其加锁占有资源和解锁释放资源的流程和上面几乎一样,只是有一些细节区别:
java
static final class FairSync extends Sync {
private static final long serialVersionUID = -3000897897090466540L;
final void lock() {
acquire(1);
}
/**
* Fair version of tryAcquire. Don't grant access unless
* recursive call or no waiters or is first.
*/
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
}
这里lock()
没有和非公平锁的一样先使用CAS尝试加锁占有资源,是直接调用acquire(1)
进行入队操作,这很容易理解,公平锁就是要求后面请求资源的线程必须乖乖入队、排队等候,别老想着插队~~,其次就是这个tryAcquire()
方法尝试加锁占有资源逻辑中多了一个hasQueuedPredecessors()
判断逻辑:
ini
public final boolean hasQueuedPredecessors() {
Node t = tail; // Read fields in reverse initialization order
Node h = head;
Node s;
return h != t &&
((s = h.next) == null || s.thread != Thread.currentThread());
}
如果在当前线程之前有一个排队的线程,返回true,此时就不再尝试加锁占有资源,因为队列前面还有线程,只能乖乖等着;如果当前线程在队列的头或队列为空,返回false,就可以尝试加锁占有资源啦。
到此,基于以上全部内容,我们把AQS的思想原理和基于ReentrantLock
同步器源码实现梳理了一遍。