大家好,我是王有志,欢迎和我聊技术,聊漂泊在外的生活。快来加入我们的Java提桶跑路群:共同富裕的Java人

AQS的前世,从1990年的论文说起》中我们已经对AQS做了简单的介绍,并学习了先于AQS出现的3种基于排队思想的自旋锁。今天我们深入到AQS的设计中,探究Doug Lea是如何构建JUC框架基础组件的。不过在正式开始前,我们先来回顾上一篇中提到的面试题:

  • 原理相关:AQS是什么?它是怎样实现的?

  • 设计相关:如何使用AQS实现Mutex?

希望今天可以帮你解答上面的问题。

Tips

初衷与目的

《The java.util.concurrent Synchronizer Framework》中清晰的阐述了Doug Lea设计AQS的目的:

This framework provides common mechanics for atomically managing synchronization state, blocking and unblocking threads, and queuing.

(AQS)框架为同步状态的原子性管理,线程的阻塞和唤醒以及排队提供了一种通用的机制。也就是说,可以通过AQS去构建不同的同步器,如:基于AQS而诞生的ReentrantLock

基于构建通用同步机制的目的,Doug Lea分析了各种同步器,总结出它们共同的特性:

  • acquire操作:阻塞调用线程,直到同步状态允许其继续执行;

  • release操作:改变同步状态,唤醒被阻塞的线程。

除此之外,论文中也提到了对AQS的性能要求,Doug Lea认为大家在分析synchronized时提到的2个问题:

  • 如何最小化空间开销(因为任意Java对象都可以作为锁)

  • 如何最小化单核处理器的单线程环境下的时间开销

都不是AQS要考虑的,他认为AQS需要考虑的是scalability(可伸缩性),即大部分场景中,即便存在竞争,也能提供稳定的效率。原文中是这样描述的:

Among the main goals is to minimize the total amount of time during which some thread is permitted to pass a synchronization point but has not done so.

(AQS)主要目标之一是使某一线程被允许通过同步点但还没有通过的情况下耗费的总时间最少,即从一个线程释放锁开始,到另一个线程获取锁,这个过程锁消耗的时间。

设计与实现

Doug Lea先是完成了acquire操作和release操作的伪代码设计:

// acquire操作
while (synchronization state does not allow acquire) {
enqueue current thread if not already queued;
possibly block current thread;
}
dequeue current thread if it was queued; // release操作
update synchronization state;
if (state may permit a blocked thread to acquire)
unblock one or more queued threads;

为了实现上述的操作,需要以下组件的协同工作:原子管理的同步状态,线程的阻塞与唤醒,以及队列

同步状态

AQS使用volatile修饰的int类型变量state保存同步状态,并提供getStatesetStatecompareAndSetState方法。

AQS中,state不仅用作表示同步状态,也是某些同步器实现的计数器,如:Semaphore中允许通过的线程数量,ReentrantLock中可重入特性的实现,都依赖于state作为计数器的特性。

早期,Java对long类型变量的原子操作需要借助内置锁来完成,性能较差,并且除了CyclicBarrier外,其余同步器使用32位的int类型已经能够满足需求,因此在AQS诞生初期,state可以使用int类型。

Tips

  • CyclicBarrier通过锁来实现;

  • Java 1.6中提供了使用long类型的AbstractQueuedLongSynchronizer

  • 注意要区别同步状态与线程在队列中的状态。

阻塞与唤醒

早期,线程的阻塞与唤醒只能通过Thread.suspendThread.resume实现,但存在竞态问题,即一个线程先调用了Thread.resume后调用Thread.suspend,那么Thread.resume不会产生任何作用。

AQS使用LockSupport.parkLockSupport.unpark实现阻塞与唤醒,特点是如果LockSupport.unpark发生在LockSupport.park前,则此次的LockSupport.park无效。

Tips:无论提前调用多少次LockSupport.unpark,都只会使后一次LockSupport.park无效。

CLH队列

队列的设计是构建AQS的关键,Doug Lea在论文中使用“The heart of”来形容:

The heart of the framework is maintenance of queues of blocked threads, which are restricted here to FIFO queues.

Doug Lea参考了CLH的设计, 保留了基本的设计,由前驱节点做阻塞与唤醒的控制,但是在队列的选择上做出了改变,AQS选择双向链表来实现队列,节点中添加了prevnext指针。

添加prev指针主要是为了实现取消功能,而next指针的加入可以方便的实现唤醒后继节点。

AQS源码分析

再次强调,本文基于Java 11完成,与Java 8的源码存在差异,如,操作同步状态state时,Java 8借助了UnSafe,而Java 11中使用了VarHandle。另外,本文只讨论AQS的独占(EXCLUSIVE)模式因此会跳过共享(SHARED)模式

队列的结构

有了《AQS的前世,从1990年的论文说起》的铺垫,再结合Doug Lea论文中的描述,我们可以很容易想象到AQS中队列节点的结构:线程状态,前驱节点指针,后继节点指针以及用于保存线程的变量。事实也和我们的猜想十分接近:

static final class Node {

  volatile int waitStatus;

  volatile Node prev;

  volatile Node next;

  volatile Thread thread;

  Node nextWaiter;
}

注意,NodewaitStatus表示线程在队列中的状态,AQS的state表示同步器的状态。Node中定义了waitStatus的5种状态:

  • CANCELLED:1,线程获取锁的请求已经取消;

  • SIGNAL :-1,节点释放后,需要唤醒后继节点

  • CONDITION:-2,节点处于条件队列中;

  • PROPAGATE:-3,共享(SHARED)模式下使用;

  • 0,初始化Node时的默认值。

AQS的实现中,并不是后继节点“监听”前驱节点的状态,来决定自身是否持有锁,而是通过前驱节点释放锁,并主动唤醒后继节点来实现排队的

AQS的结构

AQS的结构就更加简单了:

private transient volatile Node head;
private transient volatile Node tail;
private volatile int state; static final long SPIN_FOR_TIMEOUT_THRESHOLD = 1000L;

总共4个成员变量,除了我们意料之中的,队列的头尾节点和AQS的同步状态外,还有SPIN_FOR_TIMEOUT_THRESHOLD。看名字会有些误解,以为是自旋的阈值,实际上并不是,AQS提供了带有超时时间的方法,例如doAcquireNanos方法:

private boolean doAcquireNanos(int arg, long nanosTimeout) throws InterruptedException {
final long deadline = System.nanoTime() + nanosTimeout;
final Node node = addWaiter(Node.EXCLUSIVE);
for (;;) {
final Node p = node.predecessor();
nanosTimeout = deadline - System.nanoTime();
if (shouldParkAfterFailedAcquire(p, node) && nanosTimeout > SPIN_FOR_TIMEOUT_THRESHOLD) {
LockSupport.parkNanos(this, nanosTimeout);
}
}

可以看到只有在剩余的nanosTimeout大于SPIN_FOR_TIMEOUT_THRESHOLD时,才会调用LockSupport.parkNanos(this, nanosTimeout)

Tips

  • Java 11中,无论是AQS还是Node中都使用了VarHandle,定义了大量的成员变量,我们跳过这部分;

  • 删除了doAcquireNanos方法中大部分内容,重点展示nanosTimeoutSPIN_FOR_TIMEOUT_THRESHOLD的关系。

获取锁

如果是你,你会如何设计AQS的加锁过程?

我可能会“按部就班”的构建队列,并将等待线程逐个的加入的队列中。那Doug Lea是如何设计AQS加锁过程的呢?

public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}

acquire方法中,Doug Lea设计了4步操作,如果仅从名字来看,首先tryAcquire尝试获取锁,如果获取失败,则通过addWaiter加入等待,然后调用acquireQueued方法进入排队状态,最后是通过调用selfInterrupt方法使当前线程中断。先来看AQS中的tryAcquire方法。

protected boolean tryAcquire(int arg) {
throw new UnsupportedOperationException();
}

AQS中并未给出任何实现,它要求子类必须重写tryAcquire方法,否则抛出异常。

addWaiter方法

接着是addWaiter方法:

private Node addWaiter(Node mode) {
// 注释1:创建节点,通过acquire进入时mode = Node.EXCLUSIVE
Node node = new Node(mode);
for (;;) {
// 注释2:获取尾节点
Node oldTail = tail;
if (oldTail != null) {
// 注释5:添加新的尾节点
node.setPrevRelaxed(oldTail);
if (compareAndSetTail(oldTail, node)) {
oldTail.next = node;
return node;
}
} else {
// 注释3:尾节点为空则初始化队列
initializeSyncQueue();
}
}
} static final class Node {
Node(Node nextWaiter) {
this.nextWaiter = nextWaiter;
// 可以看做是:this.thread = Thread.currentThread()
THREAD.set(this, Thread.currentThread());
}
} private final void initializeSyncQueue() {
Node h;
// 注释4:创建空节点,作为尾节点
if (HEAD.compareAndSet(this, null, (h = new Node())))
tail = h;
}

addWaiter的逻辑并不复杂:

  • 注释1:创建节点node

  • 注释2:获取AQS的尾节点oldTail,并判断是否存在尾节点;

  • 注释3:初始化队列;

  • 注释4:创建空节点h,作为AQS的头尾节点;

  • 注释5:更新AQS的尾节点为node,并与oldTail关联。

我们知道,只有tryAcquire失败后,才会调用addWaiter方法,也就是说,如果实现了tryAcquire获取锁的逻辑,那么在没有竞争的场景下,AQS就不会构建等待队列

另外我注意到在初始化队列时,Doug Lea为等待队列添加了一个空的头节点,我的理解是,这里使用了处理链表的常用技巧:虚拟头节点

回过头来看addWaiter做了什么?它的核心功能是初始化的等待队列,并返回当前队列的尾节点

acquireQueued方法

addWaiter创建了等待队列并返回尾节点后,就进入了acquireQueued方法中:

final boolean acquireQueued(final Node node, int arg) {
// 是否中断的标记
boolean interrupted = false;
try {
for (;;) {
// 注释1:获取node的前驱节点,node更名为currentNode更方便我理解
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null;
return interrupted;
}
// 注释2:判断是否需要park当前线程
if (shouldParkAfterFailedAcquire(p, node))
interrupted |= parkAndCheckInterrupt();
}
} catch (Throwable t) {
cancelAcquire(node);
if (interrupted)
selfInterrupt();
throw t;
}
}

注释1的部分,获取到node的前驱节点p,如果p为头节点,则当前线程直接通过tryAcquire尝试获取锁。如果p不是头节点的话可以直接调用tryAcquire吗?

答案是不可以,如果p不是头节点,也就证明当前线程不在获取锁的第二顺位上,前面可能还有若干节点在等待锁,如果任意节点都直接调用tryAcquire,那就失去了acquireQueued方法的意义。

注释2的部分,p不是头节点的情况,也就是当前线程非第二顺位获取锁。那么node就要根据前驱节点的状态来判断是否中断执行了:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
// 获取前驱节点的状态,waitStatus初始化的状态为0
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
// 注释2:前驱节点处于Node.SIGNAL状态
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 注释1:更新前驱节点的状态为Node.SIGNAL
pred.compareAndSetWaitStatus(ws, Node.SIGNAL);
}
return false;
} private final boolean parkAndCheckInterrupt() {
// 暂停线程
LockSupport.park(this);
return Thread.interrupted();
}

addWaiter的流程中可以看到,处理node的过程中并没有处理node.waitStatus,此时waitStatus == 0,那么对于node的前驱节点pred也是一样的,因次第一次执行shouldParkAfterFailedAcquire方法时,会进入注释1的部分,并返回false

再次进入acquireQueued的循环后,shouldParkAfterFailedAcquire返回true,执行parkAndCheckInterrupt方法,需要注意LockSupport.park(this)会让线程暂停在此处,也就是说如果没有线程唤醒,线程会一直停留在此处

至此,AQS的加锁过程已经结束了,我们画张图来回顾下这个过程:

释放锁

接着来看解锁的过程:

public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}

按照AQS的风格tryRelease必然是要交给子类实现的:

protected boolean tryRelease(int arg) {
throw new UnsupportedOperationException();
}

果不其然。

假设tryRelease执行成功,接下来会发生什么?

  • 获取头节点h;

  • 判断头节点的状态h.waitStatus != 0

  • 执行unparkSuccessor

来看unparkSuccessor的代码:

private void unparkSuccessor(Node node) {
// node是当前线程的前驱节点,也是head节点
int ws = node.waitStatus;
if (ws < 0)
// 处理node的waitStatus
node.compareAndSetWaitStatus(ws, 0);
Node s = node.next;
// 注释2:从后向前遍历待唤醒的节点
if (s == null || s.waitStatus > 0) {
s = null;
for (Node p = tail; p != node && p != null; p = p.prev)
if (p.waitStatus <= 0)
s = p;
}
// 注释1:唤醒后继节点
if (s != null)
LockSupport.unpark(s.thread);
}

如果一切顺利,那么unparkSuccessor时会跳过注释2的部分,直接执行注释1的LockSupport.unpark

不过别忘了,待唤醒的线程此时还在acquireQueued方法中阻塞着,唤醒的线程会继续执行acquireQueued中的内容,调用tryAcquire获取锁,并更新AQS的头节点

我们设想一个场景:

addWaiter执行到compareAndSetTail(oldTail, node)时调用了unparkSuccessor,可能会出现一种情况:

即T1已经与HEAD建立了联系,但HEAD却没有与T1建立联系。因此注释2中,判断HEAD节点没有后继节点时,会通过TAIL节点,从后向前遍历等待队列,查找待唤醒的线程。

好了,AQS的核心源码分析到这里就结束了,至于条件队列,共享模式等就留给大家自行探索了。

构建互斥锁

学习完AQS的核心原理后,我们来实践一下,借助AQS来构建构建互斥锁:

public class MutexLock {
public void lock() {
sync.acquire(1);
} public void unlock() {
sync.release(0);
} private final Sync sync = new Sync(); static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
Thread currentThread = Thread.currentThread();
if(compareAndSetState(0, arg)) {
setExclusiveOwnerThread(currentThread);
return true;
}else {
return false;
}
} @Override
protected boolean tryRelease(int arg) {
if(getState() != 1) {
return false;
} setState(arg);
return true;
}
}
}

通过AQS实现只有基础功能的互斥锁还是非常简单的,甚至在重写tryAcquire方法时可以不设置独占线程(虽然在现在也没起到作用),只是简单的使用CAS替换掉AQS的state即可:

@Override
protected boolean tryAcquire(int arg) {
return compareAndSetState(0, arg);
}

当然了,这只是一把“玩具锁”,还存在许多问题,比如,非上锁线程依旧可以解锁。其次除了阻塞还排队外,也不支持诸如可重入等高级特性。

结语

好了,关于AQS的部分就到这里了。如果你有看过《AQS的前世,从1990年的论文说起》中基于排队思想自旋锁的演进过程,并理解了MCS锁和CLH锁的实现,那么理解AQS对你来说是非常容易的,虽然它们看起来是不同的东西,但核心原理是相同的,只是在技术实现上有些许差别。

最后,希望通过AQS的前世和今生,能够帮助你重新认识AQS,理解Doug Lea设计这样一个同步器基础组件的意义。


好了,今天就到这里了,Bye~~

15.AQS的今生,构建出JUC的基础的更多相关文章

  1. AQS源码二探-JUC系列

    本文已在公众号上发布,感谢关注,期待和你交流. AQS源码二探-JUC系列 共享模式 doAcquireShared 这个方法是共享模式下获取资源失败,执行入队和等待操作,等待的线程在被唤醒后也在这个 ...

  2. AQS源码三视-JUC系列

    AQS源码三视-JUC系列 前两篇文章介绍了AQS的核心同步机制,使用CHL同步队列实现线程等待和唤醒,一个int值记录资源量.为上层各式各样的同步器实现画好了模版,像已经介绍到的ReentrantL ...

  3. 剑指offer19:按照从外向里以顺时针的顺序依次打印出每一个数字,4 X 4矩阵: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 则依次打印出数字1,2,3,4,8,12,16,15,14,13,9,5,6,7,11,10.

    1 题目描述 输入一个矩阵,按照从外向里以顺时针的顺序依次打印出每一个数字,例如,如果输入如下4 X 4矩阵: 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 则依次打印 ...

  4. 项目管理构建工具——Maven(基础篇)

    项目管理构建工具--Maven(基础篇) 在前面的内容中我们学习了JDBC并且接触到了jar包概念 在后面我们的实际开发中会接触到很多jar包,jar包的导入需要到互联网上进行就会导致操作繁琐 Mav ...

  5. AQS源码一窥-JUC系列

    AQS源码一窥 考虑到AQS的代码量较大,涉及信息量也较多,计划是先使用较常用的ReentrantLock使用代码对AQS源码进行一个分析,一窥内部实现,然后再全面分析完AQS,最后把以它为基础的同步 ...

  6. 极限编程,最强N皇后JAVA解题代码,4秒出15皇后,33秒出16皇后

    私人博客原文链接来自:http://www.hexcode.cn/article/show/eight-queen 8皇后以及N皇后算法探究,回溯算法的JAVA实现,非递归,循环控制及其优化 8皇后以 ...

  7. 2020-06-21:ZK的哪些基础能力构建出了哪些上层应用?

    福哥答案2020-06-21: 福哥口诀法:数负命Ma集配分(使用场景:数据发布订阅.负载均衡.命名服务.Master 选举.集群管理.配置管理.分布式队列和分布式锁) 数据发布订阅:dubbo的rp ...

  8. JUC并发基础

    目录 一.Volatile 0.基础知识 1. volatile的解释 3.volatile的应用 二.CAS 0.CAS的定义 1.CAS底层原理 2.CAS的缺点 3.ABA问题 三.集合类并发安 ...

  9. AWS EC2+Docker+JMeter构建分布式负载测试基础架构

    目录 概述及范围 前提条件 Part 1: Local setup-本地配置 Part 2: Cloud端基础架构--Infrastructure 总结: 原文链接 @ 概述及范围 本文介绍有关如何使 ...

  10. 搭建自己私有的PKM系统,各家PKM大比拼。。附:构建自己熟悉的基础Docker,破解联通光猫

    Docker这容器真是很好玩!干啥都想上docker了,快疯了. 这不,最近wiz笔记开始收费,很是不爽,需要寻求新的PKM系统了.备选及落选理由如下: wiz笔记 -- 好用,顺手.要开始收费了,不 ...

随机推荐

  1. react 获取文件流导出功能

    记录一下: 根据后台接口返回的文件流,前端实现导出下载,使用(react+ts) 1.请求方法 (这里写法绕开拦截器) // 导出日志 export async function exportLog( ...

  2. MySql 错误:建表时出错1067 - Invalid default value for 'id'

    问题描述: 建表时报错无效的默认值: CREATE TABLE `product`( `id` INT(10) NOT NULL PRIMARY KEY AUTO_INCREMENT COMMENT ...

  3. arduino问题记录

    1.tx.rx只能写,不能读 2.Arduino中文社区

  4. js 获取鼠标位置的两种方法

    <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8&quo ...

  5. 329MD5的加密

    一.引用帮助类 二.登录的代码

  6. 项目:表格打印(字符图网格进阶、rjust、列表中最长的字符串长度)

    项目要求:编写一个名为 printTable()的函数,它接受字符串的列表的列表,将它显示在组织良好的表格中,每列右对齐. tableData = [['apples', 'oranges', 'ch ...

  7. 实验二 实验二 Linux系统简单文件操作命令

    项目 内容 这个作业属于哪个课程 <班级课程的主页链接> 这个作业的要求在哪里 <作业要求链接接地址> 学号-姓名 15043109吴小怀 作业学习目标 学习在Linux系统终 ...

  8. Windows 解决teamview远程必须mstsc连接

    真实原因是你的TeamViewer一直在用远程桌面的ID进行登录,所以一旦远程桌面断开,TeamViewer就无法连接了.因此我们只需要切换为服务器的TeamViewer ID即可,服务器的TeamV ...

  9. JS this指向相关

    function Foo() { getName = function() { console.log(1) } return this;}Foo.getName = function() { con ...

  10. 【NAS使用心得】使用Synology Photos管理照片

    整理方式 1.本地没有整理或只按年份整理的:时间线模式下直接上传,让软件自己按照片创建时间生成文件夹:有按年份生成相册需求的,可以用"选择照片以创建相册"功能,找到年份文件夹,全选 ...