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

微信搜索【程序员囧辉】,关注这个坚持分享技术干货的程序员。

概述

==

AQS(AbstractQueuedSynchronizer)是一个用于构建锁和同步器的框架,许多同步器都可以通过AQS很容易并且高效地构造出来。不仅ReentrantLock和Semaphore是基于AQS构建的,还包括CountDownLatch、ReentrantReadWriteLock、SynchronousQueue和FutureTask。

AQS解决了在实现同步器时涉及的大量细节问题,例如等待线程采用FIFO队列操作顺序。在不同的同步器中还可以定义一些灵活的标准来判断某个线程是应该通过还是需要等待。基于AQS来构建同步器能带来许多好处。它不仅能极大地减少实现工作,而且也不处理在多个位置上发生的竞争问题(这是在没有使用AQS来构建同步器时的情况)。在 SemaphoreOnLock中,获取许可的操作可能在两个时刻阻塞一一一当锁保护信号量状态时,以及当许可不可用时。在基于AQS构建的同步器中,只可能在一个时刻发生阻塞,从而降低上下文切换的开销,并提高吞吐量。在设计AQS时充分考虑了可伸缩性,因此java.util.concurrent 中所有基于AQS构建的同步器都能获得这个优势。

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尝试,走到这边代表出现异常

}

}
自我介绍一下,小编13年上海交大毕业,曾经在小公司待过,也去过华为、OPPO等大厂,18年进入阿里一直到现在。

深知大多数Java工程师,想要提升技能,往往是自己摸索成长或者是报班学习,但对于培训机构动则几千的学费,着实压力不小。自己不成体系的自学效果低效又漫长,而且极易碰到天花板技术停滞不前!

因此收集整理了一份《2024年Java开发全套学习资料》,初衷也很简单,就是希望能够帮助到想自学提升又不知道该从何学起的朋友,同时减轻大家的负担。img

既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,基本涵盖了95%以上Java开发知识点,真正体系化!

由于文件比较大,这里只是将部分目录截图出来,每个节点里面都包含大厂面经、学习笔记、源码讲义、实战项目、讲解视频,并且会持续更新!

如果你觉得这些内容对你有帮助,可以扫码获取!!(备注Java获取)

img

最后的内容

在开头跟大家分享的时候我就说,面试我是没有做好准备的,全靠平时的积累,确实有点临时抱佛脚了,以至于我自己还是挺懊恼的。(准备好了或许可以拿个40k,没做准备只有30k+,你们懂那种感觉吗)

如何准备面试?

1、前期铺垫(技术沉积)

程序员面试其实是对于技术的一次摸底考试,你的技术牛逼,那你就是大爷。大厂对于技术的要求主要体现在:基础,原理,深入研究源码,广度,实战五个方面,也只有将原理理论结合实战才能把技术点吃透。

下面是我会看的一些资料笔记,希望能帮助大家由浅入深,由点到面的学习Java,应对大厂面试官的灵魂追问

这部分内容过多,小编只贴出部分内容展示给大家了,见谅见谅!

  • Java程序员必看《Java开发核心笔记(华山版)》

  • Redis学习笔记

  • Java并发编程学习笔记

四部分,详细拆分并发编程——并发编程+模式篇+应用篇+原理篇

  • Java程序员必看书籍《深入理解 ava虚拟机第3版》(pdf版)

  • 大厂面试必问——数据结构与算法汇集笔记

其他像Spring,SpringBoot,SpringCloud,SpringCloudAlibaba,Dubbo,Zookeeper,Kafka,RocketMQ,RabbitMQ,Netty,MySQL,Docker,K8s等等我都整理好,这里就不一一展示了。

2、狂刷面试题

技术主要是体现在平时的积累实用,面试前准备两个月的时间再好好复习一遍,紧接着就可以刷面试题了,下面这些面试题都是小编精心整理的,贴给大家看看。

①大厂高频45道笔试题(智商题)

②BAT大厂面试总结(部分内容截图)

③面试总结

3、结合实际,修改简历

程序员的简历一定要多下一些功夫,尤其是对一些字眼要再三斟酌,如“精通、熟悉、了解”这三者的区别一定要区分清楚,否则就是在给自己挖坑了。当然不会包装,我可以将我的简历给你参考参考,如果还不够,那下面这些简历模板任你挑选:

以上分享,希望大家可以在金三银四跳槽季找到一份好工作,但千万也记住,技术一定是平时工作种累计或者自学(或报班跟着老师学)通过实战累计的,千万不要临时抱佛脚。

另外,面试中遇到不会的问题不妨尝试讲讲自己的思路,因为有些问题不是考察我们的编程能力,而是逻辑思维表达能力;最后平时要进行自我分析与评价,做好职业规划,不断摸索,提高自己的编程能力和抽象思维能力。
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!
6)]

③面试总结

[外链图片转存中…(img-YgyZm6hM-1712611475936)]

[外链图片转存中…(img-aPz8kTHi-1712611475937)]

3、结合实际,修改简历

程序员的简历一定要多下一些功夫,尤其是对一些字眼要再三斟酌,如“精通、熟悉、了解”这三者的区别一定要区分清楚,否则就是在给自己挖坑了。当然不会包装,我可以将我的简历给你参考参考,如果还不够,那下面这些简历模板任你挑选:

[外链图片转存中…(img-55KI6auM-1712611475937)]

以上分享,希望大家可以在金三银四跳槽季找到一份好工作,但千万也记住,技术一定是平时工作种累计或者自学(或报班跟着老师学)通过实战累计的,千万不要临时抱佛脚。

另外,面试中遇到不会的问题不妨尝试讲讲自己的思路,因为有些问题不是考察我们的编程能力,而是逻辑思维表达能力;最后平时要进行自我分析与评价,做好职业规划,不断摸索,提高自己的编程能力和抽象思维能力。
《互联网大厂面试真题解析、进阶开发核心学习笔记、全套讲解视频、实战项目源码讲义》点击传送门即可获取!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值