JUC并发编程之AQS源码解析(独占锁)+面试问题
跟据阳哥视频,自己总结整理.......
1.什么是AQS
AQS的全称是:AbstractQueuedSynchronizer(抽象队列同步器),它是构建锁或其他同步器组件的基础框架及整个JUC体系的基石,通过内置的CLH(FIFO)队列的变种来完成资源获取线程的排队工作,将每条将要去抢占资源的线程封装成一个Node节点来实现锁的分配,有一个int类变量表示持有锁的状态(private volatile int state),通过CAS完成对status值的修改(0表示没有,1表示阻塞)
AQS中的队列是CLH的变体:虚拟的FIFO的双向队列
CLH:是Craig, Landin, and Hagersten发明的,一个FIFO的队列,是一个单向链表,通过一定手段将所有线程对某一共享变量轮询竞争转化为一个线程队列且队列中的线程各自轮询自己的本地变量
2.AQS的核心思想
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列实现的,即将暂时获取不到锁的线程加入到队列中。
AQS定义了两种资源获取方式:独占(只有一个线程能访问执行)和共享(多个线程可同时访问执行)
3.基于AQS框架的实现
理解:AQS相当于对JUC中锁或者其他同步组件定义的一套统一的规范,
它们需要遵守这套规范,去实现自己的功能特性,从而供开发者调用,那么相对于开发者而言就隐藏了具体的实现细节
①CountDownLatch
②ReentrantLock
③ReentrantReadWriteLock
④Semaphore
4.AQS的内部架构
①以ReentrantLock为例,展示AQS的基本架构
②AQS中定义主要成员有:
所以AQS的内部架构总结为:表示同步状态的state成员变量+CLH双端Node队列
③AQS的内部类Node的内部结构
含义:
④AQS的架构图
5.以ReentrantLock中的NonfairSync非公平锁为例解析AQS
假设一种场景:一个银行只有一个办理业务的窗口,现在A顾客进来了,窗口没人开始办理业务,但是A还没有办理完,B、C顾客也来了试图去抢占窗口......
对于顾客A:
①首先调用了ReentrantLock的lock()方法抢占锁,内部调用了非公平锁的lock()方法(因为创建ReentrantLock对象时没有传入参数,默认创建了一个非公平锁)
②在该方法中,通过CAS去更新表示同步状态的成员变量state,如果state为0,表示没有线程占用资源,将state设为1,表示被占用状态,并返回true,调用AbstractOwnableSynchronizer类(AbstractQueuedSynchronizer的父类)的setExclusiveOwnerThread(Thread thread)方法,设置独占模式下,拥有资源的线程为当前线程
③此时说明当前线程获得了锁可以进行业务逻辑的处理了
对于顾客B:
①调用lock方法,通过CAS尝试修改state失败,因为此刻已经有线程占用资源,所以会调用acquire(1)方法去抢占资源
如果线程从acquireQueued中出来了,要么被中断了返回true,就进行一次自我中断,结束该线程,否则就一定是抢占到了资源,继续执行下面的业务逻辑即可
②acquire()方法的内部有三个方法,先看第一个方法:
1)tryAcquire(arg) 尝试去抢占资源
点进去可以看到它是AbstractQueuedSynchronizer中的一个方法,并且直接抛出了异常【这实际上是一种设计模式,模板设计模式,就是强制规定子类中要想用必须重写此方法】
NonfairSync的tryAcqiure中又调用了nonfairTryAcquire(acquires)方法
final boolean nonfairTryAcquire(int acquires) {
// 1.获取当前线程
final Thread current = Thread.currentThread();
// 2.获取同步状态的值
// - 1 表示a线程在占用
// - 0 表示此刻刚好a释放了锁
int c = getState();
if (c == 0) {
// 3.如果c==0,也就是a刚好释放了锁,再采用CAS的方式修改state,如果修改成功表示抢到了a释放的锁,设置当前线程为独占模式下锁的持有者,并返回true,表明尝试抢占成功
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
// 4.如果没有修改成功,表明即使a刚释放了锁,但是在b和其他线程的竞争下,b没有抢占成功,而是被其他线程抢占了,至于这个其他线程,可能还是a线程,也可能是c线程
}
// 5.如果c!=0,说明a还没有释放锁,并且在a线程获得锁后,要处理的业务中还有需要获取锁的操作-->(可重入锁),那么a线程会再次调用lock方法,没有通过compareAndSetState(0, 1)判断,来到acquire(1),去尝试抢占,所以出现当前来尝试抢占的线程就是拥有锁的线程的情况
else if (current == getExclusiveOwnerThread()) {
// 6.此时将state+1,表示重入了1次
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
// 7.若不小于0,更新state的值
setState(nextc);
return true;
}
// 8.若此时a还没有释放锁或者释放锁了但是被加塞了,就返回false表明抢占失败
return false;
}
2)addWaiter(Node.EXCLUSIVE) 以独占模式添加线程节点到等候队列
private Node addWaiter(Node mode) {
// 1.将当前线程包装成Node节点
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
// 2.获取等候队列的尾节点
Node pred = tail;
// 3.如果tail!=null,表示该队列不为空已经存在线程节点了
if (pred != null) {
// 4.就将当前节点的前节点指针指向前一个节点
node.prev = pred;
// 5.此处通过CAS又进行了一次判断,防止多线程情况下,其他线程抢占cpu,优先插入了节点到队尾,导致队尾节点改变,如果队尾没有被其他线程改变,修改尾节点为当前线程节点
if (compareAndSetTail(pred, node)) {
// 6.并将前一个节点的后节点指针指向当前节点,完成节点向队列中的1插入工作
pred.next = node;
// 7.并返回当前线程节点
return node;
}
}
// 8.-此刻如果tail==null,则表示队列中没有任何节点,需要对等候队列进行初始化操作
// - 如果是因为compareAndSetTail(pred, node)判断失败进入此方法,就需要重新尝试插入节点到队列中
enq(node);
return node;
}
enq(node) 节点进入队列
private Node enq(final Node node) {
for (;;) { // 自旋
// 1.获取当前尾节点
Node t = tail;
// 2.因为能够进入此方法,有上面分析的两个因素,一个是被加塞插入,一个是队列为空,所以要进行判断到底是哪种情况
if (t == null) { // Must initialize
// 3.如果队列为空,也就是头节点为null,创建一个新的空节点并将其设置为头节点,如果设置成功说明头节点确实为null,如果没有成功就说明又有其他线程抢了cpu,提前创建好了哨兵节点(傀儡节点),那就是利用【自旋锁】去循环添加节点到队列中,直至添加成功
if (compareAndSetHead(new Node()))
// 4.创建好了哨兵节点,将头节点和尾节点都指向此空节点,然后自旋去加入当前节点到队列中
tail = head;
} else {
// 5.如果尾节点不为null,可能是因为:
// - 尾节点是哨兵节点:刚刚创建好了哨兵节点(甭管是其他线程加塞创建的哨兵节点、还是当前线程正常创建的哨兵节点,最终都要通过自旋将节点插入到等候队列)
// - 尾节点不是哨兵节点:队列中已经添加了其他线程节点了
// 6.设置当前节点前节点为尾节点
node.prev = t;
// 7.将当前节点设置为尾节点,如果设置成功,说明没有其他线程加塞,就将前一个节点的后节点设置为当前节点,如果设置失败,说明又被加塞,就再自旋试着插入就好了
if (compareAndSetTail(t, node)) {
// 8.至此线程节点插入队尾成功,返回前一个节点(可能是哨兵节点也可能不是)
t.next = node;
return t;
}
}
}
}
图片演示(B进入队列):
3)acquireQueued(addWaiter(Node.EXCLUSIVE), arg))当前节点抢占队列
如果tryAcquire(arg)为true也就是尝试抢占成功了,!tryAcquire(arg)=false,根本不会去执行入队操作,而是去处理具体的业务逻辑了,正是因为抢占失败,并且入队了,才会执行此方法,如果当前节点是哨兵节点的下一个节点(也就是当锁被释放时,将从队列中取出的第一个节点,不准确,它不属于取出,而是将其变成哨兵节点),就去再次尝试抢占,如果又抢占失败了,或者不是“第一个节点“,就要去判断
final boolean acquireQueued(final Node node, int arg) {
// 1.开始抢占失败
boolean failed = true;
try {
// 2.线程没有被中断
boolean interrupted = false;
for (;;) {
// 3.获取当前线程节点的前节点,如果前节点为null,就抛出异常,因为一定会有一个哨兵节点在队首
final Node p = node.predecessor();
//4.如果当前节点的前节点是头节点,也就是哨兵节点,说明当前节点排在队中第一位(是逻辑上能被取出占用资源的第一个节点),就再次尝试获取锁,因为若是任何节点都可以再抢占一次,可能造成饥饿,如果成功了:
if (p == head && tryAcquire(arg)) {
// 5.就将当前节点设置为哨兵节点,也就是头节点
// head = node;
// node.thread = null;
// node.prev = null;
setHead(node);
p.next = null; // help GC
// 6.抢占成功,将失败设为false
failed = false;
// 7.结束函数处理自己的业务
return interrupted;
}
// 8.如果当前节点不是逻辑上首位被取节点或者,是优先被取的节点但是抢占失败了(可能是有新的线程来过来,抢了锁),就去判断是否应该将线程挂起,一旦确认挂起,也就是线程将前置节点的状态修改成功,就将其挂起,等待唤醒,唤醒后继续尝试占用(可能被其他线程加塞),直至占用成功,否则一直循环
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
图示演示(B抢到了锁):
shouldParkAfterFailedAcquire(p, node)判断是否应该将线程挂起
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 1.获取前节点的等候状态(可能是哨兵节点也可能是普通节点)
int ws = waitStatus;
// 2.如果当前节点的前节点的等候状态是SIGNAL,即-1(表示线程已经准备好了,只等释放资源了),那么当前节点就可以被挂起了,在队列中等待资源的释放
if (ws == Node.SIGNAL)
return true;
// 3.如果前一个结点的等待状态是1(线程获取锁的请求已经取消了)
if (ws > 0) {
// 4.就跳过前一个节点,prev指向它大前面的节点
do {
node.prev = pred = pred.prev;
// 5.循环检测,直到前一个节点状态<=0,一定会跳出循环,因为前面还有一个哨兵节点,waitState=0
} while (pred.waitStatus > 0);
// 6.完成互相引用
pred.next = node;
} else {
// 7.如果前一个节点的状态0,-2,-3,(前一个节点刚初始化好、节点在等待队列中等待某个条件去唤醒、指示下一个acquisharred应该无条件地传播),并且前置节点状态没有发生改变,就将其设置为-1(表示线程已经准备好了,只等释放资源了)
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
如果shouldParkAfterFailedAcquire(p, node)返回false,表明当前节点的前节点“之前”还处于不是SIGNAL的状态,“我”(当前线程)已经尝试去将其改为SIGNAL的状态,不一定成功,但是不成功就会循环去尝试将其设置为SIGNAL,直到设置成功shouldParkAfterFailedAcquire(p, node)返回true
图示(node.prev = pred = pred.prev):
parkAndCheckInterrupt() 利用LockSupport将该线程阻塞,并返回该线程是否终断的状态
private final boolean parkAndCheckInterrupt() {
// 将当前线程阻塞在这,后面一旦唤醒该线程,继续向下执行
LockSupport.park(this);
// 返回当前线程是否被终断
return Thread.interrupted();
}
1.LockSupport是用来创建锁和其他同步类的基本线程阻塞原语
2.LockSupport类使用了一种名为Permit(许可)的概念来做到阻塞和唤醒线程的功能,每一个线程都有一个许可(permit),permit只有两个值0,1,默认是0,即使多次调用unpark许可也不会累加,因为它最大就是1
3.park()/park(Object blocker) 阻塞当前线程/阻塞传入的线程
4.unpark(Thread thread) 唤醒处于阻塞状态的指定线程
5.总结:
若此时线程A,执行完成调用了unlock()方法,就是调用了锁的释放方法
public void unlock() {
sync.release(1);
}
sync.release(1)进行锁的释放
public final boolean release(int arg) {
// 1.尝试释放锁,又使用了模板设计模式,实际上是Sync中的方法,如果释放失败了(有可能是线程重入了),返回false
if (tryRelease(arg)) {
Node h = head;
// 2.如果释放成功,队列不为空,并且哨兵节点的状态已经被改变了,就可以选择线程进行唤醒了
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
// 3.唤醒成功返回true
return true;
}
return false;
}
tryRelease(int releases) 尝试释放锁
protected final boolean tryRelease(int releases) {
// 1.将表示同步的状态 -1
int c = getState() - releases;
// 2.当前释放锁的线程不是拥有锁的线程,就抛出异常
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
// 3.同步状态是否空闲,初始为false
boolean free = false;
if (c == 0) {
// 4.如果state == 0,表明不是处于重入情况,将拥有资源的线程设为null,并将同步状态设置为空闲
free = true;
setExclusiveOwnerThread(null);
}
// 5.回写state的状态
setState(c);
// 6.返回同步状态标记free
return free;
}
unparkSuccessor(h)唤醒继任线程
private void unparkSuccessor(Node node) {
// 1.获取哨兵节点的等候状态
int ws = node.waitStatus;
// 2.尝试将哨兵节点的waitStatus变成0
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
// 3.获取哨兵节点的下一个节点(继任者),如果哨兵节点下一个没有节点,或者说继任者还没准备好
Node s = node.next;
if (s == null || s.waitStatus > 0) {
// 5.就从后往前找,找到最前面的一个准备好的线程
s = null;
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
// 6.将该线程唤醒
LockSupport.unpark(s.thread);
}
对于顾客C
几乎与B相同,只是一些判断条件不同
6.执行流程总结:
获取锁的过程:
1.对于非公平锁开始尝试将同步状态从0设为1,如果成功,抢占了锁,变成锁资源的拥有者
2.如果失败就调用tryAcquire(arg)方法尝试占用锁,如果占用成功,成为锁资源的拥有者
3.如果占用失败,就将当前线程封装成一个节点放入等待队列,注意,如果队列为空,创建哨兵节点放在队首
4.接着会根据前一个节点的等待状态waitState,如果是-1(表示已经准备好获取资源了),就可以将当前线程阻塞了,如果不是将其设为-1,循环检查直到线程阻塞
5.若锁被释放,那么继任的线程会自旋尝试占用锁直至占用成功退出循环,并将继任的线程节点设为哨兵节点
释放锁的过程:
1.先将同步状态-1,如果减完后同步状态为0,说明锁资源没有被占用了,将拥有锁资源的线程设为null,回写同步状态,尝试释放成功
2.唤醒继任线程
3.如果尝试释放锁失败,说明可能存在可重入锁,返回false,结束此次unlock
AQS面试题
问:什么是AQS?
答:AQS的全称为(AbstractQueuedSynchronizer),这个类在java.util.concurrent.locks包下面。AQS是一个用来构建锁和同步器的框架,比如ReentrantLock,Semaphore,ReentrantReadWriteLock,SynchronousQueue,FutureTask等等皆是基于AQS的。
问:AQS的核心思想是什么?它是怎么实现的?
答:AQS核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用CLH队列锁实现的,即将暂时获取不到锁的线程加入到队列中。 AQS使用一个voliate int成员变量来表示同步状态,通过内置的FIFO队列来完成获取资源线程的排队工作。AQS使用CAS对该同步状态进行原子操作实现对其值的修改。
AQS定义了两种资源获取方式:独占(只有一个线程能访问执行,又根据是否按队列的顺序分为公平锁和非公平锁,如ReentrantLock) 和共享(多个线程可同时访问执行,如Semaphore/CountDownLatch,Semaphore、CountDownLatCh、 CyclicBarrier )。ReentrantReadWriteLock 可以看成是组合式,允许多个线程同时对某一资源进行读。
AQS底层使用了模板方法模式, 自定义同步器在实现时只需要实现共享资源 state 的获取与释放方式即可,至于具体线程等待队列的维护(如获取资源失败入队/唤醒出队等),AQS已经在上层已经帮我们实现好了。
如有问题欢迎指正
视频链接:尚硅谷Java大厂面试题第3季,跳槽必刷题目+必扫技术盲点(周阳主讲)_哔哩哔哩_bilibili
什么是AQS及其原理_striveb的博客-CSDN博客_aqs