基于ReentrantLock的案例来学习AQS的源码

1.什么是AQS?

AbstractQueuedSynchronizer:抽象的队列同步器。

2.AQS有多重要?

AQS用来构建锁或者其它同步器组件的重量级基础框架及整个JUC体系的基石, 通过内置的FIFO队列来完成资源获取线程的排队工作,并通过一个int类变量(state)表示持有锁的状态。

3.AQS用来干嘛?

加锁->阻塞->需要排队->使用某种形式的队列管理排队->使用CLH队列的变体实现,将暂时获取不到锁的线程加入到队列中->这个队列就是AQS的抽象表现->将请求资源的线程封装成队列的Node结点,通过CAS、自旋以及LockSuport.park()的方式,维护state变量的状态,使并发达到同步的效果。

4.源码阅读与分析

4.1内部体系架构

 4.2AQS基础组成

暂时先不关注成员方法,查看AQS源码:

 我们可以发现AQS的结构并不是很复杂,就是通过Node结点,以链表的形式模拟一个队列。

在看看内部类Node的源码:

 这里主要看等待状态和线程,说明一个Node里面包含线程,也就是说Node可以理解为对Thread的进一步封装加强,如果Thread是正在排队的人,那么Node就是坐在候客区椅子的人这一整个整体。我们也发现了prev指针和next指针,说明这个队列还是一个双向队列。

那么简单的说,AQS=CLH双端队列(队列中元素是Node)+state变量

以这句话为基础画出AQS的基本结构图:

 通过下面这个方法我们可以看出AQS是通过调用LockSupport.park()来实现排队阻塞的。

 4.3公平锁与非公平锁的选择

ReentrantLock默认的构造方式是非公平锁的

但其实无论是研究公平锁还是非公平锁都一样,对于学习AQS源码影响不大,但是后面的学习就以非公平锁为标准。公平锁就是源码比非公平锁多了一个 !hasQueuedPredecessors()的判断,如下图:

也就是说公平锁只有队列中无线程在等待了,来的线程才会去抢占,队列中有线程在等待时,那么只能加入等待队列;而非公平锁,来的线程会和队列的第一个排队线程进行竞争,而不是因为队列有线程,就自己去队列等待。

4.4以ReenTrantLock的lock()来学习AQS源码

设想一个现实生活的场景。

A,B,C三个同学去食堂窗口排队买饭,人有三个,窗口只有一个,且一次只能服务于一个人。

这里将三个同学类比为线程A、B、C,state代表食堂窗口的占用状态。

初始情景图①:

 假如A先进去

执行lock()方法加锁,源码如下:

 使用cas,若state为0,则替换为1,很明显初始时,state=0,成功更改为1,然后调用setExclusiveOwnerThread,源码如下:

 此时情景②:

 此时B线程也过来调用lock()方法,由于state=1,cas失败,执行acquire(1),源码如下:

 acquire函数源码如下(这个是重点)

 我们发现,光一个if判断就涉及到三个函数,所以我们一个个分析。

首先是tryAcquire(),源码如下:

调用nonfairTryAcquire()函数:

观察到有两个if产生的分支,①是如果state=0,则执行,也就是说如果此时B刚准备要进入执行时,A刚好执行完释放锁,那么state变为0就满足了,然后B可以去获取锁了,使用cas更新state并设置目前占用线程为B。当然此时A未释放锁,所以这个条件不满足,对于②,就是如果线程时当前占用线程,也就是可重入锁的过程,即同一个线程在外层方法获取锁的时候,再进入该线程的内层方法会自动获取锁(前提,锁对象得是同一个对象),不会因为之前已经获取过还没释放而阻塞。此时就是把state+1即可。当然此时占用的是A线程,而现在访问的是B线程,这个条件也不满足,所以最后返回false。此时!tryAcquire(arg)就为真了。

继续看addWaiter函数,源码如下:

 判断尾结点是否为空,很明显初始时为空。则调用enq函数,源码如下:

 分析这个函数在做什么,for是一个死循环,第一次循环,若尾结点为空,调用compareAndSetHead()函数,源码如下:

 简单来说就是给new了一个结点,头指针指向该结点,然后尾指针也指向该结点。

情景③:

 这个结点其实没有实际意思,一般称为哨兵结点或者傀儡结点。我个人认为它的存在可以在队列第一个等待线程出队后比较容易方便处理,不用考虑边界和分类。就比如我们刷leetcode的一个叫删除链表的题,删除链表头节点和链表其他节点需要分类讨论,但如果定义一个无意义的新头节点dummy,这样删除节点,所有情况都能一视同仁,不需要分类,最后结果返回dummy->next即可。

第二次循环,尾结点不为空,此时B代表的节点的前驱指针指向尾结点,尾指针指向B代表的结点,此时B代表的结点才是尾结点。然后尾结点的后继结点指向我们新的尾结点。如下图:

情景④:

 执行完后返回node,因为addWaiter函数还有一个分支①没讲到,所以我们假设此时C也来了,一直运行到addWaiter这个函数之前都和B一样,所以就不重复赘述了。

此时由于尾结点不是空,为B所代表的结点,那么执行下面这个if里面的逻辑:

 该结点的前驱指针指向尾结点,尾指针指向该结点,原尾结点的后继指针指向新的尾结点。

情景⑤:

 那么我们执行完addWaiter后

再接下里就是分析acquireQueued函数了

源码如下:

 注意这里使用了自旋的机制(因为有个for循环)。当线程B执行到这里时,第一个循环,NodeB的前驱结点时哨兵结点,所以p指向哨兵结点。满足p==head,但是执行tryAcquire(arg)会失败,返回false(这个函数讲过很多次了,这里就不多赘述了),所以这个if不符合,进行下一个if,调用shouldParkAfterFailedAcquire(arg)函数,源码如下:

 由情境图可知哨兵结点的waitStatus=0,所以前两个if不满足,最后一个else,调用compareAndSetWaitStatus函数,将其waitStatus改为了-1。返回false后,acquireQueued的第一次循环结束,开始第二次循环,此时该次循环执行shouldParkAfterFailedAcquire(arg)函数返回true(因为第一个if条件成立了),接着会执行parkAndCheckInterrupt()函数,源码如下:

 执行LockSupport.park()阻塞住线程,不会继续执行了,一直在这排队。如果被unpark或者被interrupt就会停止阻塞继续执行。

对于C同理与B,也会阻塞住,且B的waitStatus也改为了-1

情景⑥:

 假如此时A终于打完了饭,执行unlock()方法。

源码如下:

 嗲用release方法,源码如下:

 首先又需要执行tryRelease()方法,源码如下:

此时c=1-1=0,free变为true,占用线程清空,state设为0,返回true。

在realse中if满足,继续执行,h指向哨兵结点,不为空,且其waitStatus=-1,不等于0,所以满足if条件,执行unparkSuccessor(h)函数,源码如下:

 首先ws=-1,所以执行compareAndSetWaitStatus函数,将其设置为0,s指向哨兵结点的下一个结点,即NodeB,下面的第一个if条件不满足,第二个if满足,执行LockSupport.unpark()唤醒B线程。此时哨兵结点的waitStatus又变为了0。

情景⑦:

 这时候由于B线程被唤醒了,我们再会头看B,B停止阻塞,源码如下:

 被正常唤醒,而不是被中断,所以Thread.interrupted()返回false。

回到acquireQueued函数

 返回false,所以if不成立,该次循环,结束,开始第三次循环,此时p==head成立,tryAcuire()也能成功获取到锁,所以if成立,执行setHead函数,源码如下:

 头指针指向NodeB,因为B已经获得锁去窗口打饭了,不再在队列中等待,所以将B中的线程置为null,其前驱指针指向空,也就是说原NodeB因为B线程已经获取锁了就不需要等待了,便变为了新哨兵结点。此时老的哨兵结点就不需要了,将老哨兵结点的后继指针指向空,有助于垃圾回收(ps:根据引用计数法,当一个对象引用计数为0时,则可被回收)。执行完后,返回false,回到acquire函数:

 返回false,if不成立,执行完毕,返回lock(),执行完毕。

情景⑧:

 整个流程基本就分析完了,线程C分析同理,不重复赘述了。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值