Debug看懂AQS到底干了什么

本文详细探讨了AQS(AbstractQueuedSynchronizer)在Java并发中的作用,特别是通过CLH队列实现线程同步的机制。通过分析ReentrantLock的lock和unlock方法,解释了线程如何通过AQS进入等待队列以及在资源释放时被唤醒的过程,揭示了AQS的核心操作如tryAcquire、addWaiter、acquireQueued和release的工作原理。

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 4 天,点击查看活动详情

前言

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

本文就独占的资源获取进行debug,期望了解AQS共享资源争夺相关内容的可以同理自己debug查看~

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方法也返回true, 则进入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结点

我们再跟入enq方法: 

将node插入队列,必要的时候会进行队列初始化

源码中可以看到,方法使用一个死循环,如果当前队列为空,则为其添加一个 新的 Node结点; 当队列不为空时才进行双向链表的插入操作,同样也是使用CAS进行插入

至此我们知道,addWaiter方法做了以下事:

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

3.4 acquireQueued干了什么

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

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

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

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

  1. 通过predecessor方法获取一个Node结点p
  2. 如果这个p结点为CLH队列头部,则尝试让当前线程获取共享资源, 获取成功则将当前线程的node设置为CLH头结点, 并且删除原头结点p与当前node的关系,设置failed为false 并且返回false。
  3. 如果结点p不为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阻塞本线程,并且在唤醒后返回当前是否为中断唤醒

这么一来,我们便搞清楚了acquireQueued中第二个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、等待唤醒和竞争资源的。

3.5 总结一下

  • acquire方法为核心的独占资源获取方法,其内部先调用tryAcquire方法进行单次资源获取尝试:成功则结束方法;失败则先将使用addWaiter方法将当前线程存入CLH队列中,再调用acquireQueued方法进行循环的阻塞与资源获取尝试。
  • addWaiter方法向CLH队列尾部插入结点,如果队列未初始化,则会初始化一个空对象作为头部
  • acquireQueued方法为核心的循环独占资源获取方法,其内部使用死循环不断地进行资源的尝试获取失败后的CLH维护及线程的阻塞
  • acquireQueued方法中资源的获取通过判断当前是否为CLH中的第二个结点,如果是则尝试进行资源获取; 获取成功时将当前结点变更为头结点(丢弃原有头结点)并标识当前资源获取未失败
  • acquireQueued方法中CLH的维护通过核心方法shouldParkAfterFailedAcquire进行,方法根据前驱结点的waitStatus进行不同操作: 为SIGNAL时意为当前结点可被唤醒,因此将当前结点进行阻塞等待; 为CANCEL时意为前一结点已被取消,当前线程则循环的去除前面相邻的所有被取消结点; 为其他状态时则修改waitStatus为SIGNAL,标识当前结点是可以被唤醒的。
  • acquireQueued方法的最后,如果方法返回时未获取到资源,则标识结点为被取消

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队列的头部,如果CLH已被初始化并且存在waitStatus,则调用unparkSuccessor方法来尝试unpark后续结点

btw, 我们还可以看看现在CLH的结构,以证明我上面的图是正确的: 

4.2 unparkSuccessor方法

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

方法分为几步:

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

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

4.3 总结一下

  • release方法为核心的独占资源释放方法,其内部通过tryRelease方法进行资源的释放,释放成功再进行后续结点的唤醒操作
  • unparkSuccessor方法会更新CLH根结点的waitStatus,并随机唤醒接下来的第一个不为CANCELLED的结点
<think>嗯,用户问的是AQS,也就是AbstractQueuedSynchronizer,需要用简单易懂的语言解释给高中生。首先,我得确保自己完全理解AQS的基本概念和作用,然后想办法用高中生能理解的例子或类比来说明。 首先,AQSJava并发编程中的一个核心类,用于构建锁和同步器,比如ReentrantLock和Semaphore。但直接讲这些术语可能让高中生困惑。所以,我需要找一个日常生活中的例子,比如排队或者厕所的使用规则,这样更贴近他们的生活经验。 然后,用户提供的参考内容里提到了Spring的事务传播属性和动态代理,但这两个可能和AQS关系不大,不过用户可能在之前的对话中提到过这些,所以需要确认是否有关联。不过根据问题,用户现在只关注AQS,所以可以忽略Spring相关的内容,除非有直接联系。 接下来,我需要把AQS的关键点简化。比如,AQS内部维护了一个队列来管理等待的线程,类似于排队做某件事。还有状态变量的概念,用来表示资源是否可用,比如厕所是否有人在使用。这些都可以用简单的比喻来解释。 可能需要注意的技术点包括:队列管理、状态变量、获取和释放资源的机制。但不需要深入细节,比如CLH队列或者具体的CAS操作,这些对高中生来说太复杂了。重点在于让他们理解AQS的作用和基本原理。 然后,生成相关问题的时候,要围绕AQS的应用、解决的问题、核心组件和设计理念,确保问题能够帮助高中生进一步思考和理解这个概念。例如,为什么需要排队机制,或者状态变量的作用是什么。 最后,检查语言是否口语化,避免专业术语,使用简单的词汇和例子。确保整个解释逻辑清晰,步骤分明,容易理解。</think>### AQS(AbstractQueuedSynchronizer)的简单解释 AQSJava 中用来管理多线程访问共享资源的“排队系统”,可以想象成**游乐园热门项目的排队规则**。比如,一个过山车一次只能坐 10 人,AQS 会帮所有想玩的人排好队,并告诉每个人什么时候能上车[^1]。 #### 核心原理分步解释: 1. **状态标记** AQS 内部有一个“计数器”(比如 `state` 变量),用来表示当前有多少资源可用。比如过山车的空座位数。 当 `state=10` 时,表示有 10 个空位;当 `state=0` 时,说明过山车已满。 2. **队列管理** 如果资源被占满(比如过山车没空位了),新来的游客会被放进一个虚拟队列里排队。这个队列是 AQS 用链表结构实现的。 3. **获取资源** 当一个游客想上车时: - 如果 `state>0`(有空位),他可以直接上车,同时 `state` 减 1 - 如果 `state=0`(没空位),他就得去队尾等待 4. **释放资源** 当有游客下车时,`state` 加 1,并通知队列中的下一个游客上车。 #### 生活中的例子: 想象学校机房只有 5 台电脑: - **AQS 的角色**:管理使用规则的黑板 - **state=5**:所有电脑空闲 - **state=0**:电脑全被占用,后来的同学要排队 - **队列**:同学们在门口排成一列,按顺序使用电脑 ```java // 类似 AQS 的简化伪代码(仅示意) class 机房管理器 { int 剩余电脑数 = 5; Queue<学生> 等待队列 = new LinkedList<>(); void 申请使用() { if (剩余电脑数 > 0) { 剩余电脑数--; } else { 等待队列.add(当前学生); 当前学生.等待(); } } void 归还电脑() { 剩余电脑数++; if (!等待队列.isEmpty()) { 唤醒队列中的第一个学生; } } } ```
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值