java高并发编程基础之AQS
引言
曾经有一道比较比较经典的面试题“你能够说说java
的并发包下面有哪些常见的类?”大多数人应该都可以说出
CountDownLatch、CyclicBarrier、Sempahore多线程并发三大利器。这三大利器都是通过AbstractQueuedSynchronizer
抽象类(下面简写AQS)来实现的,所以学习三大利器之前我们有必要先来学习下AQS
。
AQS是一种提供了原子式管理同步状态、阻塞和唤醒线程功能以及队列模型的简单框架
AQS结构
说到同步我们如何来保证同步?大家第一印象肯定是加锁了,说到锁的话大家肯定首先会想到的是Synchronized。
Synchronized大家应该基本上都会使用,加锁和释放锁都是jvm 来帮我们实现的,我们只需要简单的加个 Synchronized关键字就可以了。
用起来超级方便。但是有没有一种情况我们设置一个锁的超时时间Synchronized就有点实现不了,这时候我们就可以用ReentrantLock来实现,ReentrantLock是通过aqs来实现的,今天我们就通过ReentrantLock来学习一下aqs。
CAS && 公平锁和非公平锁
AQS里面用到了大量的CAS学习AQS之前我们还是有必要简单的先了解下CAS
、公平锁和非公平锁。
CAS
- CAS 全称是 compare and swap,是一种用于在多线程环境下实现同步功能的机制。
CAS
操作包含三个操作数 -- 内存位置、预期数值和新值。CAS
的实现逻辑是将内存位置处的数值与预期数值想比较,若相等,则将内存位置处的值替换为新值。若不相等,则不做任何操作,这个操作是个原子性操作,java里面的AtomicInteger
等类都是通过cas来实现的。
公平锁和非公平锁
- 公平锁:多个线程按照申请锁的顺序去获得锁,线程会直接进入队列去排队,队列中第一个才能获得到锁。
优点:等待锁的线程不会饿死,每个线程都可以获取到锁。
缺点:整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。 - 非公平锁:多个线程去获取锁的时候,会直接去尝试获取,获取不到,再去进入等待队列,如果能获取到,就直接获取到锁。
优点:可以减少CPU唤醒线程的开销,整体的吞吐效率会高点,CPU也不必取唤醒所有线程,会减少唤起线程的数量。
缺点:处于等待队列中的线程可能会饿死,或者等很久才会获得锁。
文字有点拗口,我们来个实际的例子说明下。比如我们去食堂就餐的时候都要排队,大家都按照先来后到的顺序排队打饭,这就是公平锁。如果等到你准备拿盘子打饭的时候
直接蹦出了一个五大三粗的胖子插队到你前面,你看打不赢他只能忍气吞声让他插队,等胖子打完饭了又来个小个子也来插你队,这时候你没法忍了,直接大吼一声让他滚,这个
小个子只能屁颠屁颠到队尾去排队了这就是非公平锁。
我们先来看看AQS有哪些属性
// 头结点
private transient volatile Node head;
// 阻塞的尾节点,每个新的节点进来,都插入到最后,也就形成了一个链表
private transient volatile Node tail;
// 这个是最重要的,代表当前锁的状态,0代表没有被占用,大于 0 代表有线程持有当前锁
// 这个值可以大于 1,是因为锁可以重入,每次重入都加上 1
private volatile int state;
// 代表当前持有独占锁的线程,举个最重要的使用例子,因为锁可以重入
// reentrantLock.lock()可以嵌套调用多次,所以每次用这个来判断当前线程是否已经拥有了锁
// if (currentThread == getExclusiveOwnerThread()) {state++}
private transient Thread exclusiveOwnerThread; //继承自AbstractOwnableSynchronizer
下面我们来写一个demo分析下lock 加锁和释放锁的过程
final void lock() {
// 上来先试试直接把状态置位1,如果此时没人获取锁就直接
if (compareAndSetState(0, 1))
// 争抢成功则修改获得锁状态的线程
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
cas
尝试失败,说明已经有人再持有锁,所以进入acquire方法
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
tryAcquire
方法,看名字大概能猜出什么意思,就是试一试。
tryAcquire实际上是调用了父类Sync的nonfairTryAcquire
方法
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
// 获取下当前锁的状态
int c = getState();
// 这个if 逻辑跟前面一进来就获取锁的逻辑一样都是通过cas尝试获取下锁
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
// 进入这个判断说明 锁重入了 状态需要进行+1
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
// 如果锁的重入次数大于int的最大值,直接就抛出异常了,正常情况应该不存在这种情况,不过jdk还是严谨的
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 返回false 说明尝试获取锁失败了,失败了就要进行acquireQueued方法了
return false;
}
tryAcquire
方法如果获取锁失败了,那么肯定就要排队等待获取锁。排队的线程需要待在哪里等待获取锁?这个就跟我们线程池执行任务一样,线程池把任务都封装成一个work,然后当线程处理任务不过来的时候,就把任务放到队列里面。AQS同样也是类似的,把排队等待获取锁的线程封装成一个NODE。然后再把NODE放入到一个队列里面。队列如下所示,不过需要注意一点head是不存NODE的。
接下来我们继续分析源码,看下获取锁失败是如何被加入队列的。
就要执行acquireQueued方法,执行acquireQueued方法之前需要先执行addWaiter方法
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
// cas 加入队列队尾
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 尾结点不为空 || cas 加入尾结点失败
enq(node);
return node;
}
enq
接下来再看看enq方法
// 通过自旋和CAS一定要当前node加入队尾
private Node enq(final Node node) {
for (;;) {
Node t = tail;
// 尾结点为空说明队列还是空的,还没有被初始化,所以初始化头结点,可以看到头结点的node 是没有绑定线程的也就是不存数据的
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
通过addWaiter方法已经把获取锁的线程通过封装成一个NODE加入对列。上述方法的一个执行流程图如下:
,接下来就是继续执行acquireQueued方法
acquireQueued
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
// 通过自旋去获取锁 前驱节点==head的时候去尝试获取锁,这个方法在前面已经分析过了。
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 进入这个if说明node的前驱节点不等于head 或者尝试获取锁失败了
// 判断是否需要挂起当前线程
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
// 异常情况进入cancelAcquire,在jdk11的时候这个源码直接是catch (Throwable e){ cancelAcquire(node);} 简单明了
if (failed)
cancelAcquire(node);
}
}
setHead
这个方法每当有一个node获取到锁了,就把当前node
节点设置为头节点,可以简单的看做当前节点获取到锁了就把当前节点”移除“(变为头结点)队列。
shouldParkAfterFailedAcquire
说到这个方法我们就要先看下NODE可能会有哪些状态在源码里面我们可以看到总共会有四种状态
- CANCELLED:值为1,在同步队列中等待的线程等待超时或被中断,需要从同步队列中取消该Node的结点,其结点的waitStatus为CANCELLED,即结束状态,进入该状态后的结点将不会再变化。
- SIGNAL:值为-1,被标识为该等待唤醒状态的后继结点,当其前继结点的线程释放了同步锁或被取消,将会通知该后继结点的线程执行。说白了,就是处于唤醒状态,只要前继结点释放锁,就会通知标识为SIGNAL状态的后继结点的线程执行。
- CONDITION:值为-2,与Condition相关,该标识的结点处于等待队列中,结点的线程等待在Condition上,当其他线程调用了Condition的signal()方法后,CONDITION状态的结点将从等待队列转移到同步队列中,等待获取同步锁。
- PROPAGATE:值为-3,与共享模式相关,在共享模式中,该状态标识结点的线程处于可运行状态。
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
// 前驱节点状态 如果这个状态为-1 则返回true,把当前线程挂起
if (ws == Node.SIGNAL)
return true;
// 大于0,说明状态为CANCELLED
if (ws > 0) {
do {
// 删除被取消的node(让被取消的node成为一个没有引用的node等着下次GC被回收)
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
// 进入这里只能是 0,-2,-3。NODE节点初始化的时候waitStatus默认值是0,所以只有这里才有修改waitStatus的地方
// 通过cas 把前驱节点的状态设置为-1,然后返回false ,外面调用这个方法的是个循环,又会调用一次这个方法
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
parkAndCheckInterrupt
挂起当前线程,并且阻塞
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 挂起当前线程,阻塞
return Thread.interrupted();
}
解锁
加锁成功了,那锁用完了就应该释放锁了,释放锁重点看下unparkSuccessor这个方法就好了
private void unparkSuccessor(Node node) {
// 头结点状态
int ws = node.waitStatus;
if (ws < 0)
compareAndSetWaitStatus(node, ws, 0);
Node s = node.next;
// s==null head的successor节点获取锁成功后,执行了head.next=null的操作后,解锁线程读取了head.next,因此s==null
// head的successor节点被取消(cancelAcquire)时,执行了如下操作:successor.waitStatus=1 ; successor.next = successor;
if (s == null || s.waitStatus > 0) {
s = null;
// 从尾节点开始往前找,找到最前面的非取消的节点 这里没有break 哦
for (Node t = tail; t != null && t != node; t = t.prev)
if (t.waitStatus <= 0)
s = t;
}
if (s != null)
// 唤醒线程 ,唤醒的线程会从acquireQueued去获取锁
LockSupport.unpark(s.thread);
}
释放锁代码比较简单,基本都写在代码注释里面了,流程如下:
这段代码里面有一个比较经典的面试题:
如果头结点的下一个节点为空或者头结点的下一个节点的状态为取消的时候为什么要从后往前找,找到最前面非取消的节点?
- node.prev = pred; compareAndSetTail(pred, node) 这两个地方可以看作Tail入队的原子操作,但是此时pred.next = node;还没执行,如果这个时候执行了unparkSuccessor方法,就没办法从前往后找了,所以需要从后往前找。
- 在产生CANCELLED状态节点的时候,先断开的是Next指针,Prev指针并未断开,因此也是必须要从后往前遍历才能够遍历完全部的Node
总结
- reentrantLock的获取锁和释放锁基本就讲完了,里面还涉及多比较多的细节,感兴趣的同学可以对着源码一行一行去debug试试。
- 适当的了解aqs才能更好的学习CountDownLatch、CyclicBarrier、Sempahore,因为这三个利器都是基于aqs来实现的。
结束
- 由于自己才疏学浅,难免会有纰漏,假如你发现了错误的地方,还望留言给我指出来,我会对其加以修正。
- 如果你觉得文章还不错,你的转发、分享、赞赏、点赞、留言就是对我最大的鼓励。
- 感谢您的阅读,十分欢迎并感谢您的关注。
站在巨人的肩膀上摘苹果:
https://tech.meituan.com/2019/12/05/aqs-theory-and-apply.html
https://javadoop.com/post/AbstractQueuedSynchronizer
https://www.cnblogs.com/yanlong300/p/10953185.html
java高并发编程基础之AQS的更多相关文章
- Java高并发编程基础三大利器之CountDownLatch
引言 上一篇文章我们介绍了AQS的信号量Semaphore<Java高并发编程基础三大利器之Semaphore>,接下来应该轮到CountDownLatch了. 什么是CountDownL ...
- [ 高并发]Java高并发编程系列第二篇--线程同步
高并发,听起来高大上的一个词汇,在身处于互联网潮的社会大趋势下,高并发赋予了更多的传奇色彩.首先,我们可以看到很多招聘中,会提到有高并发项目者优先.高并发,意味着,你的前雇主,有很大的业务层面的需求, ...
- 关于Java高并发编程你需要知道的“升段攻略”
关于Java高并发编程你需要知道的"升段攻略" 基础 Thread对象调用start()方法包含的步骤 通过jvm告诉操作系统创建Thread 操作系统开辟内存并使用Windows ...
- java高并发编程(一)
读马士兵java高并发编程,引用他的代码,做个记录. 一.分析下面程序输出: /** * 分析一下这个程序的输出 * @author mashibing */ package yxxy.c_005; ...
- java高并发编程(三)
java高并发主要有三块知识点: synchronizer:同步器,在多个线程之间互相之间怎么进行通讯,同步等: 同步容器:jdk提供了同步性的容器,比如concurrentMap,concurren ...
- 高并发编程基础(java.util.concurrent包常见类基础)
JDK5中添加了新的java.util.concurrent包,相对同步容器而言,并发容器通过一些机制改进了并发性能.因为同步容器将所有对容器状态的访问都串行化了,这样保证了线程的安全性,所以这种方法 ...
- java高并发编程(五)线程池
摘自马士兵java并发编程 一.认识Executor.ExecutorService.Callable.Executors /** * 认识Executor */ package yxxy.c_026 ...
- java高并发编程(四)高并发的一些容器
摘抄自马士兵java并发视频课程: 一.需求背景: 有N张火车票,每张票都有一个编号,同时有10个窗口对外售票, 请写一个模拟程序. 分析下面的程序可能会产生哪些问题?重复销售?超量销售? /** * ...
- java高并发编程(二)
马士兵java并发编程的代码,照抄过来,做个记录. 一.分析下面面试题 /** * 曾经的面试题:(淘宝?) * 实现一个容器,提供两个方法,add,size * 写两个线程,线程1添加10个元素到容 ...
随机推荐
- 解决GraphViz's executables not found
用python做决策树可视化时,出现了下面的错误: 于是安装Graphviz,并将其添加到path的环境变量. Graphviz下载 提取码:fmst 但是已经安装了pydotplus且import之 ...
- Java二维数组转成稀疏sparsearray数组
稀疏数组 基本介绍 当一个数组中大部分元素为0,或者为同一个值的数组时,可以使用稀疏数组来保存该数组. 稀疏数组的处理方法是: 记录数组一共有几行几列,有多少个不同的值 把具有不同值的元素的行列及值记 ...
- HDU5286 wyh2000 and sequence【分块 均摊复杂度】
HDU5286 wyh2000 and sequence 题意: 给出长为\(N\)的序列\(A_1,A_2,A_3,\cdots,A_n\),\(q\)次询问,每次询问给出区间\([L,R]\),假 ...
- Java多线程同步和异步问题
我们首先来说一下多线程: 多线程很形象的例子就是:在一个时刻下,一个班级的学生有人在拖地,有人在擦窗户,有人在擦桌子 按照单线程程序,肯定是先去拖地,再去擦窗户,再去擦桌子.但是在多线程就好像他们在一 ...
- 2019HDU多校 Round4
08 K-th Closest Distance 题意:询问区间l,r中与数p的距离为第k大的数 求这个距离 题解:很裸的主席树 二分答案 然后可以用主席数判断在这个区间内 一段值域内出现的数 之前没 ...
- Codeforces Round #647 (Div. 2) C. Johnny and Another Rating Drop(数学)
题目链接:https://codeforces.com/contest/1362/problem/C 题意 计算从 $0$ 到 $n$ 相邻的数二进制下共有多少位不同,考虑二进制下的前导 $0$ .( ...
- java.awt.event.MouseEvent鼠标事件的定义和使用 以及 Java Swing-JTextArea的使用
最近发现一个CSDN大佬写的Java-Swing全部组件的介绍:Java Swing 图形界面开发(目录) JTextArea 文本区域.JTextArea 用来编辑多行的文本.JTextArea 除 ...
- URAL - 1635 哈希区间(或者不哈希)+dp
题意: 演队在口试中非常不幸.在42道考题中,他恰好没有准备最后一道题,而刚好被问到的正是那道题.演队坐在教授面前,一句话也说不出来.但教授心情很好,给了演队最后一次通过考试的机会.他让这个可怜的学生 ...
- 使VS开发的程序在Win7系统运行时自动提升权限
软件开发时,总是会遇到在Win7系统上运行不起来或者异常的情况,这通常是用户的权限不够引起的. 下面提供一个可以使程序运行时,自动提升用户权限的方法. 1.右键点击启动项目,单击"属性&qu ...
- anaconda jupyter notebook 启动方法
介绍 anaconda jupyter notebook是一种基于浏览器的python编译环境.(大概) 使用时可能因为浏览器缓存造成问题. 但是很方便. 启动方法 anaconda navigato ...