Java并发:AbstractQueuedSynchronizer详解(独占模式)(1)

本文详细解读了AbstractQueuedSynchronizer(AQS)中的核心概念,包括获取和释放操作,状态管理,以及独占和共享模式的实现。重点介绍了acquire和release方法的工作原理,以及如何通过状态整数表示不同同步器的内部逻辑。
摘要由CSDN通过智能技术生成

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个方法也是基本没有什么阻碍。

几个点

===

一些比较难理解或者容易搞混的知识点,先在这里介绍一下,有助于阅读本文和源码。

  1. 注意区分文中提到的队列是“同步队列”还是“条件队列”。“同步队列”通过prev属性和next属性来维护队列,“条件队列”通过nextWaiter属性来维护队列。另外,有些书将prev属性和next属性维护的队列称为“同步队列”,将nextWaiter维护的队列称为“等待队列”。根据源码的注释,其实两个队列都可以称为“等待队列”,因此特以“同步队列”和“条件队列”来区分,请注意。注:本文讲的内容基本都是“同步队列”,“条件队列”是用于Condition的实现。(参考基础属性中的图)

  2. nextWaiter可以分为3种情况:1)共享模式的节点,值固定为源码中的常量SHARED;2)独占模式的普通节点:值固定为源码中的常量EXCLUSIVE,也就是null;3)独占模式的条件队列节点:值指向下一个线程等待在Condition上的节点。如果觉得不好理解,可以参考基础属性下面的图。

  3. AQS里的队列是“CLH”锁定队列的变种, CLH通常用于自旋锁。

  4. prev属性主要用于处理CANCELLED状态。如果节点被取消,其后继节点会向前遍历重新链接到未被取消的前驱节点。

  5. acquire(int) 和 release(int) 方法解释起来比较拗口,正常的语法,动词后面应该带有名词,例如:acquireLock,但是在AQS的源码中并没有这样。因此,在本文中可能会将acquire直接解释成“获取”或直接用“acquire”。

  6. 在实际的使用中,acquire一般都指获取锁。如ReentrantLock中的实现。

  7. 文中提到的唤醒后继节点,即对后继节点的线程使用LockSupport.unpark方法,与之前的park方法(阻塞节点线程)对应。

  8. head节点(头节点)一般是指当前acquire成功的节点(通常就是当前获取到锁的节点),在设置成头节点后,会将该节点的线程设置为null。

  9. 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(); // 中断当前线程

}

  1. 首先是调用tryAcquire方法,在AQS中该方法是没有实现的,子类必须实现,主要用于以独占模式尝试acquire。例如在ReentrantLock中的实现逻辑是:先获取当前的同步状态,再使用CAS尝试将同步状态修改成期望值,如果修改成功将拥有独占访问权的线程设置为当前线程。在ReentrantLock中,acquire指的是获取锁,而tryAcquire即为尝试获取锁。

  2. 如果tryAcquire返回false,则尝试acquire失败了,则会调用addWaiter方法(详解见下文代码块1),添加一个独占模式的节点到同步队列尾部。 并调用acquireQueued方法(详解见下文代码块3)尝试acquire。

  3. 最后,如果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;

}

}

}

}

  1. 如果队列为空,则先初始化head和tail节点(介绍属性时说过了,head和tail采用懒汉模式初始化),再使用CAS将node添加到队列尾部。

  2. 如果队列不为空,直接使用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尝试,走到这边代表出现异常

}

}

  1. 该方法用于添加完节点后调用,首先判断node节点的前驱节点是否为head,如果是,node会尝试acquire,如果node成功acquire,会调用setHead方法,将node设置为head、将node的thread设置为null、将node的prev设置为null,这保证了数据结构中头节点永远是一个不带Thread的空节点。

  2. 如果node节点的前驱节点不是head,或者node尝试acquire失败,则会调用shouldParkAfterFailedAcquire方法(详解见下文代码块4)校验node是否需要park(此处park是将node的线程阻塞,LockSupport.park),如果shouldParkAfterFailedAcquire返回true则调用parkAndCheckInterrupt方法(详解见下文代码块5)将node的线程阻塞。

  3. 如果走到finally方法时,failed为true,则代表出现了异常,调用cancelAcquire方法(详解见下文代码块6)取消正在进行的acquire尝试。

代码块4:shouldParkAfterFailedAcquire方法


结局:总结+分享

看完美团、字节、腾讯这三家的一二三面试问题,是不是感觉问的特别多,可能咱们真的又得开启面试造火箭、工作拧螺丝的模式去准备下一次的面试了。

开篇有提及我可是足足背下了Java互联网工程师面试1000题,多少还是有点用的呢,换汤不换药,不管面试官怎么问你,抓住本质即可!能读到此处的都是真爱

  • Java互联网工程师面试1000题

image.png

而且从上面三家来看,算法与数据结构是必备不可少的呀,因此我建议大家可以去刷刷这本左程云大佬著作的 《程序员代码面试指南 IT名企算法与数据结构题目最优解》,里面近200道真实出现过的经典代码面试题。

  • 程序员代码面试指南–IT名企算法与数据结构题目最优解

image.png

  • 其余像设计模式,建议可以看看下面这4份PDF(已经整理)

image.png

  • 更多的Java面试学习笔记如下,关于面试这一块,我额外细分出Java基础-中级-高级开发的面试+解析,以及调优笔记等等等。。。

image.png

以上所提及的全部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及笔记,如若皆是你所需要的,那么都可发送给你!

本文已被CODING开源项目:【一线大厂Java面试题解析+核心总结学习笔记+最新讲解视频+实战项目源码】收录

  • 7
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值