手撕AQS源码第三弹 --AQS加锁解锁原理

AQS加锁解锁原理

这是网上引用的一张流程图,讲的非常明白,但是对于没有扒过源码的小伙伴而言,有点困难了。比如SIGNAL,方法没有加注释,就上传了这个一个图也好几百访问量,我裂开了。

首先讲加锁的流程之前,先把相关的方法,变量简单讲一遍

相关变量

private transient volatile Node head;

private transient volatile Node tail;

private volatile int state;

解释

我们的队列由Node组成,每个Node里有prev,next,waitstate,Thread等属性。我们的head和tail节点是作为一个指针的存在,head指向队列的第一个节点,tail指向队列的最后一个节点,如果head==tail或者head为空,tail也为空(head==tail也成立),说明队列没有被初始化,或者没有线程在排队。

state表示锁的状态,规定state为0是,表示没有线程持有锁,锁是自由态。如果锁是可以重用的,那么state可以是1,2,3,如果不是,那么state是0或者1。一些情况下会出现state等于-1的状态,AQS会直接抛出一个Error,说明是严重的错误了。

AQS阻塞队列大概长这样:

  • head指向队列的第一个节点,第一个节点一定是没有存储线程的
  • tail指向队列的最后一个线程,waitestate一定是0,后序会证明
  • 队列中节点直接是有序的,使用双向链表进行连接。

image-20200920145135617

相关方法

//获得锁(程序入口)
public final void acquire(int arg){}
//尝试获得锁
protected boolean tryAcquire(int arg){}
//进队列
private Node addWaiter(Node mode){}
//进队列候的一系列排队操作
final boolean acquireQueued(final Node node, int arg){}
//是否应该睡眠
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node){}
//应该睡眠
private final boolean parkAndCheckInterrupt(){}

基本流程

我们知道AQS是没有lock方法的,实际上lock方法是Lock接口的方法,而AQS没有实现这接口。

而我们的ReentrantLock锁有lock方法,实际上就是直接使用acquire()方法,所以acquire是AQS的入口。

image-20200920121919640

加锁流程

我么以ReentrantLock的公平锁来简单讲一下加锁解锁的过程。

首先我们ReentrantLock有两个方法:lock()和unlock()

//定义一个可重入的公平锁
ReentrantLock l = new ReentrantLock(true);

l.lock();
....
l.unlock();

第一个线程来了,方法的执行流程如下图,简单看一下。
image-20200920123321402

进入lock方法,走acquire(1)方法,acquire()方法调用了tryAcquire方法,而tryAcquire方法也有一次CAS。

  • 如果我们的tryAcquire方法返回true,则acquire方法剩余部分不执行,方法退出,lock也退出,程序继续执行,这也就是锁的基本原理,只要成功拿到锁了,我们的lock()方法是直接畅通无阻的。

  • tryAcqurire方法返回true有几种情况:

    • 如果我们要获取锁的线程如果等于当前持有锁的线程,我们状态加1,表示可重入。当我们state解锁时减一直至减少到0时,才释放锁资源。
    • 如果当前锁的状态是无锁态,那么我们先判断一下等待队列是否有线程在排队,为什么要这个判断呢?我们知道等待线程都是阻塞的存放在队列中,当我们当前线程释放锁时,锁状改为0。然后唤醒第一个线程,但是并不代表该线程立刻获得CPU时间片,可能与此同时一个新的线程程序也走到lock方法,也执行tryAcquire方法,此时判断锁的状态是0,也有可能CAS成功。所以在CAS之前,我们应该判断一下此时锁状态为0可不可能是工作线程刚好释放资源准备把锁交给B。如果队列是空的,才能放心的去CAS。
    • 好好理解这个过程,唤醒等待线程的同时可能会有新线程的加入,如何处理,这也是公平锁和非公平锁的区别

在这里插入图片描述

  • 既然队列为空的,为什么要CAS,而不是直接setState=1呢?记住!并发环境,任何非原子性的操作,都必须使用CAS或者锁来实现原子性。假如100个线程同时进来,判断队列都是空,岂不是都拿到锁了,这也是重要的并发思想。我们认为读数据是原子性的,写是非原子性的。我们任何get方法可以不用CAS,但是修改数据必须保证原子性

  • 我们如果CAS成功直接方法就执行完毕了,程序走过lock方法继续执行其他代码。如果我们有线程持有锁了,那么我们应该让线程停留在lock方法,才能实现锁的功能。

    假如我们CAS失败,我们会走addWaiter方法

相应的方法注释我已经写在图中了,但是大家肯定有一个问题,那就是这个队列长什么样,为什么我们队列没有初始化时,要插入空参数构造的节点来作为head头节点,这个队列的详细放下一篇博客。
简单看一下流程,队列没有就造一个再插入新节点,否则直接插入新节点,tail指向新的节点。
image-20200920125449816
Node的空参构造方法什么事情都没有做,所以默认Thread为null,默认waitState为0。

//用于建立初始标头或SHARED标记
Node() { }       

这是队列的示意图,加深理解。

默认情况下,可以理解为头尾指向null

image-20200920130651817

执行完addWaiter方法之后,是这样的。关于这个队列的详解,我放在下一篇博客。

image-20200920130839499

  • addWaiter方法是入队操作,但是还没有实现阻塞,所以我们还需要执行acquireQueued方法

image-20200920131028351

  • acqureedQueued方法看到一个for(,)循环,表示死循环。首先获取当前节点的前面一个节点,我们看了上图知道,队列的第一个节点是空的,第二个节点才是我们插入的节点。

image-20200920131900950

  • 思考一下为什么我们已经进了队列不是去直接睡眠,而是要判断这个head呢?是这样的,我们在初始化队列,并将我么的线程进入队列的过程中,这个时间段很可能当前持有锁的线程释放了锁,我们要判断一下我们是不是当前队列除了空节点以外的第一个线程。

    因为前面没人排队,很可能我们是有机会得到锁资源的,这里进行一次CAS,如果成功,我们执行setHead方法,setHead方法大概就是将我们当前线程持有锁,然后将其作为新的队列头,原来的头GC了。

image-20200920132343342

  • 此时队列只有一个空节点,而我们没有出现锁竞争的时候,head和tail都是空,所以我们的队列是第一次出现资源竞争的时候才创建的!

  • 刚刚我们是演示入队列之后CAS成功的案例,如果没有成功呢?

    没成功走这个shouldParkAfterFailedAcquire方法,表示我们失败之后是否要睡眠呢?上图我们知道,head指向的头节点是个空参构造的,所以int类型常量默认是0,我们走compareAndSetWaitstate(),传入之前的节点,ws(此时为0),SIGNAL(-1)。调用封装好的CAS方法将前一个节点的waitState状态改为-1,然后返回false

image-20200920132836426

  • 我们返回false之后,循环结束,又判断是不是第一个等待的线程,当前线程是第一个等待的线程,所以再次CAS。所以一共CAS两次。如果第二次也失败,由于之前CAS改了前一个节点的waitState,所以shouldParkAfterFailedAcquire返回true。那么我们只能走短路&&的parkAndCheckInterrupt让线程阻塞。

image-20200920115902036

第二次循环的队列情况示意图

image-20200920134038418

  • LockSuport的park方法调用了Unsafe类的阻塞方法,这个方法不详细讲,可以理解为,程序永远停在这一行了!直观的感受就是我们线程执行到lock方法卡住了,过了一会儿才释放,实现了锁。

image-20200920133917433

解锁流程

此时我们的AQS总体情况是这样的:

image-20200920134043984

假设我们实际拥有锁的线程是线程A,现在等待的是线程B,如图:

image-20200920134320929

线程A执行完毕,加入不是重入锁,也就是AQS的state为1,此时我们要释放锁,要执行以下两个操作:

  • 锁标志state改为0
  • 唤醒等待队列的一个线程,也就是线程B

首先我们持有锁线程走到unlock方法释放锁,直接调用了release方法,sync是AQS的实现类,调用了sync重写的release方法。

image-20200920142356107

  • tryRelease先判断一下持锁状态是否正确,这里肯定也是有一定的考虑。然后判断state-1是否是0,如果为0释放锁,由于重入锁的缘故,可能我们只释放了一层锁,要走完所有的unlock直至state减至0才释放。
  • 由于我们的unlock方法是void返回值,所以无论返回结果是什么,我们的unlock方法都是可以无阻碍执行。

image-20200920143327741

  • 如果我们成果释放锁之后,我们判断一下是否有等待队列,因为如果我们线程执行lock时无人抢占,是不用进队列的,所以多个线程交替执行,可能出现队列一直没被初始化,一直为空的情况。这也就是JUC的锁的效率高的原因,如果不存在交替执行,其实我们的线程只要进行一次CAS操作即可。

image-20200920143732200

  • 对于这个waitStatus的理解,我们简单几种情况

这是我们有一个线程B在排队的情况,可以看到head指向的Node节点的waitState确实为-1,可以unpark操作

image-20200920143818967

这是我们队列未初始化的时候,可以看到head==null,所以不用unpark

image-20200920143946894

这是我们初始化过队列,但是队列的数据指向完毕之后,waitState==-1也是不满足的。

image-20200920144111423

简单思考一下:

  • 我们之前入队列之后的shouldParkAfterFailedAcquire操作是不是把一个节点的前面的节点的waitState改为-1,自己是0。所以我们发现:
    • 一个节点的前面所有节点的waitState肯定是-1,最后一个节点的waitState肯定是0,因为没有再后面一个节点把它改为0了,它是最后一个节点。

image-20200920144741127

  • 每次从队列唤醒一个都会把head指向第二个节点,然后把第一个节点的指针信息清空,让其被GC,第二个节点作为新的head节点,然后把它的Thread置为null。由于后面没有节点了,我们发现

    • 当等待队列的最后等待线程唤醒之后,队列里还剩一个空节点,waitState值为0

      因为该waitestate是最后一个节点了,我们覆盖了Thread但是没有覆盖waitstate。

image-20200920144512752

简单模拟一下多个等待队列的情况,除了最后一个节点,其他节点的waitState为-1,然后第一个节点的Thread==null。

image-20200920145135617

我们唤醒一个线程,可以看到,下一个线程的waitState也是-1,也能被唤醒,

image-20200920145254290

直到我们换线了线程D,此时我们下一次条件判断h!=null && h.waitState!=0就不成立了!

image-20200920145341936

所以我们之前的这个条件判断是合理并且正确的。

image-20200920145509711

  • 接下来我们,我们看一下唤醒操作

我们之前的案例只看到了waitState只有在0和-1的切换,实际上,waitState还有很多状态,比如1,2。定时任务,多久之后才执行,立刻执行,等各种控制操作,这里我们就讲最简单0和-1的waitState状态装换。

image-20200920145857662

我们不要管这个循环,反正取出第一个小于等于0的线程,小于-1说明很多排队线程,0说明就一个在排队,然后唤醒。

我们unparkSuccessor实际上传入的是我们要唤醒的节点的前一个节点,s是我们要唤醒的节点。我们将其Node的thread属性,也就是要唤醒的线程插入unpark方法唤醒,这是UNSAFE的方法,理解为接触park()方法。

image-20200920150336104

我们之前的程序分析:

lock方法如果没有拿到锁,我们的代码会停留在这个地方。

image-20200920150411081

我么的代码停留在LockSupport.park()方法中,此时我们已经调用unpark方法了,所以这个代码继续执行。

image-20200920150528899

如图,我们的parkAndCheckInterrupt()方法执行完毕,返回Thread.interrupted此时的返回值是true,将interrupted改为true。由于这是个死循环,继续尝试拿锁。此时我们由于是公平锁,被唤醒的线程肯定能拿到锁,我们将这个唤醒的线程作为持锁线程,然后方法返回interruped返回true。

关于这个interrupted标签,简单说明一下:

  • 如果我们一个线程进队列,还是有机会CAS成功的,我们之前分析有两次CAS的机会,如果这两次中任意一次成功,我们会获得执行权,返回interrupted标记为false,意思就是我们的线程没有被打断,没有睡眠
  • 我们看到唤醒的返回值是true,是被打断过的意思。

image-20200920150654893

没有被打断的线程,acquiredQueued的值为false,所以不会执行selfInterrupt(),被打断的返回true,执行selfInterrupt这里执行被唤醒之后的一些其余操作,这里不再叙述了。然后acquire方法执行完毕,lock方法也执行完毕,程序往下执行,直观的感受就是lock方法又能继续往下走了。

image-20200920151546597

我们看到加锁解锁实际上是使用UNSAFE类的park和unpark方法来实现线程睡眠和唤醒的。

非公平锁的加锁流程

非公平锁和公平锁其实解锁是一样的,只是加锁,非公平锁有一些插队操作。

非公平锁,在lock的时候尝试CAS,成功之后,直接设置为持锁线程

image-20200920152016413

tryAcquire方法几乎一模一样,但是非公平锁不用判断是否有线程排队,只要CAS成功,直接获取锁。

image-20200920152030046

总结

  • 在线程交替执行时,是不会创建AQS等待队列的
  • 锁即使进队列,在阻塞之前,还是有机会CAS的,其中非公平锁有2-4次机会,公平锁有0到2次。
  • 锁的底层调用了UNSAFE类的park和unpark方法,底层C++实现,关于这个UNSAFE类大家可以多了解一下,内存屏障,CAS,操作内存等操作都支持。
  • 不同的锁会对AQS的方法进行不同的补充,有时间我会总结AQS的其他类型的实现类的锁的执行原理。

以上就是AQS的加锁解锁的流程了,如果有时间我会把队列的图解流程和waitState的其他状态再整理一篇博客

  • 4
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值