14.AQS的前世,从1990年的论文说起
大家好,我是王有志。关注王有志,一起聊技术,聊游戏,聊在外漂泊的生活。
鸽了这么久怪不好意思的,因此送一本《多处理器编程的艺术》,快点击此处参加吧。另外欢迎大家加入“共同富裕的Java人”互助群。
今天的主题是AbstractQueuedSynchronizer,即AQS。作为java.util.concurrent的基础,AQS在工作中的重要性是毋庸置疑的。通常在面试中也会有两道“必考”题等着你
原理相关:AQS是什么?它是怎样实现的?
设计相关:如何使用AQS实现Mutex?
原理相关的问题几乎会出现在每场Java面试中,是面试中的“明枪”,是必须要准备的内容;而设计相关的问题更多的是对技术深度的考察,算是“暗箭”,要尤为谨慎的去应对。
我和很多小伙伴交流关于AQS的问题时发现,大部分都只是为了应付面试而“背”了AQS的实现过程。为了全面地理解AQS的设计,今天我们会从1990年T.E.Anderson引入排队的思想萌芽开始,到Mellor-Crummey和Scott提出的MCS锁,以及Craig,Landin和Hagersten设计的CLH锁。
AQS的内容整体规划了4个部分:

今天我们一起学习前两个部分,了解AQS的前世。
Tips:本文基于Java 11完成,与Java 8存在部分差异,请注意区分源码之间的差异。
AQS是什么?
通常我们按照类名将AbstractQueuedSynchronizer翻译为抽象队列同步器。单从类名来看,我们就已经可以得到3个重要信息:
Abstract:抽象类,通常无法直接使用;
Queued:队列,借助队列实现功能;
Synchronizer:同步器,用于控制并发。
源码中的注释也对AQS做了全面的概括:
Provides a framework for implementing blocking locks and related synchronizers (semaphores, events, etc) that rely on first-in-first-out (FIFO) wait queues.
提供了依赖于FIFO等待队列用于实现阻塞锁和同步器(信号量,事件等)的框架。这段描述恰好印证了我们通过类名得到的信息,我们来看Java中有哪些AQS的实现:

可以看到,JUC中有大量的同步工具内部都是通过继承AQS来实现的,而这也正是Doug Lea对AQS的期望:成为大部分同步工具的基础组件。
Tips:至少在Java 8中,FutureTask已经不再依赖AQS实现了(未考证具体版本)。
接着我们来看注释中提到的“rely on first-in-first-out (FIFO) wait queues”,这句话指出AQS依赖了FIFO的等待队列。那么这个队列是什么?我们可以在注释中找到答案:
The wait queue is a variant of a "CLH" (Craig, Landin, and Hagersten) lock queue. CLH locks are normally used for spinlocks.
AQS中使用的等待队列时CLH队列的变种。那么CLH队列是什么呢?AQS做了哪些改变呢?
AQS的前世
AQS明确揭示了它使用CLH队列的变种,因此我从CLH队列的相关论文入手:
Craig于1993年发表的《Building FIFO and priority-queueing spin locks from atomic swap》
Landin和Hagersten于1994年发表的《Efficient Software Synchronization on Large Cache Coherent Multiprocessors》
这两篇文章都引用了T.E.Anderson于1990年发表的的《The Performance of Spin Lock Alternatives for Shared-Memory Multiprocessors》,因此我们以这篇文章中提出的基于数组的自旋锁设计作为切入点。
Tips:
《Efficient Software Synchronization on Large Cache Coherent Multiprocessors》的作者有3个人~~
Landin和Hagersten的《Efficient Software Synchronization on Large Cache Coherent Multiprocessors》中引用了Craig的《Building FIFO and priority-queueing spin locks from atomic swap》,Craig率先提出了CLH锁的结构,不知道为什么学术界以他们3人进行命名;
由于论文是很多年前收集的,现在去查找原始网站较为困难,只能提供下载链接了,对不起各位祖师爷~~
T.E.Anderson The Performance of Spin Lock Alternatives for Shared-Memory Multiprocessors 1990
Mellor Crummey,Scott Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors 1991
Craig Building FIFO and priority-queueing spin locks from atomic swap 1993
Landin, Hagersten Efficient Software Synchronization on Large Cache Coherent Multiprocessors 1994
Doug Lea The java.util.concurrent Synchronizer Framework 2004
《多处理器编程的艺术》中第7章详细讨论了队列锁的设计,包括基于数组的设计,MCS锁,CLH锁。
基于数组的自旋锁
1990年T.E.Anderson发表了《The Performance of Spin Lock Alternatives for Shared-Memory Multiprocessors》,文章讨论了基于CPU原子指令自旋锁的性能瓶颈,并提出了基于数组的自旋锁设计。
基于原子指令的自旋锁
第一种设计(SPIN ON TEST-AND-SET),即TASLock,使用CPU提供的原子指令test-and-set尝试更新锁标识:

初始化锁标识为CLEAR,获取锁时尝试更新锁标识为BUSY,更新成功则获取到锁,释放时将锁标识更新为CLEAR。
设计非常简单,竞争并不激烈的场景下性能也是完全没问题,但是一旦CPU的核心数增多,问题就出现了:
持有者在释放锁时要和其它正在自旋的竞争者争夺锁标识内存的独占访问权限,因为test-and-set是原子写操作;
在使用总线的体系结构中,无论test-and-set指令是否成功,它都会消耗一次总线事务,会使总线变得拥堵。
因此提出了第二种设计(SPIN ON READ),即TTASLock,加入test指令,避免频繁的:

该设计中,在执行test-and-set指令前,先进行锁标识状态的判断,处于BUSY状态,直接进入自旋逻辑(或运算的短路特性),跳过test-and-set指令的执行。
额外一次读取操作,避免了频繁的test-and-set指令造成的内存争抢,也减少了总线事务,竞争者只需要自旋在自己的缓存上即可,只有锁标识发生改变时,才会执行test-and-set指令。
这种设计依旧有些性能问题无法解决:
如果频繁锁标识频繁的发生改变,CPU的缓存会频繁的失效,重新读取;
持有者释放锁时,会导致所有CPU的缓存失效,必须重新在内存或总线中竞争。
T.E.Anderson对两种设计进行了测试,计算了在不同数量的CPU上执行了100万次操作的耗时,执行等待锁,执行临界区,释放锁和延迟一段时间。

可以看到SPIN ON READ的设计随着CPU数量的增多性能确实得到了改善,但距离理想的性能曲线仍有着不小的差距。
除了这两种设计外,T.E.Anderson还考虑了在自旋逻辑中引入延迟来减少冲突:

此时需要考虑设置合理的延迟时间,选择合适的退避(backoff)算法来减少竞争。
Tips:Java版TASLock和TTASLock,供大家参考。
基于数组的自旋锁
前面的设计中,自旋锁的性能问题是由多个CPU同时争抢内存访问权限产生的,那么让它们按顺序排队是不是就解决了这个问题?T.E.Anderson引入了队列的设计:

初始化
创建长度为CPU数量P的数组flags[P]
flags[0]标识为HAS_LOCK(拥有锁),其余标记为MUST_WAIT(等待锁)
初始化queueLast为0,标识当前队列位置
加锁
CPU通过ReadAndIncrement指令读取queueLast后保存为自己的myPlace
- ReadAndIncrement指令,先读取,后自增
CPU判断自己的flags[myPlace mod P]上的标记来决定持有锁或进入自旋
- 取模操作让数组变成了头尾相连的“环状”数组
解锁
将当前CPU在队列中的位置flags[myPlace]更新为MUST_WAIT
将flags[(myPlace + 1) mod P]更新为HAS_LOCK,标识下一个CPU获取锁
每个CPU只访问自己的锁标识(myPlace),避免了争抢内存访问的权限,另外锁会直接释放给队列中的下一个CPU,避免了通过竞争获取,减少了从释放锁到获取锁的时间。
当然缺点也很明显,仅从伪代码的行数上也能看出来,基于队列的自旋锁设计更复杂,当竞争并不激烈时,它的性能会更差。T.E.Anderson也给出了他的测试结果:

很明显,在竞争激烈的场景中,引入队列后的自旋锁性能更加优秀,并没有过多的额外开销。
Tips:
T.E.Anderson的论文就介绍到这里,除了对自旋锁的讨论,文章中还讨论了在自旋锁引入退避算法和静态延迟(static delays)的优劣,就留给大家自行阅读了;
Java版TEALock,供大家参考(名字是我自己起的~)。
MCS锁的设计
基于数组的自旋锁是排队思想的实现,T.E.Anderson的论文发表后,又涌现出了许多使用排队思想锁,例如:Mellor-Crummey和Scott于1991年在论文《Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors》中提出的MCS锁,也是基于排队思想实现,只不过在数据结构上选择了单向链表。
描述MCS锁的初始化与加解锁的原理,我使用经过“本地化”的Java实现版本的MCS锁:
MCS锁的初始化
public class MCSLock {
AtomicReference<QNode> lock;
ThreadLocal<QNode> myNode;
public MCSLock() {
this.lock = new AtomicReference<>(null);
this.myNode = ThreadLocal.withInitial(QNode::new);
}
private static class QNode {
private boolean locked;
private QNode next;
}
}
声明单向链表的节点QNode,locked表示锁是否被前驱节点获取;
创建QNode节点lock,表示当前锁的位置,实际上也是链表的尾节点。
MCS锁的加锁
public void lock() {
QNode I = this.myNode.get();
QNode predecessor = this.lock.getAndSet(I);
if (predecessor != null) {
I.locked = true;
predecessor.next = I;
while (I.locked) {
System.out.println("自旋,可以加入退避算法");
}
}
}
为每个线程初始化QNode,命名为
I;通过原子指令获取
I的前驱节点lock命名为predecessor,并将I设置为lock(取出当前lock,并设置新的lock);当
predecessor == null时,表示队列为空,可以直接返回,代表获取到锁;当
predecessor != null时,表示前驱节点已经获取到锁;更新locked,表示锁已经被前驱节点获取;
更新predecessor的后继节点为
I,否则predecessor无法唤醒I;I进入自旋逻辑。
MCS锁的解锁
public void unlock() {
QNode I = this.I.get();
if (I.next == null) {
if (lock.compareAndSet(I, null)) {
return;
}
while (I.next == null) {
System.out.println("自旋");
}
}
I.next.locked = false;
I.next = null;
}
获取当前线程的QNode命名为
I;如果
I.next == null,队列中无其它节点,即不存在锁竞争的场景;尝试通过CAS更新lock为null,保证下次加锁时
predecessor == null,成功则直接返回;如果失败,表示此时有线程开始竞争锁,此时进入自旋,保证竞争者成功执行
predecessor.next = I;
如果
I.next != null,队列中有其他节点,锁存在竞争;更新后继节点的locked标识,使其跳出自旋;
更新自己的后继节点指针,断开联系。
MCS锁的逻辑并不复杂,不过有些细节设计的非常巧妙,提个问题供大家思考下:加锁过程中I.locked = true和predecessor.next = I的顺序可以调整吗?
MCS锁的整体设计思路到这里就结束了,Mellor-Crummey和Scott给出了MCS锁的4个优点:
FIFO保证了公平性,避免了锁饥饿;
自旋标识是线程自身的变量,避免了共享内存的访问冲突;
每个锁的创建只需要极短的时间(requires a small constant amount of space per lock);
无论是否采用一致性缓存架构, 每次获取锁只需要$ O(1)$ 级别的通信开销。
除此之外,相较于T.E.Anderson的设计,MCS锁在内存空间上是按需分配,并不需要初始化固定长度数组,避免了内存浪费。
Tips:
本文只简单的介绍MCS锁的原理,想要深入学习的可以阅读以下内容:
CLH锁的设计
1993年Craig发表了《Building FIFO and priority-queueing spin locks from atomic swap》,文章中描述了另一种基于排队思想的队列锁,即CLH 锁(我觉得称为Craig Lock更合适)的雏形,它和MCS锁很相似,但有一些差异:
CLH旋转在队列前驱节点的锁标识上;
CLH锁使用了一种“隐式”的链表结果。
我们带着这两个差异来看CLH的锁的设计,原文使用Pascal风格的伪代码,这里我们使用《多处理器编程的艺术》中提供的Java版本,与论文中的差异较大,重点理解实现思路即可。
CLH锁的初始化
public class CLHLock {
AtomicReference<Node> tail;
ThreadLocal<Node> myPred;
ThreadLocal<Node> myNode;
public CLHLock() {
this.tail = new AtomicReference<>(new Node());
this.myNode = ThreadLocal.withInitial(Node::new);
this.myPred = new ThreadLocal<>();
}
private static class Node {
private volatile boolean locked = false;
}
}
Craig的设计中,请求锁的队列节点有两种状态,在实现中可以使用布尔变量代替:
PENDING,表示获取到锁或者等待获取锁,可以使用true;
GRANTED,表示释放锁,可以使用false。
另外CLHLock的初始化中,this.tail = new AtomicReference<>(new QNode())添加了默认节点,该节点的locked默认为false,这是借鉴了链表处理时常用到技巧虚拟头节点。
CLH锁的加锁
public void lock() {
Node myNode = this.myNode.get();
myNode.locked = true;
Node pred = this.tail.getAndSet(myNode);
this.myPred.set(pred);
while(myPred.locked) {
System.out.println("自旋,可以加入退避算法");
}
}
实现中巧妙的使用了两个ThreadLocal变量来构建出了逻辑上的链表,和传统意义的单向链表不同,CLH的链表从尾节点开始指向头部。
另外,CLH锁中的节点只关心自身前驱节点的状态,当前驱节点释放锁的那一刻,节点就知道轮到自己获取锁了。
CLH锁的解锁
public void unlock() {
Node myNode = this.myNode.get();
myNode.locked = false;
this.myNode.set(this.myPred.get());
}
解锁的逻辑也非常简单,只需要更新自身的锁标识即可。但是你可能会疑问this.myNode.set(this.myPred.get())是用来干嘛的?删除会产生什么影响吗?
Tips:Java版CLHLock,供大家参考,代码有详细的注释。
单线程场景
在单线程场景中,完成CLH锁的初始化后,锁的内部结构是如下:

Tips:@后表示Node节点的地址。
第一次加锁后状态如下:

这时前驱节点的锁标记为false,表示当前节点可以直接获取锁。
第一次解锁后状态如下:

到目前为止一切都很正常,但是当我们再次加锁时会发现,好像没办法加锁了,我们来逐行代码分析锁的状态。当获取myNode后并更新锁标识,即执行如下代码后:
Node myNode = this.myNode.get();
myNode.locked = true;

当获取并更新tail和myPred后,即执行如下代码后:
Node pred = this.tail.getAndSet(myNode);
this.myPred.set(pred);

这时候问题出现了,myNode == myPred,导致永远无法获取锁。this.myNode.set(this.myPred.get())相当于在链表中移除当前节点,使获取锁的节点的直接前驱节点永远是初始化时锁标识为false的默认节点。
多线程场景
再来考虑多线程的场景,假设有线程t1和线程t2争抢锁,此时t1率先获取到锁:

线程t1释放后再次立即获取是有可能出现的,最典型的情况是如果为自旋逻辑添加了退避算法,当线程t2多次自旋后再次进入自旋逻辑,此时线程t1释放锁后立即尝试获取锁,先更新线程t1的锁标记为true,接着从tail节点中获取前驱节点线程t2,然后再更新tail节点,此时线程t1在线程t2的锁标记上自旋,线程t2在线程t1的锁标记上自旋,凉凉~~
留个思考题,为什么this.myNode.set(this.myPred.get())可以避免这种情况?
CLH锁和MCS锁的对比
首先是代码实现上,CLH锁的实现非常简单,除了自旋的部分其余全是平铺直叙,反观MCS锁,分支,嵌套,从实现难度上来看CLH锁更胜一筹(难点在于逆向思维,让当前节点自旋在直接前驱节点的锁标识上)。另外,CLH锁只在加锁时使用了一次原子指令,而MCS锁的加解锁中都需要使用原子指令,性能上也略胜一筹。
那么CLH锁是全面超越了MCS锁吗?不是的,在NUMA架构下,CLH锁的自旋性能非常差。先来看NUMA架构的示意图:

NUMA架构中,每个CPU有自己缓存,访问不同CPU缓存的成本较高,在需要频繁进入自旋的场景中CLH锁自旋的性能较差,而在需要频繁解锁更新其他CPU锁标识的场景中MCS锁的性能较差。
结语
到目前为止,我们一起学习了3种基于排队思想的自旋锁设计,作为AQS的“前世”,理解它们的设计能够帮助我们理解AQS的原理。当然并非只有这3种基于排队思想的自旋锁,还有如RHLock,HCLHLock等,感兴趣的可以自行探索,这里提供论文链接:
好了,今天就到这里了,Bye~~
14.AQS的前世,从1990年的论文说起的更多相关文章
- Hadoop 二次排序
需求 求每年的最高气温,年份升序,温度求最高 数据源内容如下 temperature.txt 2004 49 1981 -22 1981 -31 1965 -47 2027 -2 1964 6 203 ...
- 深入理解hadoop之排序
MapReduce的排序是默认按照Key排序的,也就是说输出的时候,key会按照大小或字典顺序来输出,比如一个简单的wordcount,出现的结果也会是左侧的字母按照字典顺序排列.下面我们主要聊聊面试 ...
- 2020由浅入深最强Java面试题
1.String,StringBuffer和StringBuilder有什么区别? String是字符串常量,不可变对象,每次对String修改都等同于生成了一个新的String象,然后将指针 ...
- 3 年经验的 Java 后端妹子,横扫阿里、滴滴、美团,整理出这份厚厚的 8000 字面经!
自序 这次面试的公司有一点点多,主要是因为毕业后前两份工作找的都很草率,这次换工作就想着,emm,毕业三年了,该找个工作好好沉淀几年了. 先说下这次面试的结果吧: 到 hr 面的:阿里.美团.滴滴.金 ...
- 第一章: 初始JVM
跟很多人一样,我一开始接触 Java 虚拟机只是因为面试需要用到,所以硬着头皮看看.所以很多人对于为什么要学虚拟机这个问题,他们的答案都是:因为面试. 因为装逼 但我经过了几年的学习和实战,我发现其实 ...
- 获取文件的缩略图Thumbnail和通过 AQS - Advanced Query Syntax 搜索本地文件
演示如何获取文件的缩略图 FileSystem/ThumbnailAccess.xaml <Page x:Class="XamlDemo.FileSystem.ThumbnailAcc ...
- Java并发包源码学习之AQS框架(一)概述
AQS其实就是java.util.concurrent.locks.AbstractQueuedSynchronizer这个类. 阅读Java的并发包源码你会发现这个类是整个java.util.con ...
- 扒一扒ReentrantLock以及AQS实现原理
提到JAVA加锁,我们通常会想到synchronized关键字或者是Java Concurrent Util(后面简称JCU)包下面的Lock,今天就来扒一扒Lock是如何实现的,比如我们可以先提出一 ...
- 14.3.5 LOCK TABLES and UNLOCK TABLES Syntax
14.3.5 LOCK TABLES and UNLOCK TABLES Syntax LOCK TABLES tbl_name [[AS] alias] lock_type [, tbl_name ...
- 论山寨手机与Android 【14】3G SmartPhone时代的MTK
分析了SmartPhone的里里外外以后,现在我们可以分析MTK的机遇和挑战了.MTK面临的外部环境在发生变化,变化有两条,一是移动网络从2G演变到3G,二是手机由FeaturePhone演化到Sma ...
随机推荐
- react对于setState的写法
react对于setState的写法,改变state的数组里边的值也可以这样写 [1,2,3,4,5].forEach((item)=>{ let arr={} arr[`list${item} ...
- Mac下如何使用EVE-NG的telnet客户端和wireshark抓包
当我没有安装SecureCRT,点击启动的设备,弹出使用终端打开,但是由于eve中telnet使用的url是telnet xx.xx.xx.xx:xxxx 的形式,其在终端app中不能正常工作,tel ...
- react+antd 导出excel文件(简单数据&多级表头)
需求: 在基于react+antd进行开发的页面中,实现导出excel报表的功能 实际场景: 1.简单数据:单层表头+数据 2.复杂数据:多层表头+数据 实现方式: 1.简单数据 简单数据的导出使用了 ...
- 使用SharpCompress压缩文件后把压缩的文件流传给前端
1 SharpCompress版本 0.30.1 2 应用场景:前端传递某个标识符,如Id,查询和该Id相关联的文件,并把文件压缩,最后返回给前端.适用于压缩多个体积较小的文件,如果文件体系过大,可能 ...
- 文献学习——A Deep Dive into Conflict Generating Decisions
A Deep Dive into Conflict Generating Decisions Md. Solimul Chowdhury, Martin Müller, Jia-Huai You:A ...
- django_模板层的变量和标签
一.模板层的变量 1.能传递到模板层的数据类型:str(字符串).list(数组).dict(字典).obj(类实例化的对象).fun(函数)等. 2.在模板中使用变量的方法: (1)字符串:{{ 变 ...
- heiamJava16IO流
Java I/O流 按流的方向分 输入输出流 I表示intput(输入),是数据从硬盘文件读入到内存的过程,称之输入,负责读. O表示output(输出),是内存程序的数据从内存到写出硬盘文件的过程, ...
- HTTPS的实现原理 ---- 核心 SSL/TLS协议
是在应用层和 传输层之间 添加的 安全层(SSL/TLS协议) 端口号 :HTTP 默认是 80,HTTPS 默认是 443. URL 前缀 :HTTP 的 URL 前缀是 http://,HTTPS ...
- B树、B+树详解
B树.B+树详解 B树 前言 首先,为什么要总结B树.B+树的知识呢?最近在学习数据库索引调优相关知识,数据库系统普遍采用B-/+Tree作为索引结构(例如mysql的InnoDB引擎使用的B+树 ...
- NodeJS - XSS-Attribute
参考:https://owasp-skf.gitbook.io/asvs-write-ups/cross-site-scripting-attribute-xss-attribute/kbid-3-x ...