Debug看懂AQS到底干了什么

打印 上一主题 下一主题

主题 1023|帖子 1023|积分 3069

前言

之前楼主在准备口试时恶补Java并发和JUC, 对其中AQS的实现一直没有很明确的理解,网上论AQS实现的文章也都是一笔带过(其实有很具体的,但可阅读性和源码差不太多) 于是决定本身对战源码,并将过程记录,以帮助和我一样想相识原理的小搭档
JDK:1.8.0_201
1、 一些基础知识

当你找到这篇文章,那我默认你已经相识了AQS的一些基础知识:


  • AQS的意义
  • AQS框架
  • 通过AQS实现自定义锁
  • ReentrantLock实现的简单原理
2、CLH队列

关于CLH队列的先容,许多文章都讲得很好,本文就不赘述了,贴个图冒充我已经讲过了

3、lock时的Debug

我编写的测试步伐如下:
  1. public class Main{
  2.     public static void main(String[] args) throws InterruptedException {
  3.         ReentrantLock lock = new ReentrantLock();
  4.         Thread thread1 = new Thread(() -> {
  5.             try {
  6.                 lock.lock();
  7.                 Thread.sleep(10000000);
  8.                 lock.unlock();
  9.             } catch (InterruptedException e) {
  10.                 e.printStackTrace();
  11.             }
  12.         }, "thread1");
  13.         Thread thread2 = new Thread(() -> {
  14.             try {
  15.                 Thread.sleep(1000);
  16.                 lock.lock();
  17.                 Thread.sleep(10000);
  18.                 lock.unlock();
  19.             } catch (InterruptedException e) {
  20.                 e.printStackTrace();
  21.             }
  22.         }, "thread2");
  23.         
  24.         thread1.start();
  25.         thread2.start();
  26.     }
  27. }
  28. 复制代码
复制代码
照旧非常简单易懂的,想要实现的也就是thread2阻塞:即thread1获得锁,thread2申请锁资源时被阻塞的情况。
3.1 lock方法干了些什么?

我们将断点打在thread2.lock方法上:

debug启动后,线程执行了ReentrantLock的不公平锁的lock方法
简单阅读一下:lock方法实验通过CAS利用获取资源,如果获取乐成则将资源持有线程标记为当前线程, 失败则进入acquire方法。很明显,此处不大概竞争乐成,故我们在进入acquire方法。
3.2 acquire干了什么


通过方法形貌我们能知道:
   以独占的方式获取资源,而且会忽略interrupts。 同时会执行至少一次的tryAcquire来获取锁,如果获取到了就返回;否则这个线程大概会经历多次阻塞和叫醒,直至tryAcquire乐成
  官方的方法形貌根本将AQS的焦点说清晰了,那我们再看看其具体实现:

  • 方法首先调用tryAcquire方法,乐成则跳出if条件(短路原则)并返回
  • 如果获取资源失败,则调用addWaiter,向CLH队列中添加一个等候结点, 随后调用acquireQueued方法
  • 如果acquireQueued方法也返回false, 则进入selfInterrupt方法
tryAcquire方法我们这儿就不看了,因为这是实现者本身编写的方法,和我们想要探究的内容无关,我们只必要知道调用tryAcquire会实验获取资源,但不会阻塞,而且会返回获取资源的乐成与否
那么很明显的,这里的重点就是addWaiter和acquireQueued方法, 我们一个一个的来看
3.3 addWaiter干了什么


   通过模式给当前线程创建一个列队结点
  代码很简单,通过传入的mode和当前线程,构建出一个Node对象, 而且将这个Node对象放置在队列末了。
3.3.1 Node对象的创建

其中,addWaiter的参数mode可选值为Node方法中的两个常量:SHARED和EXELUSIVE, 分别为共享和独占

而且在Node构造函数中,将这个mode传值给了nextWaiter属性至此我们知道,addWaiter方法为我们创建了一个Node对象,其中包含当前线程信息和当前线程的资源竞争模式
3.3.2 Node对象的插入

我们回到addWatier方法:

方法首先获取CLH队列的末了结点tail, 判定末了结点是否为null:


  • 如果为null, 代表当前CLH队列为空,必要初始化,即进入enq方法
  • 如果不为null, 使用CAS利用进行双向链表结点的插入并返回当前node结点( 思索一下为什么要用CAS )
我们再跟入enq方法:

   将node插入队列,必要的时间会进行队列初始化
  源码中可以看到,方法使用一个死循环,如果当前队列为空,则为其添加一个 新的Node结点;  当队列不为空时才进行双向链表的插入利用,同样也是使用CAS进行插入 ( 思索一下为什么要使用死循环进行利用,而不是进行if判定queue是否为空后直接利用 )
至此我们知道,addWaiter方法做了以下事:

  • 将当前线程封装为一个Node结点,而且Node中保存着当前线程的资源争夺模式
  • 将当前结点插入到CLH队列中, 而且CLH队列中存在一个空的头部,这个头部指向当前Node结点
3.4 acquireQueued干了什么


   以独占不间断模式获取已在队列中的线程。
  这个方法乍一看大概没什么思绪,我们一步一步来分析:
首先可以看到,这里具有两个局部变量:failed和interrupted。failed在方法结束时判定是否要取消acqurie, 默认是要取消的;而interrupted是方法的返回值,标记着当前线程是否被打断了。
我们继承看源码:这里同样使用了一个死循环, 循环执行以下内容

  • 通过predecessor方法获取一个Node结点
  • 如果这个指定结点为CLH队列头部,则实验让当前线程获取共享资源, 获取乐成则将当前线程的node设置为头结点, 而且删除原头结点与当前node的关系,设置failed为false 而且返回false。
  • 如果指定结点不为CLH头部结点或是获取资源失败,则调用shouldParkAfterFailedAcquire方法, 其返回乐成后调用parkAndCheckInterrupt方法, 都返回乐成后设置打断标记为true
3.4.1 predecessor方法


   返回上一个节点,如果为 null,则抛出 NullPointerException。
  即方法返回CLH中当前结点的前驱结点
3.4.2 shouldParkAfterFailedAcquire方法

这里插一嘴,读者大概不明确我为什么突然跳到下面的方法进行解说,而不是解说中心的实验获取资源代码段, 您先跟着看,稍后大概就明确了

   查抄和更新未能获取资源的节点的状态。如果本线程应该被阻塞,则返回 true
  

方法主要分三个逻辑:

  • 如果前驱node的等候状态为SIGNAL, 则返回true(即被阻塞)
  • 如果前驱node的等候状态大于0(由图可知,即状态为取消) 则从当前node开始,查找并删除CLH中前驱node为CANCELLED的结点
  • 如果前驱node的等候状态为0, 即未初始化,则初始化为SIGNAL
读者们乍一看大概看不明确,怎么突然涉及了一个什么waitStatus, 又涉及什么前驱结点状态、删除后继结点巴拉巴拉的。这里必要给各人说明一个AQS实现CLH的知识点:==线程的锁竞争状态是存储在当前结点的nextWaiter中的, 但线程的状态是存储在前驱结点的waitStatus(signal propagate)或是本结点的waitStatus(cancel condition)中的。这也是为什么在初始化CLH队列时必要一个空的Node作为CLH的头部== 以下一份简图表示队列的关系:

知道了这么个知识点,那我们理解起来就容易多了:shouldParkAfterFailedAcquire方法首先判定当前线程是否为SIGNAL状态,如果是则阻塞(返回true);如果是CANCELLED, 则连续寻找,直到找到一个waitStatus不为CANCELLED的前驱结点;如果前驱结点waitStatus状态未初始化,则进行SINAL赋值
即本方法是将传入的结点进行线程状态的初始化、大概根据线程状态来决议是否必要进行阻塞。
3.4.3 parkAndCheckInterrupt方法


   阻塞而且查抄是否被中断的轻便方法
  方法在shouldParkAfterFailedAcquire方法决议必要进行阻塞后调用, 本方法也非常简单,使用park阻塞本线程,而且在叫醒后返回当前是否为中断叫醒
   这么一来,我们便搞清晰了下面的if块执行的逻辑:对一个新加入的结点,初始化线程状态,而且在第二次循环时将自身阻塞,等候叫醒或打断。
  3.4.4 上面的if代码块

  1. if (p == head && tryAcquire(arg)) {
  2.     setHead(node);
  3.     p.next = null; // help GC
  4.     failed = false;
  5.     return interrupted;
  6. }
  7. 复制代码
复制代码
这里的代码也是非常的简单, 判定前一个结点是否为头结点, 如果是则进行本线程的实验锁获取, 如果获取乐成则将当前node设置为头结点,而且跳出acquireQueued方法,返回打断标记。
问题是,为什么要判定前驱结点是否为头结点之后才进行资源的实验获取呢?没有为什么,这就是CLH的规则,让CLH中队首的线程实验竞争锁。而某一结点如果前驱结点是头结点,那他就是位于队首的未获得资源的线程。同时这里也要指出许多文章的错误观点:CLH的head结点一定是当前获取到锁资源的线程。很明显是错误的,head结点还有大概是被初始化的空Node结点;或是已经开释锁的线程结点。
这里大概各人还有一个疑惑, 代码解说时为什么要先将后面的if而不是前面的if。从思绪上看,第一个if是实验进行资源获取;而后一个if是进行线程的阻塞。 一般来说,对于锁的竞争都是发生与阻塞叫醒后的,而官方将实验获取锁放在阻塞前我想大概是为了优化刚准备阻塞时资源就被开释的情况吧。
   总的来说,acquireQueued方法会将本线程的Node进行线程状态初始化、阻塞当前线程而且在线程执行期间实验对锁进行获取(在CLH队列中只有头结点后的首个结点可以实验进行锁获取) 最后返回这个线程获取锁过程中是否被打断过
  我想到这里,各人应该都清晰了在调用acquire时发生的事,也清晰了线程是如何构建node,存储node、等候叫醒和竞争资源的。
4、 unlock时的Debug

现在我们把代码稍稍改一下
  1. public class Main{
  2.     public static void main(String[] args) throws InterruptedException {
  3.         ReentrantLock lock = new ReentrantLock();
  4.         Thread thread1 = new Thread(() -> {
  5.             try {
  6.                 lock.lock();
  7.                 Thread.sleep(2000);
  8.                 lock.unlock();
  9.             } catch (InterruptedException e) {
  10.                 e.printStackTrace();
  11.             }
  12.         }, "thread1");
  13.         Thread thread2 = new Thread(() -> {
  14.             try {
  15.                 Thread.sleep(1000);
  16.                 lock.lock();
  17.                 lock.unlock();
  18.             } catch (InterruptedException e) {
  19.                 e.printStackTrace();
  20.             }
  21.         }, "thread2");
  22.         thread1.start();
  23.         thread2.start();
  24.     }
  25. }
  26. 复制代码
复制代码
并在thread1的unlock方法上打上第二个断点

方法很简单,实验看看线程1 unlock时会对线程2进行什么利用
4.1 release方法


   独占模式的资源开释, 当tryRelease方法返回true时会叫醒一个或多个线程
  代码非常简单, 当开释乐成后拿到CLH队列的头部, 而且调用unparkSuccessor方法来实验unpark
btw, 我们还可以看看现在CLH的结构,以证明我上面的图是正确的:

4.2 unparkSuccessor方法


   如果后继结点存在,则进行叫醒
  方法分为几步:

  • 如果后继结点的线程状态小于0 (请参照上面的常量截图) 则使用CAS进行waitStatus的修改(为什么要用CAS)
  • 如果后继结点为null或是后继结点存在CANCELLED的线程,则进行清除
  • 如果后继结点不为null, 则一定是一个必要被叫醒的结点,则进行叫醒
则本方法的一个简单阐述为:实验开释资源,开释乐成后叫醒第一个不为CANCELLED的后继结点线程。

免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。

本帖子中包含更多资源

您需要 登录 才可以下载或查看,没有账号?立即注册

x
回复

使用道具 举报

0 个回复

倒序浏览

快速回复

您需要登录后才可以回帖 登录 or 立即注册

本版积分规则

老婆出轨

论坛元老
这个人很懒什么都没写!
快速回复 返回顶部 返回列表