AbstractQueuedSynchronizer介绍
============================
大多数开发者都不会直接使用AQS,标准同步器类的集合能够满足绝大多数情况的需求。但如果能了解标准同步器类的实现方式,那么对于理解它们的工作原理是非常有帮助的。
在基于AQS构建的同步器类中,最基本的操作包括各种形式的获取操作和释放操作。获取操作是一种依赖状态的操作,并且通常会阻塞。当使用锁或信号量时,“获取”操作的含义就很直观,即获取的是锁或者许可,并且调用者可能会一直等待直到同步器类处于可被获取的状态。在使用CountDownLatch时,“获取”操作意味着“等待并直到闭锁到达结束状态”,而在使用FutureTask时,则意味着“等待并直到任务已经完成”。“释放”并不是一个可阻塞的操作,当执行“释放”操作时,所有在请求时被阻塞的线程都会开始执行。
如果一个类想成为状态依赖的类,那么它必须拥有一些状态。AQS负责管理同步器类中的状态,它管理了一个整数状态信息,可以通过getstate,setState以及compareAndSetState等 protected类型方法来进行操作。这个整数可以用于表示任意状态。例如,ReentrantLock用它来表示所有者线程已经重复获取该锁的次数,Semaphore用它来表示剩余的许可数量,FutureTask 用它来表示任务的状态(尚未开始、正在运行、已完成以及已取消)。在同步器类中还可以自行管理一些额外的状态变量,例如,ReentrantLock保存了锁的当前所有者的信息,这样就能区分某个获取操作是重人的还是竞争的。
重要入口方法
======
AQS里面最重要的就是两个操作和一个状态:获取操作(acquire)、释放操作(release)、同步状态(state)。两个操作通过各种条件限制,总共有8个重要的方法,6个获取方法,2个释放方法,如下:
-
acquire(int):独占模式的获取,忽略中断。
-
acquireInterruptibly(int):独占模式的获取,可中断
-
tryAcquireNanos(int, long):独占模式的获取,可中断,并且有超时时间。
-
release(int):独占模式的释放。
-
acquireShared(int):共享模式的获取,忽略中断。
-
acquireSharedInterruptibly(int):共享模式的获取,可中断
-
tryAcquireSharedNanos(int, long):共享模式的获取,可中断,并且有超时时间。
-
releaseShared(int):共享模式的释放。
而各个获取方法和释放方法其实大同小异,因此本文只对acquire(int)和release(int)方法展开详解(即独占模式下忽略中断的获取和释放),搞懂了这2个方法,读懂其他6个方法也是基本没有什么阻碍。
几个点
===
一些比较难理解或者容易搞混的知识点,先在这里介绍一下,有助于阅读本文和源码。
-
注意区分文中提到的队列是“同步队列”还是“条件队列”。“同步队列”通过prev属性和next属性来维护队列,“条件队列”通过nextWaiter属性来维护队列。另外,有些书将prev属性和next属性维护的队列称为“同步队列”,将nextWaiter维护的队列称为“等待队列”。根据源码的注释,其实两个队列都可以称为“等待队列”,因此特以“同步队列”和“条件队列”来区分,请注意。注:本文讲的内容基本都是“同步队列”,“条件队列”是用于Condition的实现。(参考基础属性中的图)
-
nextWaiter可以分为3种情况:1)共享模式的节点,值固定为源码中的常量SHARED;2)独占模式的普通节点:值固定为源码中的常量EXCLUSIVE,也就是null;3)独占模式的条件队列节点:值指向下一个线程等待在Condition上的节点。如果觉得不好理解,可以参考基础属性下面的图。
-
AQS里的队列是“CLH”锁定队列的变种, CLH通常用于自旋锁。
-
prev属性主要用于处理CANCELLED状态。如果节点被取消,其后继节点会向前遍历重新链接到未被取消的前驱节点。
-
acquire(int) 和 release(int) 方法解释起来比较拗口,正常的语法,动词后面应该带有名词,例如:acquireLock,但是在AQS的源码中并没有这样。因此,在本文中可能会将acquire直接解释成“获取”或直接用“acquire”。
-
在实际的使用中,acquire一般都指获取锁。如ReentrantLock中的实现。
-
文中提到的唤醒后继节点,即对后继节点的线程使用LockSupport.unpark方法,与之前的park方法(阻塞节点线程)对应。
-
head节点(头节点)一般是指当前acquire成功的节点(通常就是当前获取到锁的节点),在设置成头节点后,会将该节点的线程设置为null。
-
waitStatus=CANCELLED的节点是要丢弃(跳过)的节点,在cancelAcquire(Node)方法中,最直接的办法应该是将node节点移除,但是源码中进行了更优的处理,再移除node节点的同时,将node前面和后面的连续节点waitStatus=CANCELLED的也一并移除了。(参考下文cancelAcquire方法的图)
基础属性
====
static final class Node {
/** Marker to indicate a node is waiting in shared mode */
static final Node SHARED = new Node(); // 标记节点正在以共享模式等待
/** Marker to indicate a node is waiting in exclusive mode */
static final Node EXCLUSIVE = null; // 标记节点正在以独占模式等待
// 表示线程已取消:由于在同步队列中等待的线程等待超时或者被中断,
// 需要从同步队列中取消等待,节点进入该状态将不会变化(即要移除/跳过的节点)
static final int CANCELLED = 1;
// 表示后继节点处于park,需要唤醒:后继节点的线程处于park,而当前节点的线
// 程如果进行释放操作或者被取消,将会通知后继节点,使后继节点的线程得以运行
static final int SIGNAL = -1;
// 表示线程正在等待状态:即节点在等待队列中,节点线程等待在Condition上,
// 当其他线程对Condition调用了signal()方法后,该节点将会从等待队列中转移到同步队列中
//(即该节点的线程调用了Condition.await()方法,需要先唤醒才能进入同步队列)
static final int CONDITION = -2;
// 表示下一次共享模式同步状态获取讲会无条件地被传播下去
static final int PROPAGATE = -3;
// 即上面的CANCELLED/SIGNAL/CONDITION/PROPAGATE,初始状态为0
volatile int waitStatus; // 等待状态
volatile Node prev; // 前驱节点
volatile Node next; // 后继节点
volatile Thread thread; // 节点的线程(获取同步状态的线程)
// 条件队列(注意和同步队列区分)中的后继节点:参见addConditionWaiter方法,
// 表示下一个等待Condition的Node,如果当前节点是共享的,那么这个字段将是一个
// SHARED常量,也就是说节点类型(独占和共享)和等待队列中的后继节点共用一个字段。
Node nextWaiter;
final boolean isShared() { // 如果节点在共享模式下等待,则返回true。
return nextWaiter == SHARED;
}
// 返回节点的前驱节点,如果为null,则抛出NullPointerException
final Node predecessor() throws NullPointerException {
Node p = prev;
if (p == null)
throw new NullPointerException();
else
return p;
}
Node() { // 用于创建头节点或SHARED标记
}
Node(Thread thread, Node mode) { // Used by addWaiter
this.nextWaiter = mode;
this.thread = thread;
}
Node(Thread thread, int waitStatus) { // Used by Condition
this.waitStatus = waitStatus;
this.thread = thread;
}
}
// 同步队列的头节点,使用懒汉模式初始化。 除了初始化,它只能通过setHead方法修改。
// 注意:如果头节点存在,其waitStatus保证不是CANCELLED。
private transient volatile Node head;
// 同步队列的尾节点,使用懒汉模式初始化。仅通过enq方法修改,用于添加新的等待节点。
private transient volatile Node tail;
// 同步状态, volatile修饰,很多同步类的实现都用到了该变量,
// 例如:ReentrantLock、CountDownLatch等
private volatile int state;
// 返回当前的同步状态
protected final int getState() {
return state;
}
// 设置同步状态值
protected final void setState(int newState) {
state = newState;
}
// 使用CAS修改同步状态值
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
AQS中Node是组成队列的数据结构,如下图是队列的数据结构图:
acquire方法
=========
public final void acquire(int arg) {
// tryAcquire(arg)方法:提供给子类实现的,主要用于以独占模式尝试acquire
if (!tryAcquire(arg) &&
// addWaiter方法:添加一个独占模式的节点到同步队列的尾部;
// acquireQueued:该节点尝试acquire
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt(); // 中断当前线程
}
-
首先是调用tryAcquire方法,在AQS中该方法是没有实现的,子类必须实现,主要用于以独占模式尝试acquire。例如在ReentrantLock中的实现逻辑是:先获取当前的同步状态,再使用CAS尝试将同步状态修改成期望值,如果修改成功将拥有独占访问权的线程设置为当前线程。在ReentrantLock中,acquire指的是获取锁,而tryAcquire即为尝试获取锁。
-
如果tryAcquire返回false,则尝试acquire失败了,则会调用addWaiter方法(详解见下文代码块1),添加一个独占模式的节点到同步队列尾部。 并调用acquireQueued方法(详解见下文代码块3)尝试acquire。
-
最后,如果acquireQueued返回true,则调用selfInterrupt方法中断当前线程,这是因为acquireQueued返回true就是代表线程被中断。
代码块1:addWaiter方法
private Node addWaiter(Node mode) {
// 以当前线程和mode为参数,创建一个节点
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail; // 将pred赋值为当前尾节点
if (pred != null) { // pred不为空
// 将新创建的节点的前驱节点设置为pred,即将刚创建的节点放到尾部
node.prev = pred;
// 使用CAS将尾节点修改为新节点
if (compareAndSetTail(pred, node)) {
// 尾节点修改成功后,将pred的后继节点设置为新节点,与上文node.prev=pred对应
pred.next = node;
return node;
}
}
// 如果pred为空,代表此时同步队列为空,调用enq方法将新节点添加到同步队列
enq(node);
return node;
}
根据当前线程和入参mode创建一个新的Node,并放到尾部。如果同步队列为空,则调用enq方法(详解见下文代码块2)添加节点。
代码块2:enq方法
// 将节点插入队列,如果队列为空则先进行初始化,再插入队列。
private Node enq(final Node node) {
for (;😉 {
Node t = tail; // 将t赋值为尾节点
// 如果尾节点为空,则初始化head和tail节点
if (t == null) {
// 使用CAS将头节点赋值为一个新创建的无状态的节点
if (compareAndSetHead(new Node()))
tail = head; // 初始化尾节点
} else { // 如果尾节点不为空,使用CAS将当前node添加到尾节点
node.prev = t; // 将node的前驱节点设置为t
if (compareAndSetTail(t, node)) { // 使用CAS将尾节点设置为node
// 成功将尾节点修改为node后,将t的后驱节点设置为node,与node.prev=t对应
t.next = node;
return t;
}
}
}
}
-
如果队列为空,则先初始化head和tail节点(介绍属性时说过了,head和tail采用懒汉模式初始化),再使用CAS将node添加到队列尾部。
-
如果队列不为空,直接使用CAS将node添加到队列尾部。
该方法和上面的addWaiter方法其实很相似,只是多了一个队列为空时的初始化head和tail操作。
代码块3:acquireQueued方法
// 添加完节点后,立即尝试该节点是否能够成功acquire
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false; // 用于判断是否被中断过
for (;😉 { // 自旋过程
final Node p = node.predecessor(); // 将p赋值为node的前驱节点
// 如果p为头节点,则node节点尝试以独占模式acquire(acquire一般为获取锁)
if (p == head && tryAcquire(arg)) {
// node节点成功以独占模式acquire,调用setHead方法将node设置为头节点
setHead(node);
p.next = null; // 断开原头节点与node节点的关联
failed = false;
return interrupted; // 返回node是否被中断过
}
// shouldParkAfterFailedAcquire: 校验node是否需要park(park:会将node的线程阻塞)
// 只有当前驱节点等待状态为SIGNAL,才能将node进行park,因为当前驱节点为SIGNAL
// 时,会保证来唤醒自己,因此可以安心park
if (shouldParkAfterFailedAcquire(p, node) &&
// node进入park状态,直到被前驱节点唤醒,被唤醒后返回线程是否为中断状态
parkAndCheckInterrupt())
interrupted = true; // 在等待过程中被中断
}
} finally {
if (failed)
cancelAcquire(node); // 取消正在进行的acquire尝试,走到这边代表出现异常
}
}
-
该方法用于添加完节点后调用,首先判断node节点的前驱节点是否为head,如果是,node会尝试acquire,如果node成功acquire,会调用setHead方法,将node设置为head、将node的thread设置为null、将node的prev设置为null,这保证了数据结构中头节点永远是一个不带Thread的空节点。
-
如果node节点的前驱节点不是head,或者node尝试acquire失败,则会调用shouldParkAfterFailedAcquire方法(详解见下文代码块4)校验node是否需要park(此处park是将node的线程阻塞,LockSupport.park),如果shouldParkAfterFailedAcquire返回true则调用parkAndCheckInterrupt方法(详解见下文代码块5)将node的线程阻塞。
-
如果走到finally方法时,failed为true,则代表出现了异常,调用cancelAcquire方法(详解见下文代码块6)取消正在进行的acquire尝试。
代码块4:shouldParkAfterFailedAcquire方法
结局:总结+分享
看完美团、字节、腾讯这三家的一二三面试问题,是不是感觉问的特别多,可能咱们真的又得开启面试造火箭、工作拧螺丝的模式去准备下一次的面试了。
开篇有提及我可是足足背下了Java互联网工程师面试1000题,多少还是有点用的呢,换汤不换药,不管面试官怎么问你,抓住本质即可!能读到此处的都是真爱
- Java互联网工程师面试1000题
而且从上面三家来看,算法与数据结构是必备不可少的呀,因此我建议大家可以去刷刷这本左程云大佬著作的 《程序员代码面试指南 IT名企算法与数据结构题目最优解》,里面近200道真实出现过的经典代码面试题。
- 程序员代码面试指南–IT名企算法与数据结构题目最优解
- 其余像设计模式,建议可以看看下面这4份PDF(已经整理)
- 更多的Java面试学习笔记如下,关于面试这一块,我额外细分出Java基础-中级-高级开发的面试+解析,以及调优笔记等等等。。。
以上所提及的全部Java面试学习的PDF及笔记,如若皆是你所需要的,那么都可发送给你!
质即可!能读到此处的都是真爱
- Java互联网工程师面试1000题
[外链图片转存中…(img-lMHmVOSJ-1714367451815)]
而且从上面三家来看,算法与数据结构是必备不可少的呀,因此我建议大家可以去刷刷这本左程云大佬著作的 《程序员代码面试指南 IT名企算法与数据结构题目最优解》,里面近200道真实出现过的经典代码面试题。
- 程序员代码面试指南–IT名企算法与数据结构题目最优解
[外链图片转存中…(img-X3bXToXZ-1714367451816)]
- 其余像设计模式,建议可以看看下面这4份PDF(已经整理)
[外链图片转存中…(img-n9jEbDnv-1714367451816)]
- 更多的Java面试学习笔记如下,关于面试这一块,我额外细分出Java基础-中级-高级开发的面试+解析,以及调优笔记等等等。。。
[外链图片转存中…(img-OdxQU0BM-1714367451817)]
以上所提及的全部Java面试学习的PDF及笔记,如若皆是你所需要的,那么都可发送给你!