AQS源码看不懂?两个断点帮你搞定

前言

楼主在准备面试时恶补Java并发和JUC, 对其中AQS的实现一直没有很明确的理解,网上论AQS实现的文章也都是一笔带过(其实有很详细的,但可阅读性和源码差不太多) 于是决定自己对战源码,并将过程记录,以帮助和我一样想了解原理的小伙伴

JDK: 1.8.0_201

1、 一些基础知识

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

  • AQS的意义
  • AQS框架
  • 通过AQS实现自定义锁
  • ReentrantLock实现的简单原理

2、CLH队列

关于CLH队列的介绍,很多文章都讲得很好,本文就不赘述了,贴个图假装我已经讲过了
在这里插入图片描述

3、lock时的Debug

我编写的测试程序如下:

public class Main{
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();

        Thread thread1 = new Thread(() -> {
            try {
                lock.lock();
                Thread.sleep(10000000);
                lock.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thread1");

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(1000);
                lock.lock();
                Thread.sleep(10000);
                lock.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thread2");
        
        thread1.start();
        thread2.start();
    }
}

还是非常简单易懂的,想要实现的也就是thread2阻塞:即thread1获得锁,thread2申请锁资源时被阻塞的情况。

3.1 lock方法干了些什么?

我们将断点打在thread2.lock方法上:
在这里插入图片描述
debug启动后,线程执行了ReentrantLock的不公平锁的lock方法
在这里插入图片描述

简单阅读一下: lock方法尝试通过CAS操作获取资源,如果获取成功则将资源持有线程标记为当前线程, 失败则进入acquire方法。 很明显,此处不可能竞争成功,故我们在进入acquire方法。

3.2 acquire干了什么

在这里插入图片描述
通过方法描述我们能知道:

以独占的方式获取资源,并且会忽略interrupts。 同时会执行至少一次的tryAcquire来获取锁,如果获取到了就返回; 否则这个线程可能会经历多次阻塞和唤醒,直至tryAcquire成功

官方的方法描述基本将AQS的核心说清楚了,那我们再看看其具体实现:

  1. 方法首先调用tryAcquire方法,成功则跳出if条件(短路原则)并返回
  2. 如果获取资源失败,则调用addWaiter,向CLH队列中添加一个等待结点, 随后调用acquireQueued方法
  3. 如果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方法做了以下事:

  1. 将当前线程封装为一个Node结点,并且Node中保存着当前线程的资源争夺模式
  2. 将当前结点插入到CLH队列中, 并且CLH队列中存在一个空的头部,这个头部指向当前Node结点

3.4 acquireQueued干了什么

在这里插入图片描述

以独占不间断模式获取已在队列中的线程。

这个方法乍一看可能没什么思路,我们一步一步来分析:

首先可以看到,这里具有两个局部变量: failed和interrupted。 failed在方法结束时判断是否要取消acqurie, 默认是要取消的; 而interrupted是方法的返回值,标记着当前线程是否被打断了。

我们继续看源码: 这里同样使用了一个死循环, 循环执行以下内容

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

3.4.1 predecessor方法

在这里插入图片描述

返回上一个节点,如果为 null,则抛出 NullPointerException。

即方法返回CLH中当前结点的前驱结点

3.4.2 shouldParkAfterFailedAcquire方法

这里插一嘴,读者可能不明白我为什么突然跳到下面的方法进行讲解,而不是讲解中间的尝试获取资源代码段, 您先跟着看,稍后可能就明白了

在这里插入图片描述

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

在这里插入图片描述

方法主要分三个逻辑:

  1. 如果前驱node的等待状态为SIGNAL, 则返回true(即被阻塞)
  2. 如果前驱node的等待状态大于0(由图可知,即状态为取消) 则从当前node开始,查找并删除CLH中前驱node为CANCELLED的结点
  3. 如果前驱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代码块

if (p == head && tryAcquire(arg)) {
    setHead(node);
    p.next = null; // help GC
    failed = false;
    return interrupted;
}

这里的代码也是非常的简单, 判断前一个结点是否为头结点, 如果是则进行本线程的尝试锁获取, 如果获取成功则将当前node设置为头结点,并且跳出acquireQueued方法,返回打断标记。

问题是,为什么要判断前驱结点是否为头结点之后才进行资源的尝试获取呢?
没有为什么,这就是CLH的规则,让CLH中队首的线程尝试竞争锁。 而某一结点如果前驱结点是头结点,那他就是位于队首的未获得资源的线程。 同时这里也要指出很多文章的错误观点: CLH的head结点一定是当前获取到锁资源的线程。 很明显是错误的,head结点还有可能是被初始化的空Node结点; 或是已经释放锁的线程结点。

这里可能大家还有一个疑惑, 代码讲解时为什么要先将后面的if而不是前面的if。 从思路上看,第一个if是尝试进行资源获取;而后一个if是进行线程的阻塞。 一般来说,对于锁的竞争都是发生与阻塞唤醒后的,而官方将尝试获取锁放在阻塞前我想可能是为了优化刚准备阻塞时资源就被释放的情况吧。

总的来说,acquireQueued方法会将本线程的Node进行线程状态初始化、阻塞当前线程并且在线程执行期间尝试对锁进行获取(在CLH队列中只有头结点后的首个结点可以尝试进行锁获取) 最后返回这个线程获取锁过程中是否被打断过

我想到这里,大家应该都清楚了在调用acquire时发生的事,也清楚了线程是如何构建node,存储node、等待唤醒和竞争资源的。

4、 unlock时的Debug

现在我们把代码稍稍改一下

public class Main{
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();

        Thread thread1 = new Thread(() -> {
            try {
                lock.lock();
                Thread.sleep(2000);
                lock.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thread1");

        Thread thread2 = new Thread(() -> {
            try {
                Thread.sleep(1000);
                lock.lock();
                lock.unlock();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "thread2");

        thread1.start();
        thread2.start();
    }
}

并在thread1的unlock方法上打上第二个断点
在这里插入图片描述
方法很简单,尝试看看线程1 unlock时会对线程2进行什么操作

4.1 release方法

在这里插入图片描述

独占模式的资源释放, 当tryRelease方法返回true时会唤醒一个或多个线程

代码非常简单, 当释放成功后拿到CLH队列的头部, 并且调用unparkSuccessor方法来尝试unpark

btw, 我们还可以看看现在CLH的结构,以证明我上面的图是正确的:
在这里插入图片描述

4.2 unparkSuccessor方法

在这里插入图片描述

如果后继结点存在,则进行唤醒

方法分为几步:

  1. 如果后继结点的线程状态小于0 (请参照上面的常量截图) 则使用CAS进行waitStatus的修改(为什么要用CAS)
  2. 如果后继结点为null或是后继结点存在CANCELLED的线程,则进行清除
  3. 如果后继结点不为null, 则一定是一个需要被唤醒的结点,则进行唤醒

则本方法的一个简单阐述为: 尝试释放资源,释放成功后唤醒第一个不为CANCELLED的后继结点线程。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值