说明:部分图片、资料来源于某马培训机构的JUC并发课程,如侵权请联系我删除
一、什么是AQS
AQS是AbstractQueuedSynchronizer的简称,中文的意思就是抽象队列同步器,它是用来构建锁或者其他同步组件的基础框架,在JUC高并发编程中广泛得到使用,如ReentrantLock、CountDownLatch、ReentrantReadWriteLock和Semaphore等等都有使用到AQS。
二、AQS的设计思想
AQS设计思想:如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,就需要一套线程阻塞和等待以及被唤醒的机制来实现锁的分配,这个实现的机制也就是基于CLH队列(虚拟的双向队列)来实现的,也就是它会把等待的线程封装成一个NODE节点加入到CLH队列,等待锁的分配,整体就是一个抽象的FIFO队列来完成资源获取线程的排队工作,并通过一个int变量(state)表示持有锁的状态,state变量是有volatile修饰的,保证了可见性。
说明:CLH本身是单向链表,而AQS是CLH变体的双向队列
三、前置知识
1、公平锁和非公平锁
2、可重入锁
3、自旋思想
4、LockSupport
5、数据结构之双向链表
6、设计模式之模板设计模式
四、AQS案例
举个浅显易懂的例子吧!比如我们要去银行的柜台办理业务,那就需要排号,如果有三个客户去排号,那么第一个客户肯定先可以到柜台办理,而其他两位客户需要按照拿的号数排队等待第一个客户办理业务完叫号。
按照AQS的思想也就是:第一个客户先来柜台办理,抢到了这把"锁",其他客户在进来要办理必须去排队等待,也就是需要把其他的两位客户加入CLH队列,按照排队等候机制,等待第一位客户办理完业务然后唤醒他们去办理业务。
由以下代码实现
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class AQSDemo {
public static void main(String[] args) {
ReentrantLock reentrantLock = new ReentrantLock();
new Thread(()->{
reentrantLock.lock();
try {
System.out.println("------A客户拿到锁,先来柜台办理用户");
try {
//暂停20分钟,假设办理业务办了20分钟
TimeUnit.MINUTES.sleep(20);
} catch (Exception e) {
e.printStackTrace();
}
}finally {
reentrantLock.unlock();
}
},"A").start();
//B是第二个顾客,B一看到受理窗口被A占用,只能去候客区等待,进入CLH队列,等待A办理完成,尝试去抢占受理窗口,
new Thread(()->{
reentrantLock.lock();
try {
System.out.println("------B用户抢到锁,进来办理业务");
}finally {
reentrantLock.unlock();
}
},"B").start();
new Thread(()->{
reentrantLock.lock();
try {
System.out.println("------C用户抢到锁,进来办理业务");
}finally {
reentrantLock.unlock();
}
},"C").start();
}
}
五、AQS属性
1、state变量
上面我们有提到state变量是用来表示锁持有的状态,它是通过volatile修饰的,同时也保证了它的可见性,当state等于0的时候,表示当成锁处于空闲状态,反之,大于0则是有线程在占用锁
private volatile int state;//共享变量,使用volatile修饰保证线程可见性
2、CLH队列
CLH同步队列是一个FIFO双向队列,AQS依赖它来完成同步状态的管理,当前线程如果获取同步状态失败时,AQS则会将当前线程已经等待状态等信息构造成一个节点(Node)并将其加入到CLH同步队列,同时会阻塞当前线程,当同步状态释放时,会把首节点唤醒(公平锁),使其再次尝试获取同步状态。
3、自身属性和Node节点
内部类Node(Node类在AQS类内部),可以理解为银行候客区的椅子
Node的int变量waitStatus(Node的等待状态)
static final class Node {
//指示节点在共享模式下等待的标记
static final Node SHARED = new Node();
//标记,指示节点正以独占模式等待
static final Node EXCLUSIVE = null;
//线程被取消了
static final int CANCELLED = 1;
//后继线程需要唤醒
static final int SIGNAL = -1;
//等待condition唤醒
static final int CONDITION = -2;
// 共享式同步状态获取将无条件传播下去
static final int PROPAGATE = -3;
//初始值为0,状态是上面几种
volatile int waitStatus;
//前置节点
volatile Node prev;
//后置节点
volatile Node next;
//线程
volatile Thread thread;
//指向下一个处于CONDITION状态的节点
Node nextWaiter;
//下面是对应的封装的构造方法
}
模式 | 含义 |
SHARED | 表示线程以共享的模式等待锁 |
EXCLUSIVE | 表示线程正在以独占的方式等待锁 |
方法和属性值 | 含义 |
waitStatus | 当前节点在队列中的状态 |
thread | 表示处于该节点的线程 |
prev | 前驱指针 |
nextWaiter | 指向下一个处于CONDITION状态的节点 |
next | 后继指针 |
枚举 | 含义 |
0 | 当Node被初始化的时候的默认值 |
CANCELLED | 为1,表示线程获取锁的请求已经取消了 |
CONDITION | 为-2,表示节点在等待队列中,节点线程等待唤醒 |
PROPAGATE | 为-3,当前线程处在SHARED情况下,该字段才会使用 |
SIGNAL | 为-1,表示线程已经准备好了,就等资源释放了 |
六、源码分析
我们以ReentrantLock()的非公平锁为例子
ReentrantLock reentrantLock = new ReentrantLock(false); //参数不写默认就是非公平锁
进入方法,如下图
public ReentrantLock(boolean fair) {
//判断是公平锁还是非公平锁,我们传的是false,是非公平锁
sync = fair ? new FairSync() : new NonfairSync();
}
判断是公平锁还是非公平锁,我们传的是false,是非公平锁,所以走的是new NonfairSync(),进入方法如下图
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
final void lock() {
//进行一次CAS获取锁
if (compareAndSetState(0, 1))
//如果获取锁成功,设置独占锁线程为当前线程
setExclusiveOwnerThread(Thread.currentThread());
else
//获取锁失败,进行acquire方法
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
模拟我们刚才的例子,有三位客户来办理业务,分为三个步骤来看
一、第一位客户到达lock()方法
1、用CAS去判断状态是否为0,如果是0,就把state更新成1,默认state一开始就是0,那就是说明第一位客户肯定能修改成功,返回true
2、就进入setExclusiveOwnerThread(Thread.currentThread()方法,把独占的线程设置成当前自己的线程,也就是A客户的线程
二、第二位顾客进来到达lock()方法
B客户还是先进到lock方法会去先做if判断也就是CAS操作,我们刚才A客户已经把state的值改成1了,那么B客户再来修改这个state的值肯定会修改失败,返回false,进入else分支,执行acquire(1)这个方法,如下图
这个也就是AQS最重要的三个方法,分别执行的顺序是tryAcquire、addWaiter、acquireQueued
分别的操作其实也就是尝试加锁、线程入队、线程入队后进入线程阻塞状态三个流程
1、tryAcquire方法
先让我们看看tryAcquire方法
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
我们可以看到没有具体的实现方法,只是定义了一个方法而已,这也就是设计模式——模板设计模式,模板设计模式在一个方法中定义一个算法的骨架,而将一些步骤延迟到子类中。模板方法使得子类可以在不改变算法结构的情况下,重新定义算法中的某些步骤。
所以我们点开下面的方法,如下图红色框选的方法
在进入到nonfairTryAcquire(acquires)方法里
final boolean nonfairTryAcquire(int acquires) {
//获取当前线程
final Thread current = Thread.currentThread();
//获取state的值
int c = getState();
//判断状态值
if (c == 0) {
//如果等于0,还是进行CAS操作拿锁
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
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;
}
1、先获取了当前的线程和现在的state值
2、进行判断如果c等于0也就是当前锁没被占用,那就抢锁,但是我们现在是B客户,A客户还在窗口办理很显然state值不为0,不走这个分支
3、第二个if判断是判断当前线程是不是持有锁的线程也就是A线程,如果是就代表这个线程是当前占有线程,就去+1操作,也就是可重入锁,很显然B线程不是
4、所以我们走最后直接return出去也就是false
我们return了回到刚才acquire的方法
tryAcquire刚才返回的是false然后!进行取反也就是true,所以我们应该继续执行下面的方法,
先来到addWaiter方法,传入的参数为下图
2、addWaiter方法
进入addWaiter方法
private Node addWaiter(Node mode) {
//new一个Node节点
Node node = new Node(Thread.currentThread(), mode);
// tail就是尾节点,赋值给pred
Node pred = tail;
if (pred != null) {
//如果pred不为null,node的前一个节点就是new的节点
node.prev = pred;
//进行CAS操作入队
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
//入队操作
enq(node);
return node;
}
刚开始队列是没有任何的东西的是空的,所以tail(尾节点)赋值给pred就是一个空值,所以它会走enq方法
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
1、先把tail尾节点的值给t,t肯定是null因为队列没东西
2、进入第一个分支,在用CAS去设置头结点,注意,这里是new出了一个新的节点,并不是我们一开始传入的节点,然后队列设置完就会如下图
然后for是个循环,在进来一次循环,第二次进来t就不等于null了,走到else分支
尾节点等于t,然后把node的前一个指针指向t,再去设置尾节点,比较交换尾节点为node,然后在设置t的下一个结点为node,线程B完成入队
说明:双向链表中,第一个节点为虚节点(也叫哨兵节点),其实并不存储任何信息,只是占位。真正的第一个有数据的节点,是从第二个节点开始的。
三、假设第三个客户C进来,走到这个方法,然后pred用尾节点赋给他,尾节点不为null,进去if方法
进来这个分支,然后把c结点的前一个指针设置成尾节点,然后用compareAndSetTail方法,把c节点设置成了尾节点,然后再找节点B的下一个结点设置成节点C,然后C节点就成功入队了
队列如下图
3、acquireQueued方法
一、比如B用户先来调用
不考虑异常情况,先执行以下代码
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);
}
}
1、先获取B的前置节点p,也就是虚拟节点(占位节点)
2、所以p==head就是为true是头结点,然后tryAcquire也就是资源一释放就马上强抢
3、然后A要执行很久,强抢就失败,返回false,然后往下走另外一个if
进入shouldParkAfterFailedAcquire(头结点,B),new出来的节点的waitStatus都是0
因为是0,signal是-1所以不是第一个if,因为0没有大于0,也不是第二个,所以走第三个
第三个就是把虚拟节点waitstatus改成-1,然后返回false,然后又回来这个方法,继续for循环
当前队列如下图
signal表示的意思,上面表格也有列出
然后又进入for循环
又进入shouldParkAfterFailedAcquire方法,因为刚才已经改成-1了。走第一个if就是返回true
返回来再去执行这个parkAndCheckInterrupt方法
这样才稳稳当当停在队列中阻塞,等待唤醒机制也就是(unpark方法)
二、C客户也进来到这个方法
1、现在是C节点,获取他的前置节点p也就是B
2、然后B很显然不是头节点,进入下一个if判断
3、然后使用shouldParkAfterFailedAcquire(p, node)方法,p就是B节点,node就是C节点
pred就是B节点,B节点的的waitStatus就是0,走最后一个分支,然后就把前一个节点也就是B的waitSatus设置成-1,返回false,又再来一次for循环
还是到这个里面进来
B节点的waitStatus是-1,返回true
在调用parkAndCheckInterrupt()
C节点也就进来转着挂起了,等待唤醒
如果前驱节点的waitStatus是SIGNAL状态,即shouldParkAfterFailedAcquire
程序会继续向下执行parkAndCheckInterrupt方法,用于将当前线程挂起
接下来进入释放锁的流程
4、unlock
一、A客户办理好业务释放锁
看着截图一步步跟入,就不粘贴代码了
点进去tryRelease方法里面
1、c=1-1=0
2、当前线程不等于持有锁的线程,一般不会出现这个异常
3、然后判断c=0,c肯定等于0,free就是true,setExclusiveOwnerThread(null),就是A线程出去了
4、setState(c)也就是设置state设置为0,空闲状态
5、然后返回true,进入这个代码块
1、Node h=head,就是头结点,判断头结点不等于null,然后waitStatus不等于0,现在头结点是虚拟节点,为-1
2、进来调用unparkSuccessor(h),传入头结点
1、ws就是头结点就是-1
2、判断ws肯定小于0,compareAndSetWaitStatus,然后把头结点的状态改成0
3、s就是头结点的下一个结点B
4、s不等于null,就调用LockSupport.unpark(s.thread)把B节点的线程传入,unpark就能进来工作了
看刚才park阻塞的时候,return了Thread.interrupted没有什么打断意外就是返回false
在这就返回了false就退出来了
在进入for循环
1、Node的p就是虚拟节点
2、是头结点p==head是true,进入tryAcquire,因为是非公平锁,可能会第一个排队线程苏醒了就一定能抢到锁
现在c是0,来做CAS操作,如果成功就设置当前独占线程,返回true,进入代码块
进入setHead方法
1、node就是现在的节点B,设置成头结点
2、再把node线程设置为null
3、再把nodeB的前置节点设置为null
再执行下列操作
1、p就是占位节点,把占位节点的下个节点设置成null,帮助GC回收
2、failed设置成false,没有失败
3、返回也没有中断
如果比如有线程突然不做了,不再队列了,那个线程的node节点又在队列当中,会去进入cancelAcquire(Node node)方法,这就不一一讲解了,大家自己进入代码查看一下
七、总结
1、整个ReentrantLock的加锁过程,可以分为三个阶段:
1、尝试加锁:
2、加锁失败,线程入队列:
3、线程入队列后,进入阻塞状态(LockSupport)
2、tryAcquire方法,尝试获取锁。以下几种情况,会导致获取锁失败:
1、锁已经被其他线程获取:
2、锁设有被其他线程获取,但当前线程需要排队:
3、cas失败(可过程中已经有其他线程拿到锁了)
锁为自由状态(c==0),并不能说明可以立刻执行cas获取锁,因为可能在当前线程获取锁之前,已经有其他线程在排队了,必须遵循先来后到原则获取锁。所以还要调用hasQueuedPredecessors方法,查看自己是否需要排队。
希望这篇文章能让你更好的理解AQS抽象队列同步器