部分文章来源:JavaGuide
什么是AQS
AQS的全称是抽象队列同步器
用来构建锁和同步器的
能简单且高效地构造出大量的锁和同步器
AQS的核心思想是什么
AQS 核心思想:
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态
如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制
这个机制 AQS 是用 CLH 队列锁 实现的,即将暂时获取不到锁的线程加入到队列中
AQS的目的:更好地处理资源的竞争
CLH队列锁(前置知识)
首先我们要弄懂AQS就要先有个前置知识,也就是什么是自旋锁,什么是CLH锁
什么是自旋锁
通过while来不断重试
因此自旋锁适用于锁竞争不激烈、锁持有时间短的场景
因为
1.插队问题:锁竞争激烈的情况下,可能存在一个线程一直被其他线程”插队“而一直获取不到锁的情况。
2.性能较差问题:实际的多处理上运行的自旋锁在锁竞争激烈时性能较差
什么是CLH锁
首先我们要知道CLH锁是对自旋锁的一个改进
我们的线程只需要轮询上一个队尾节点的状态变量
之前我们是中心化锁
1.为了解决插队问题:将线程组织成一个队列,保证先请求的线程先获得锁,避免了饥饿问题
2.锁状态去中心化,让每个线程在不同的状态变量中自旋,这样当一个线程释放它的锁时,只能使其后续线程的高速缓存失效,缩小了影响范围,从而减少了 CPU 的开销。
什么是中心化锁的问题
什么是中心化锁问题
所有线程都围绕一个全局锁竞争,如果有大量线程在竞争这个锁,那么会导致大量线程阻塞,降低系统的并发性能
缓存失效(Cache Coherency)问题:在多处理器系统中,每个处理器都有自己的缓存。当一个线程修改了锁的状态(比如锁被释放),所有其他处理器上的缓存数据会失效,迫使这些处理器从主内存重新获取锁状态。这会导致频繁的缓存失效,增加系统的开销
在传统的中心化锁中,所有线程争抢的是同一把锁,导致线程都在同一个锁变量上竞争,发生激烈的争用。然而,去中心化锁通过让线程在不同的变量上自旋,减小了竞争锁时的开销和缓存失效的影响。
例子
我们会把线程加入到一个链表中
里面有一个节点,它来判断当前线程是否需要继续等待
这个线程不需要不断检查全局锁的状态,而是自旋等待它自己对应的节点状态变量
AQS原理详解
AQS核心思想
如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态
这个机制是基于CLH锁实现的
CLH锁是对自旋锁的一种改进
AQS的核心原理图
我们有两个重要的东西
1.state资源变量
2.FIFO等待队列(CLH队列)
如何理解锁竞争:节点去争夺state变量,争夺成功就相当于抢到锁
AQS 使用 int 成员变量 state 表示同步状态
通过内置的FIFO 线程等待/等待队列来完成获取资源线程的排队工作
我们的state变量是用volatile变量修饰的,用于展示当前临界资源的获取锁的情况
且里面的三个方法getState(),setState(),compareAndSetState(),都是用了我们的final变量修饰
以可重入锁ReentranLock为例
state 的初始值为 0,表示锁处于未锁定状态
当线程 A 调用 lock() 方法时,会尝试通过 tryAcquire() 方法独占该锁,并让 state 的值加 1
如果成功了,那么线程 A 就获取到了锁
如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 队列)中,直到其他线程释放该锁
假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的(state 会累加)
可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 state 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁
Node节点的waitStatus
如果 waitStatus > 0
,表明节点的状态已经取消等待获取资源
如果 waitStatus < 0
,表明节点的处于有效的等待状态
因此在 AQS 的源码中,经常使用 > 0
、 < 0
来对 waitStatus
进行判断
自定义同步器
我们的AQS的实现类都继承AbstractQueuedSynchronizer
并重写指定的方法
例如CountDownLatch和ReentranLock,我们都继承了这个类然后重写指定的方法
AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法
AQS资源共享方式
AQS的两种资源共享方式
AQS 定义两种资源共享方式:Exclusive
(独占,只有一个线程能执行,如ReentrantLock
)
和
Share
(共享,多个线程可同时执行,如Semaphore
/CountDownLatch
)。
一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现tryAcquire-tryRelease
、tryAcquireShared-tryReleaseShared
中的一种即可
但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如ReentrantReadWriteLock
什么是独占方式和共享方式
独占方式(Exclusive Mode)
- 定义:同一时间只有一个线程能获取资源,其他线程必须等待。
- 实现:通过
tryAcquire
和tryRelease
方法实现资源的获取和释放。 - 应用场景:适用于互斥锁(如ReentrantLock),确保资源不会被多个线程同时访问。
共享方式(Shared Mode)
- 定义:多个线程可以同时获取资源,适用于允许多个线程并发访问的场景。
- 实现:通过
tryAcquireShared
和tryReleaseShared
方法实现资源的获取和释放。 - 应用场景:适用于信号量(如Semaphore)和读写锁(如ReentrantReadWriteLock),允许多个线程同时读取资源。
区别
- 独占方式:资源一次只能被一个线程持有。
- 共享方式:资源可以被多个线程同时持有。
示例
- 独占方式:
ReentrantLock
确保同一时间只有一个线程持有锁。 - 共享方式:
Semaphore
允许多个线程同时获取许可。
总结
- 独占方式:互斥访问,适用于需要排他性控制的场景。
- 共享方式:并发访问,适用于允许多个线程同时操作的场景
AQS资源获取源码分析(独占模式)
AQS 中以独占模式获取资源的入口方法是 acquire()
,如下:
// AQS
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
acquire()获取锁流程
1.在 acquire()
中,线程会先尝试获取共享资源
2.如果获取失败,会将线程封装为 Node 节点加入到 AQS 的等待队列中
3.加入队列之后,会让等待队列中的线程尝试获取资源,并且会对线程进行阻塞操作
三个方法
tryAcquire()
:尝试获取锁(模板方法),AQS
不提供具体实现,由子类实现。
addWaiter()
:如果获取锁失败,会将当前线程封装为 Node 节点加入到 AQS 的 CLH 变体队列中等待获取锁。
acquireQueued()
:对线程进行阻塞、唤醒,并调用 tryAcquire()
方法让队列中的线程尝试获取锁
tryAcquire()分析
AQS 中对应的 tryAcquire()
模板方法
// AQS
protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}
tryAcquire()
方法是 AQS 提供的模板方法,不提供默认实现
ReentranLock举例子
这里分析 tryAcquire()
方法时,以 ReentrantLock
的非公平锁(独占锁)为例进行分析
ReentrantLock
内部实现的 tryAcquire()
会调用到下边的 nonfairTryAcquire()
:
// ReentrantLock
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 1、获取 AQS 中的 state 状态
int c = getState();
// 2、如果 state 为 0,证明锁没有被其他线程占用
if (c == 0) {
// 2.1、通过 CAS 对 state 进行更新
if (compareAndSetState(0, acquires)) {
// 2.2、如果 CAS 更新成功,就将锁的持有者设置为当前线程
setExclusiveOwnerThread(current);
return true;
}
}
// 3、如果当前线程和锁的持有线程相同,说明发生了「锁的重入」
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
// 3.1、将锁的重入次数加 1
setState(nextc);
return true;
}
// 4、如果锁被其他线程占用,就返回 false,表示获取锁失败
return false;
}
在 nonfairTryAcquire()
方法内部,主要通过两个核心操作去完成资源的获取:
- 通过
CAS
更新state
变量。state == 0
表示资源没有被占用。state > 0
表示资源被占用,此时state
表示重入次数。 - 通过
setExclusiveOwnerThread()
设置持有资源的线程。
如果线程更新 state
变量成功,就表明获取到了资源
因此将持有资源的线程设置为当前线程即可
addWaiter()分析
在通过 tryAcquire()
方法尝试获取资源失败之后
会调用 addWaiter()
方法将当前线程封装为 Node 节点加入 AQS
内部的队列中
addWaite()
代码如下:
// AQS
private Node addWaiter(Node mode) {
// 1、将当前线程封装为 Node 节点。
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
// 2、如果 pred != null,则证明 tail 节点已经被初始化,直接将 Node 节点加入队列即可。
if (pred != null) {
node.prev = pred;
// 2.1、通过 CAS 控制并发安全。
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 3、初始化队列,并将新创建的 Node 节点加入队列。
enq(node);
return node;
}
节点入队的并发安全
在 addWaiter()
方法中,需要执行 Node 节点 入队 的操作。由于是在多线程环境下
因此需要通过 CAS
操作保证并发安全
通过 CAS
操作去更新 tail
指针指向新入队的 Node 节点,CAS
可以保证只有一个线程会成功修改 tail
指针,以此来保证 Node 节点入队时的并发安全
AQS内部队列的初始化
在执行 addWaiter()
时,如果发现 pred == null
,即 tail
指针为 null
则证明队列没有初始化
需要调用 enq()
方法初始化队列,并将 Node
节点加入到初始化后的队列中
代码如下:
// AQS
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) {
// 1、通过 CAS 操作保证队列初始化的并发安全
if (compareAndSetHead(new Node()))
tail = head;
} else {
// 2、与 addWaiter() 方法中节点入队的操作相同
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
在 enq()
方法中初始化队列,在初始化过程中,也需要通过 CAS
来保证并发安全
初始化队列总共包含两个步骤:
1.初始化 head
节点
2.tail
指向 head
节点
acquireQueued()分析
AQS 中以独占模式获取资源的入口方法是 acquire()
,如下:
// AQS
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
在 acquire()
方法中,通过 addWaiter()
方法将 Node
节点加入队列之后,就会调用 acquireQueued()
方法。
代码如下:
// AQS:令队列中的节点尝试获取锁,并且对线程进行阻塞。
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 1、尝试获取锁。
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 2、判断线程是否可以阻塞,如果可以,则阻塞当前线程。
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 3、如果获取锁失败,就会取消获取锁,将节点状态更新为 CANCELLED。
if (failed)
cancelAcquire(node);
}
}
在 acquireQueued()
方法中,主要做两件事情:
- 尝试获取资源: 当前线程加入队列之后,如果发现前继节点是
head
节点,说明当前线程是队列中第一个等待的节点,于是调用tryAcquire()
尝试获取资源。 - 阻塞当前线程 :如果尝试获取资源失败,就需要阻塞当前线程,等待被唤醒之后获取资源
1.尝试获取资源
在 acquireQueued()
方法中,尝试获取资源总共有 2 个步骤:
p == head
:表明当前节点的前继节点为head
节点。此时当前节点为 AQS 队列中的第一个等待节点。tryAcquire(arg) == true
:表明当前线程尝试获取资源成功
在成功获取资源之后,就需要将当前线程的节点 从等待队列中移除
移除操作为:将当前等待的线程节点设置为 head
节点(head
节点是虚拟节点,并不参与排队获取资源)
2、阻塞当前线程
在 AQS
中,当前节点的唤醒需要依赖于上一个节点
如果上一个节点取消获取锁,它的状态就会变为 CANCELLED
,CANCELLED
状态的节点没有获取到锁,也就无法执行解锁操作对当前节点进行唤醒。
因此在阻塞当前线程之前,需要跳过 CANCELLED
状态的节点
通过 shouldParkAfterFailedAcquire()
方法来判断当前线程节点是否可以阻塞,如下:
// AQS:判断当前线程节点是否可以阻塞。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
// 1、前继节点状态正常,直接返回 true 即可。
if (ws == Node.SIGNAL)
return true;
// 2、ws > 0 表示前继节点的状态异常,即为 CANCELLED 状态,需要跳过异常状态的节点。
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 3、如果前继节点的状态不是 SIGNAL,也不是 CANCELLED,就将状态设置为 SIGNAL。
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
shouldParkAfterFailedAcquire()
方法中的判断逻辑:
- 如果发现前继节点的状态是
SIGNAL
,则可以阻塞当前线程。 - 如果发现前继节点的状态是
CANCELLED
,则需要跳过CANCELLED
状态的节点。 - 如果发现前继节点的状态不是
SIGNAL
和CANCELLED
,表明前继节点的状态处于正常等待资源的状态,因此将前继节点的状态设置为SIGNAL
,表明该前继节点需要对后续节点进行唤醒
当判断当前线程可以阻塞之后,通过调用 parkAndCheckInterrupt()
方法来阻塞当前线程
内部使用了 LockSupport
来实现阻塞。LockSupoprt
底层是基于 Unsafe
类来阻塞线程,代码如下:
// AQS
private final boolean parkAndCheckInterrupt() {
// 1、线程阻塞到这里
LockSupport.park(this);
// 2、线程被唤醒之后,返回线程中断状态
return Thread.interrupted();
}
为什么在线程被唤醒之后,要返回线程的中断状态呢?
在 parkAndCheckInterrupt()
方法中,当执行完 LockSupport.park(this)
,线程会被阻塞,代码如下:
// AQS
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
// 线程被唤醒之后,需要返回线程中断状态
return Thread.interrupted();
}
当线程被唤醒之后,需要执行 Thread.interrupted()
来返回线程的中断状态,这是为什么呢?
这个和线程的中断协作机制有关系,线程被唤醒之后,并不确定是被中断唤醒,还是被 LockSupport.unpark()
唤醒,因此需要通过线程的中断状态来判断
在 acquire()
方法中,为什么需要调用 selfInterrupt()
?
acquire()
方法代码如下:
// AQS
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
在 acquire()
方法中,当 if
语句的条件返回 true
后,就会调用 selfInterrupt()
,该方法会中断当前线程,为什么需要中断当前线程呢?
当 if
判断为 true
时,需要 tryAcquire()
返回 false
,并且 acquireQueued()
返回 true
。
其中 acquireQueued()
方法返回的是线程被唤醒之后的 中断状态 ,通过执行 Thread.interrupted()
来返回。该方法在返回中断状态的同时,会清除线程的中断状态。
因此如果 if
判断为 true
,表明线程的中断状态为 true
,但是调用 Thread.interrupted()
之后,线程的中断状态被清除为 false
因此需要重新执行 selfInterrupt()
来重新设置线程的中断状态
AQS资源释放源码分析(独占模式)
release()代码
AQS 中以独占模式释放资源的入口方法是 release()
,代码如下:
// AQS
public final boolean release(int arg) {
// 1、尝试释放锁
if (tryRelease(arg)) {
Node h = head;
// 2、唤醒后继节点
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
在 release()
方法中,主要做两件事:尝试释放锁和唤醒后继节点
1.尝试释放锁:tryRelease()
通过 tryRelease()
方法尝试释放锁,该方法为模板方法,由自定义同步器实现,因此这里仍然以 ReentrantLock
为例来讲解。
ReentrantLock
中实现的 tryRelease()
方法如下:
重点在于我们的free变量
// ReentrantLock
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
// 1、判断持有锁的线程是否为当前线程
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
// 2、如果 state 为 0,则表明当前线程已经没有重入次数。因此将 free 更新为 true,表明该线程会释放锁。
if (c == 0) {
free = true;
// 3、更新持有资源的线程为 null
setExclusiveOwnerThread(null);
}
// 4、更新 state 值
setState(c);
return free;
}
在 tryRelease()
方法中,会先计算释放锁之后的 state
值,判断 state
值是否为 0。
- 如果
state == 0
,表明该线程没有重入次数了,更新free = true
并修改持有资源的线程为 null,表明该线程完全释放这把锁 - 如果
state != 0
,表明该线程还存在重入次数,因此不更新free
值,free
值为false
表明该线程没有完全释放这把锁。
之后更新 state
值,并返回 free
值,free
值表明线程是否完全释放锁
2.唤醒后继节点:unparkSuccessor()
如果 tryRelease()
返回 true
,也就是我们的返回的free值为true表明线程已经没有重入次数了,锁已经被完全释放,因此需要唤醒后继节点
在唤醒后继节点之前,需要判断是否可以唤醒后继节点
分析判断条件
判断条件为: h != null && h.waitStatus != 0
。这里解释一下为什么要这样判断:
h == null
:表明head
节点还没有被初始化,也就是 AQS 中的队列没有被初始化,因此无法唤醒队列中的线程节点。h != null && h.waitStatus == 0
:表明头节点刚刚初始化完毕(节点的初始化状态为 0),后继节点线程还没有成功入队,因此不需要对后续节点进行唤醒。(当后继节点入队之后,会将前继节点的状态修改为SIGNAL
,表明需要对后继节点进行唤醒)h != null && h.waitStatus != 0
:其中waitStatus
有可能大于 0,也有可能小于 0。其中> 0
表明节点已经取消等待获取资源,< 0
表明节点处于正常等待状态
具体如何唤醒后续节点
接下来进入 unparkSuccessor()
方法查看如何唤醒后继节点:
// AQS:这里的入参 node 为队列的头节点(虚拟头节点)
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
// 1、将头节点的状态进行清除,为后续的唤醒做准备。
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
// 2、如果后继节点异常,则需要从 tail 向前遍历,找到正常状态的节点进行唤醒。
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)
// 3、唤醒后继节点
LockSupport.unpark(s.thread);
}
在 unparkSuccessor()
中,如果头节点的状态 < 0
(在正常情况下,只要有后继节点,头节点的状态应该为 SIGNAL
,即 -1),表示需要对后继节点进行唤醒
因此这里提前清除头节点的状态标识,将状态修改为 0,表示已经执行了对后续节点唤醒的操作
如果 s == null
或者 s.waitStatus > 0
,表明后继节点异常,此时不能唤醒异常节点,而是要找到正常状态的节点进行唤醒。
因此需要从 tail
指针向前遍历,来找到第一个状态正常(waitStatus <= 0
)的节点进行唤醒
为什么要从 tail
指针向前遍历,而不是从 head
指针向后遍历,寻找正常状态的节点呢?
遍历的方向和 节点的入队操作 有关
入队方法如下:
// AQS:节点入队方法
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
if (pred != null) {
// 1、先修改 prev 指针。
node.prev = pred;
if (compareAndSetTail(pred, node)) {
// 2、再修改 next 指针。
pred.next = node;
return node;
}
}
enq(node);
return node;
}
在 addWaiter()
方法中,node
节点入队需要修改 node.prev
和 pred.next
两个指针,但是这两个操作并不是 原子操作 ,先修改了 node.prev
指针,之后才修改 pred.next
指针。
在极端情况下,可能会出现 head
节点的下一个节点状态为 CANCELLED
此时新入队的节点仅更新了 node.prev
指针,还未更新 pred.next
指针,如下图:
这样如果从 head
指针向后遍历,无法找到新入队的节点,因此需要从 tail
指针向前遍历找到新入队的节点
图解AQS工作原理
由于 AQS 是底层同步工具,获取和释放资源的方法并没有提供具体实现,因此这里基于 ReentrantLock
来画图进行讲解。
假设总共有 3 个线程尝试获取锁,线程分别为 T1
、 T2
和 T3
。
T1获取锁成功:初始化AQS队列
此时,假设线程 T1
先获取到锁,线程 T2
排队等待获取锁在线程 T2
进入队列之前,需要对 AQS 内部队列进行初始化。head
节点在初始化后状态为 0
AQS 内部初始化后的队列如下图:
T2尝试获取锁:T2入AQS队列
此时,线程 T2
尝试获取锁。由于线程 T1
持有锁,因此线程 T2
会进入队列中等待获取锁
同时会将前继节点( head
节点)的状态由 0
更新为 SIGNAL
,表示需要对 head
节点的后继节点进行唤醒
AQS 内部队列如下图所示:
T3尝试获取锁:T3入AQS队列
此时,线程 T3
尝试获取锁。由于线程 T1
持有锁,因此线程 T3
会进入队列中等待获取锁
同时会将前继节点(线程 T2
节点)的状态由 0
更新为 SIGNAL
,表示线程 T2
节点需要对后继节点进行唤醒
AQS 内部队列如下图所示:
T1释放锁:唤醒后续节点T2
此时,假设线程 T1
释放锁,会唤醒后继节点 T2
。线程 T2
被唤醒后获取到锁,并且会从等待队列中退出。
这里线程 T2
节点退出等待队列并不是直接从队列移除
而是令线程 T2
节点成为新的 head
节点,以此来退出资源获取的等待
此时 AQS 内部队列如下所示:
T2释放锁:唤醒后续节点T3
此时,假设线程 T2
释放锁,会唤醒后继节点 T3
。线程 T3
获取到锁之后,同样也退出等待队列,即将线程 T3
节点变为 head
节点来退出资源获取的等待
此时 AQS 内部队列如下所示:
通过ReentranLock简单总结AQS
理解AQS的两个重要东西
1.state资源变量
2.FIFO等待队列(CLH队列)
如何理解锁竞争:节点去争夺state变量,争夺成功就相当于抢到锁
AQS 使用 int 成员变量 state 表示同步状态
通过内置的FIFO 线程等待/等待队列来完成获取资源线程的排队工作
我们的state变量是用volatile变量修饰的,用于展示当前临界资源的获取锁的情况
AQS模板
AQS定义了一系列的模板方法,我们子类重写它的模板方法
例如CountDownLatch和ReentranLock
所以我们自定义队列同步器的时候,也要重写部分AQS规定的方法
资源获取流程简单小结
初始化时,head节点就是当前拿到锁的节点
初始化后,head节点就是上次拿到锁的节点,拿到锁的节点执行完后他再去唤醒后面的线程
争夺state
获取state变量,如果state=0说明资源没被抢,我们cas来抢state
如果线程不为0,我们看看当前线程是否是当前持有state资源的线程,如果是那就是重入了,我们state➕➕
争夺失败的节点入队
没抢到state的节点入队,开始前检查通过pre是否为null来判断是否初始化
没初始化则用cas操作初始化队列,否则则用cas操作入队列
我们只有发现前一个节点是head的时候我们才可以获取锁,否则就阻塞,然后等待被唤醒
ps:阻塞线程前要跳过cancelled节点
如果获取锁时出现异常失败就会被取消,更新节点状态为cancelled
获取资源后移除当前线程的节点
在成功获取资源之后,就需要将当前线程的节点 从等待队列中移除
移除操作为:将当前等待的线程节点设置为 head 节点(head 节点是虚拟节点,并不参与排队获取资源)
资源释放流程简单小结
我们分为两个步骤,释放锁和唤醒后续节点
释放锁
我们有个boolean类型的free变量表示资源是否释放
释放资源,state减少
检查持有锁的是否为当前线程
如果state不为0,就是还是持有锁状态【例如可重入锁】,我们更新state
如果state为0说明当前线程已经没有可重入次数,我们把持有锁的线程设置为null【说明资源释放,目前没人持有资源】,我们更新state
判断是否可以唤醒后续节点
根据head节点和waitStatus判断
如果是刚初始化【也就是当前节点是head】或者没初始化,就不用找后续节点
如果waitstatus=0,也就是当前节点不是正常等待或取消状态,就不用找后续节点
唤醒后续节点
如果头节点状态为Signal即为-1,表示需要对后续节点进行唤醒
清空头节点状态
通过tail指针从前往后遍历找到第一个正常的节点进行唤醒
CAS操作
修改state,队列入队, 初始化队列都是通过CAS操作来保证我们的并发安全
队列入队用CAS产生的问题
node
节点入队需要修改 node.prev
和 pred.next
两个指针,但是这两个操作并不是 原子操作 ,先修改了 node.prev
指针,之后才修改 pred.next
指针。
错误情况:会导致新入队的节点仅更新了 node.prev
指针,还未更新 pred.next
指针
如下图:
这就是为什么我们我们从尾节点tail开始,从后往前遍历找到正常的节点来唤醒
面试时AQS的回答引导
AQS是什么:抽象队列同步器
AQS的作用是啥:它是Java中大量的锁和同步器的基类,用来构建锁和同步器例如CountDownLatch倒计时器和ReentranLock可重入锁
通过ReentranLock举例子来说明AQS的原理
首先,先说明什么是自旋锁->自旋锁的不公平问题,再介绍到CLH锁
CLH锁是如何优化和解决自旋锁的问题的不公平问题和中心化锁问题以及什么是中心化锁问题
开始介绍AQS里面的东西,也就是我们的CLH队列和State状态变量
然后用ReentranLock的锁争夺和释放例子,看看节点是怎么入队的,资源state是怎么被争夺的