CrabNote螃蟹笔记

AQS 详解

[AQS 介绍](aqs-介绍) AQS 的全称为 `AbstractQueuedSynchronizer`,翻译过来的意思就是抽象队列同步器。这个类在 `java.util.concurrent.

AQS 介绍 AQS 的全称为 ,翻译过来的意思就是抽象队列同步器。这个类在 包下面。 AQS 就是一个抽象类,主要用来构建锁和同步器。 AQS 为构建锁和同步器提供了一些通用功能的实现。因此,使用 AQS 能简单且高效地构造出应用广泛的大量的同步器,比如我们提到的 , ,其他的诸如 , 等等皆是基于 AQS 的。 AQS 原理 在面试中被问到并发知识的时候,大多都会被问到“请你说一下自己对于 AQS 原理的理解”。下面给大家一个示例供大家参考,面试不是背题,大家一定要加入自己的思想,即使加入不了自己的思想也要保证自己能够通俗的讲出来而不是背出来。 AQS 快速了解 在真正讲解 AQS 源码之前,需要对 AQS 有一个整体层面的认识。这里会先通过几个问题,从整体层面上认识 AQS,了解 AQS 在整个 Java 并发中所位于的层面,之后在学习 AQS 源码的过程中,才能更加了解同步器和 AQS 之间的关系。 AQS 的作用是什么? AQS 解决了开发者在实现同步器时的复杂性问题。它提供了一个通用框架,用于实现各种同步器,例如 可重入锁 ( )、 信号量 ( )和 倒计时器 ( )。通过封装底层的线程同步机制,AQS 将复杂的线程管理逻辑隐藏起来,使开发者只需专注于具体的同步逻辑。 简单来说,AQS 是一个抽象类,为同步器提供了通用的 执行框架 。它定义了 资源获取和释放的通用流程 ,而具体的资源获取逻辑则由具体同步器通过重写模板方法来实现。 因此,可以将 AQS 看作是同步器的 基础“底座” ,而同步器则是基于 AQS 实现的 具体“应用” 。 AQS 为什么使用 CLH 锁队列的变体? CLH 锁是一种基于 自旋锁 的优化实现。 先说一下自旋锁存在的问题:自旋锁通过线程不断对一个原子变量执行 (简称 )操作来尝试获取锁。在高并发场景下,多个线程会同时竞争同一个原子变量,容易造成某个线程的 操作长时间失败,从而导致 “饥饿”问题 (某些线程可能永远无法获取锁)。 CLH 锁通过引入一个队列来组织并发竞争的线程,对自旋锁进行了改进: 每个线程会作为一个节点加入到队列中,并通过自旋监控前一个线程节点的状态,而不是直接竞争共享变量。 线程按顺序排队,确保公平性,从而避免了 “饥饿” 问题。 AQS(AbstractQueuedSynchronizer)在 CLH 锁的基础上进一步优化,形成了其内部的 CLH 队列变体 。主要改进点有以下两方面: 1. 自旋 + 阻塞 : CLH 锁使用纯自旋方式等待锁的释放,但大量的自旋操作会占用过多的 CPU 资源。AQS 引入了 自旋 + 阻塞 的混合机制: 如果线程获取锁失败,会先短暂自旋尝试获取锁; 如果仍然失败,则线程会进入阻塞状态,等待被唤醒,从而减少 CPU 的浪费。 2. 单向队列改为双向队列 :CLH 锁使用单向队列,节点只知道前驱节点的状态,而当某个节点释放锁时,需要通过队列唤醒后续节点。AQS 将队列改为 双向队列 ,新增了 指针,使得节点不仅知道前驱节点,也可以直接唤醒后继节点,从而简化了队列操作,提高了唤醒效率。 AQS 的性能比较好,原因是什么? 因为 AQS 内部大量使用了 操作。 AQS 内部通过队列来存储等待的线程节点。由于队列是共享资源,在多线程场景下,需要保证队列的同步访问。 AQS 内部通过 操作来控制队列的同步访问, 操作主要用于控制 、 两个操作的并发安全。虽然利用 控制并发安全可以保证比较好的性能,但同时会带来比较高的 编码复杂度 。 AQS 中为什么 Node 节点需要不同的状态? AQS 中的 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。 状态 :新节点加入队列之后,初始状态为 。 状态 :当有新的节点加入队列,此时新节点的前继节点状态就会由 更新为 ,表示前继节点释放锁之后,需要对新节点进行唤醒操作。如果唤醒 状态节点的后续节点,就会将 状态更新为 。即通过清除 状态,表示已经执行了唤醒操作。 状态 :如果一个节点在队列中等待获取锁锁时,因为某种原因失败了,该节点的状态就会变为 ,表明取消获取锁,这种状态的节点是异常的,无法被唤醒,也无法唤醒后继节点。 AQS 核心思想 AQS 核心思想是,如果被请求的共享资源空闲,则将当前请求资源的线程设置为有效的工作线程,并且将共享资源设置为锁定状态。如果被请求的共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制 AQS 是基于 CLH 锁 (Craig, Landin, and Hagersten locks) 进一步优化实现的。 CLH 锁 对自旋锁进行了改进,是基于单链表的自旋锁。在多线程场景下,会将请求获取锁的线程组织成一个单向队列,每个等待的线程会通过自旋访问前一个线程节点的状态,前一个节点释放锁之后,当前节点才可以获取锁。 CLH 锁 的队列结构如下图所示。 AQS 中使用的 等待队列 是 CLH 锁队列的变体(接下来简称为 CLH 变体队列)。 AQS 的 CLH 变体队列是一个双向队列,会暂时获取不到锁的线程将被加入到该队列中,CLH 变体队列和原本的 CLH 锁队列的区别主要有两点: 由 自旋 优化为 自旋 + 阻塞 :自旋操作的性能很高,但大量的自旋操作比较占用 CPU 资源,因此在 CLH 变体队列中会先通过自旋尝试获取锁,如果失败再进行阻塞等待。 由 单向队列 优化为 双向队列 :在 CLH 变体队列中,会对等待的线程进行阻塞操作,当队列前边的线程释放锁之后,需要对后边的线程进行唤醒,因此增加了 指针,成为了双向队列。 AQS 将每条请求共享资源的线程封装成一个 CLH 变体队列的一个结点(Node)来实现锁的分配。在 CLH 变体队列中,一个节点表示一个线程,它保存着线程的引用(thread)、 当前节点在队列中的状态(waitStatus)、前驱节点(prev)、后继节点(next)。 AQS 中的 CLH 变体队列结构如下图所示: 关于 AQS 核心数据结构 CLH 锁的详细解读,强烈推荐阅读 Java AQS 核心数据结构 CLH 锁 Qunar 技术沙龙 这篇文章。 AQS( )的核心原理图: AQS 使用 int 成员变量 表示同步状态 ,通过内置的 FIFO 线程等待/等待队列 来完成获取资源线程的排队工作。 变量由 修饰,用于展示当前临界资源的获取情况。这里 的作用不仅仅是保证可见性,更重要的是通过 happens before 规则(volatile 变量的写操作先行发生于后续的读操作)防止编译器和处理器对指令进行重排序,从而保证锁语义的正确性。 另外,状态信息 可以通过 类型的 、 和 进行操作。并且,这几个方法都是 修饰的,在子类中无法被重写。 以可重入的互斥锁 为例,它的内部维护了一个 变量,用来表示锁的占用状态。 的初始值为 0,表示锁处于未锁定状态。当线程 A 调用 方法时,会尝试通过 方法独占该锁,并让 的值加 1。如果成功了,那么线程 A 就获取到了锁。如果失败了,那么线程 A 就会被加入到一个等待队列(CLH 变体队列)中,直到其他线程释放该锁。假设线程 A 获取锁成功了,释放锁之前,A 线程自己是可以重复获取此锁的( 会累加)。这就是可重入性的体现:一个线程可以多次获取同一个锁而不会被阻塞。但是,这也意味着,一个线程必须释放与获取的次数相同的锁,才能让 的值回到 0,也就是让锁恢复到未锁定状态。只有这样,其他等待的线程才能有机会获取该锁。 线程 A 尝试获取锁的过程如下图所示(图源从 ReentrantLock 的实现看 AQS 的原理及应用 美团技术团队): 再以倒计时器 以例,任务分为 N 个子线程去执行, 也初始化为 N(注意 N 要与线程个数一致)。这 N 个子线程开始执行任务,每执行完一个子线程,就调用一次 方法。该方法会尝试使用 CAS(Compare and Swap) 操作,让 的值减少 1。当所有的子线程都执行完毕后(即 的值变为 0), 会调用 方法,唤醒主线程。这时,主线程就可以从 方法( 中的 方法而非 AQS 中的)返回,继续执行后续的操作。 Node 节点 waitStatus 状态含义 AQS 中的 状态类似于 状态机 ,通过不同状态来表明 Node 节点的不同含义,并且根据不同操作,来控制状态之间的流转。 | Node 节点状态 | 值 | 含义 | | | | | | | 1 | 表示线程已经取消获取锁。线程在等待获取资源时被中断、等待资源超时会更新为该状态。 | | | 1 | 表示后继节点需要当前节点唤醒。在当前线程节点释放锁之后,需要对后继节点进行唤醒。 | | | 2 | 表示节点在等待 Condition。当其他线程调用了 Condition 的 方法后,节点会从等待队列转移到同步队列中等待获取资源。 | | | 3 | 用于共享模式。在共享模式下,可能会出现线程在队列中无法被唤醒的情况,因此引入了 状态来解决这个问题。 | | | 0 | 加入队列的新节点的初始状态。 | 在 AQS 的源码中,经常使用 、 来对 进行判断。 如果 ,表明节点的状态已经取消等待获取资源。 如果 ,表明节点的状态处于正常的状态,即没有取消等待。 其中 状态是最重要的,节点状态流转以及对应操作如下: | 状态流转 | 对应操作 | | | | | | 新节点入队时,初始状态为 。 | | | 新节点入队时,它的前继节点状态会由 更新为 。 状态表明该节点的后续节点需要被唤醒。 | | | 在唤醒后继节点时,需要清除当前节点的状态。通常发生在 节点,比如 节点的状态由 更新为 ,表示已经对 节点的后继节点唤醒了。 | | | AQS 内部引入了 状态,为了解决并发场景下,可能造成的线程节点无法唤醒的情况。(在 AQS 共享模式获取资源的源码分析会讲到) | 自定义同步器 基于 AQS 可以实现自定义的同步器, AQS 提供了 5 个模板方法(模板方法模式)。如果需要自定义同步器一般的方式是这样(模板方法模式很经典的一个应用): 1. 自定义的同步器继承 。 2. 重写 AQS 暴露的模板方法。 AQS 使用了模板方法模式,自定义同步器时需要重写下面几个 AQS 提供的钩子方法: 什么是钩子方法呢? 钩子方法是一种被声明在抽象类中的方法,一般使用 关键字修饰,它可以是空方法(由子类实现),也可以是默认实现的方法。模板设计模式通过钩子方法控制固定步骤的实现。 篇幅问题,这里就不详细介绍模板方法模式了,不太了解的小伙伴可以看看这篇文章:用 Java8 改造后的模板方法模式真的是 yyds!。 除了上面提到的钩子方法之外,AQS 类中的其他方法都是 ,所以无法被其他类重写。 AQS 资源共享方式 AQS 定义两种资源共享方式: (独占,只有一个线程能执行,如 )和 (共享,多个线程可同时执行,如 / )。 一般来说,自定义同步器的共享方式要么是独占,要么是共享,他们也只需实现 、 中的一种即可。但 AQS 也支持自定义同步器同时实现独占和共享两种方式,如 。 独占模式与共享模式的深入对比 上面简要介绍了 AQS 的两种资源共享方式,下面从多个维度对独占模式和共享模式进行系统对比,帮助更深入地理解二者的差异。 特性对比 | 对比维度 | 独占模式(Exclusive) | 共享模式(Share) | | | | | | 并发度 | 同一时刻只有一个线程能获取到资源 | 同一时刻可以有多个线程同时获取到资源 | | 获取资源入口 | | | | 释放资源入口 | | | | 需要重写的模板方法 | / | / | | tryXxx 返回值 | , 表示获取/释放成功 | (获取时),负数表示失败,0 表示成功但无剩余资源,正数表示成功且有剩余资源; (释放时) | | 唤醒后继节点 | 释放资源时唤醒一个后继节点 | 获取资源成功后,如果还有剩余资源,会继续唤醒后续节点(传播唤醒) | | Node 类型标识 | ( ) | (一个静态的 实例) | | 典型实现 | 、 的写锁 | 、 、 的读锁 | 在不同同步器中的语义 AQS 中的 是一个通用的同步状态变量,不同的同步器赋予它不同的含义: | 同步器 | 模式 | 的语义 | | | | | | | 独占 | 表示锁的重入次数。 表示锁空闲; 表示锁被持有,值为重入次数 | | | 独占 + 共享 | 高 16 位表示读锁的持有数量(共享),低 16 位表示写锁的重入次数(独占) | | | 共享 | 表示可用许可证的数量。每次 减少, 增加 | | | 共享 | 表示需要等待的计数。每次 减 1,到 0 时唤醒所有等待线程 | 下面通过一个代码示例来直观感受独占模式和共享模式在使用上的区别: 运行上面的代码可以观察到:独占模式下 5 个线程严格按顺序一个一个执行,而共享模式下最多有 3 个线程同时执行。 AQS 资源获取源码分析(独占模式) AQS 中以独占模式获取资源的入口方法是 ,如下: 在 中,线程会先尝试获取共享资源;如果获取失败,会将线程封装为 Node 节点加入到 AQS 的等待队列中;加入队列之后,会让等待队列中的线程尝试获取资源,并且会对线程进行阻塞操作。分别对应以下三个方法: :尝试获取锁(模板方法), 不提供具体实现,由子类实现。 :如果获取锁失败,会将当前线程封装为 Node 节点加入到 AQS 的 CLH 变体队列中等待获取锁。 :对线程进行阻塞,并调用 方法让队列中的线程尝试获取锁。 分析 AQS 中对应的 模板方法如下: 方法是 AQS 提供的模板方法,不提供默认实现。 因此,这里分析 方法时,以 的非公平锁(独占锁)为例进行分析, 内部实现的 会调用到下边的 : 在 方法内部,主要通过两个核心操作去完成资源的获取: 通过 更新 变量。 表示资源没有被占用。 表示资源被占用,此时 表示重入次数。 通过 设置持有资源的线程。 如果线程更新 变量成功,就表明获取到了资源, 因此将持有资源的线程设置为当前线程即可。 分析 在通过 方法尝试获取资源失败之后,会调用 方法将当前线程封装为 Node 节点加入 内部的队列中。 代码如下: 节点入队的并发安全: 在 方法中,需要执行 Node 节点 入队 的操作。由于是在多线程环境下,因此需要通过 操作保证并发安全。 通过 操作去更新 指针指向新入队的 Node 节点, 可以保证只有一个线程会成功修改 指针,以此来保证 Node 节点入队时的并发安全。 AQS 内部队列的初始化: 在执行 时,如果发现 ,即 指针为 null,则证明队列没有初始化,需要调用 方法初始化队列,并将 节点加入到初始化后的队列中,代码如下: 在 方法中初始化队列,在初始化过程中,也需要通过 来保证并发安全。 初始化队列总共包含两个步骤:初始化 节点、 指向 节点。 初始化后的队列如下图所示: 分析 为了方便阅读,这里再贴一下 中 获取资源的代码: 在 方法中,通过 方法将 节点加入队列之后,就会调用 方法。代码如下: 在 方法中,主要做两件事情: 尝试获取资源: 当前线程加入队列之后,如果发现前继节点是 节点,说明当前线程是队列中第一个等待的节点,于是调用 尝试获取资源。 阻塞当前线程 :如果尝试获取资源失败,就需要阻塞当前线程,等待被唤醒之后获取资源。 1、尝试获取资源 在 方法中,尝试获取资源总共有 2 个步骤: :表明当前节点的前继节点为 节点。此时当前节点为 AQS 队列中的第一个等待节点。 :表明当前线程尝试获取资源成功。 在成功获取资源之后,就需要将当前线程的节点 从等待队列中移除 。移除操作为:将当前等待的线程节点设置为 节点( 节点是虚拟节点,并不参与排队获取资源)。 2、阻塞当前线程 在 中,当前节点的唤醒需要依赖于上一个节点。如果上一个节点取消获取锁,它的状态就会变为 , 状态的节点没有获取到锁,也就无法执行解锁操作对当前节点进行唤醒。因此在阻塞当前线程之前,需要跳过 状态的节点。 通过 方法来判断当前线程节点是否可以阻塞,如下: 方法中的判断逻辑: 如果发现前继节点的状态是 ,则可以阻塞当前线程。 如果发现前继节点的状态是 ,则需要跳过 状态的节点。 如果发现前继节点的状态不是 和 ,表明前继节点的状态处于正常等待资源的状态,因此将前继节点的状态设置为 ,表明该前继节点需要对后续节点进行唤醒。 当判断当前线程可以阻塞之后,通过调用 方法来阻塞当前线程。内部使用了 来实现阻塞。 底层是基于 类来阻塞线程,代码如下: 为什么在线程被唤醒之后,要返回线程的中断状态呢? 在 方法中,当执行完 ,线程会被阻塞,代码如下: 当线程被唤醒之后,需要执行 来返回线程的中断状态,这是为什么呢? 这个和线程的中断协作机制有关系,线程被唤醒之后,并不确定是被中断唤醒,还是被 唤醒,因此需要通过线程的中断状态来判断。 在 方法中,为什么需要调用 ? 方法代码如下: 在 方法中,当 语句的条件返回 后,就会调用 ,该方法会中断当前线程,为什么需要中断当前线程呢? 当 判断为 时,需要 返回 ,并且 返回 。 其中 方法返回的是线程被唤醒之后的 中断状态 ,通过执行 来返回。该方法在返回中断状态的同时,会清除线程的中断状态。 因此如果 判断为 ,表明线程的中断状态为 ,但是调用 之后,线程的中断状态被清除为 ,因此需要重新执行 来重新设置线程的中断状态。 AQS 资源释放源码分析(独占模式) AQS 中以独占模式释放资源的入口方法是 ,代码如下: 在 方法中,主要做两件事:尝试释放锁和唤醒后继节点。对应方法如下: 1、尝试释放锁 通过 方法尝试释放锁,该方法为模板方法,由自定义同步器实现,因此这里仍然以 为例来讲解。 中实现的 方法如下: 在 方法中,会先计算释放锁之后的 值,判断 值是否为 0。 如果 ,表明该线程没有重入次数了,更新 ,并修改持有资源的线程为 null,表明该线程完全释放这把锁。 如果 ,表明该线程还存在重入次数,因此不更新 值, 值为 表明该线程没有完全释放这把锁。 之后更新 值,并返回 值, 值表明线程是否完全释放锁。 2、唤醒后继节点 如果 返回 ,表明线程已经没有重入次数了,锁已经被完全释放,因此需要唤醒后继节点。 在唤醒后继节点之前,需要判断是否可以唤醒后继节点,判断条件为: 。这里解释一下为什么要这样判断: :表明 节点还没有被初始化,也就是 AQS 中的队列没有被初始化,因此无法唤醒队列中的线程节点。 :表明头节点刚刚初始化完毕(节点的初始化状态为 0),后继节点线程还没有成功入队,因此不需要对后续节点进行唤醒。(当后继节点入队之后,会将前继节点的状态修改为 ,表明需要对后继节点进行唤醒) :其中 有可能大于 0,也有可能小于 0。其中 表明节点已经取消等待获取资源, 表明节点处于正常等待状态。 接下来进入 方法查看如何唤醒后继节点: 在 中,如果头节点的状态 (在正常情况下,只要有后继节点,头节点的状态应该为 ,即 1),表示需要对后继节点进行唤醒,因此这里提前清除头节点的状态标识,将状态修改为 0,表示已经执行了对后续节点唤醒的操作。 如果 或者 ,表明后继节点异常,此时不能唤醒异常节点,而是要找到正常状态的节点进行唤醒。 因此需要从 指针向前遍历,来找到第一个状态正常( )的节点进行唤醒。 为什么要从 指针向前遍历,而不是从 指针向后遍历,寻找正常状态的节点呢? 遍历的方向和 节点的入队操作 有关。入队方法如下: 在 方法中, 节点入队需要修改 和 两个指针,但是这两个操作并不是 原子操作 ,先修改了 指针,之后才修改 指针。 在极端情况下,可能会出现 节点的下一个节点状态为 ,此时新入队的节点仅更新了 指针,还未更新 指针,如下图: 这样如果从 指针向后遍历,无法找到新入队的节点,因此需要从 指针向前遍历找到新入队的节点。 图解 AQS 工作原理(独占模式) 至此,AQS 中以独占模式获取资源、释放资源的源码就讲完了。为了对 AQS 的工作原理、节点状态变化有一个更加清晰的认识,接下来会通过画图的方式来了解整个 AQS 的工作原理。 由于 AQS 是底层同步工具,获取和释放资源的方法并没有提供具体实现,因此这里基于 来画图进行讲解。 假设总共有 3 个线程尝试获取锁,线程分别为 、 和 。 此时,假设线程 先获取到锁,线程 排队等待获取锁。在线程 进入队列之前,需要对 AQS 内部队列进行初始化。 节点在初始化后状态为 。AQS 内部初始化后的队列如下图: 此时,线程 尝试获取锁。由于线程 持有锁,因此线程 会进入队列中等待获取锁。同时会将前继节点( 节点)的状态由 更新为 ,表示需要对 节点的后继节点进行唤醒。此时,AQS 内部队列如下图所示: 此时,线程 尝试获取锁。由于线程 持有锁,因此线程 会进入队列中等待获取锁。同时会将前继节点(线程 节点)的状态由 更新为 ,表示线程 节点需要对后继节点进行唤醒。此时,AQS 内部队列如下图所示: 此时,假设线程 释放锁,会唤醒后继节点 。线程 被唤醒后获取到锁,并且会从等待队列中退出。 这里线程 节点退出等待队列并不是直接从队列移除,而是令线程 节点成为新的 节点,以此来退出资源获取的等待。此时 AQS 内部队列如下所示: 此时,假设线程 释放锁,会唤醒后继节点 。线程 获取到锁之后,同样也退出等待队列,即将线程 节点变为 节点来退出资源获取的等待。此时 AQS 内部队列如下所示: AQS 资源获取源码分析(共享模式) AQS 中以共享模式获取资源的入口方法是 ,如下: 在 方法中,会先尝试获取共享锁,如果获取失败,则将当前线程加入到队列中阻塞,等待唤醒后尝试获取共享锁,分别对应一下两个方法: 和 。 其中 方法是 AQS 提供的模板方法,由同步器来实现具体逻辑。因此这里以 为例,来分析共享模式下,如何获取资源。 分析 中实现了公平锁和非公平锁,接下来以非公平锁为例来分析 源码。 中重写的 方法会调用下边的 方法: 在共享模式下,AQS 中的 值表示共享资源的数量。 在 方法中,会在死循环中不断尝试获取资源,如果 「剩余资源数不足」 或者 「当前线程成功获取资源」,就退出死循环。方法返回 剩余的资源数量 ,根据返回值的不同,分为 3 种情况: 剩余资源数量 0 :表示成功获取资源,并且后续的线程也可以成功获取资源。 剩余资源数量 = 0 :表示成功获取资源,但是后续的线程无法成功获取资源。 剩余资源数量 < 0 :表示获取资源失败。 分析 为了方便阅读,这里再贴一下获取资源的入口方法 : 在 方法中,会先通过 尝试获取资源。 如果发现方法的返回值 ,即剩余的资源数小于 0,则表明当前线程获取资源失败。因此会进入 方法,将当前线程加入到 AQS 队列进行等待。如下: 由于当前线程已经尝试获取资源失败了,因此在 方法中,需要将当前线程封装为 Node 节点,加入到队列中进行等待。 以 共享模式 获取资源和 独占模式 获取资源最大的不同之处在于:共享模式下,资源的数量可能会大于 1,即可以多个线程同时持有资源。 因此在共享模式下,当线程线程被唤醒之后,获取到了资源,如果发现还存在剩余资源,就会尝试唤醒后边的线程去尝试获取资源。对应的 方法如下: 在 方法中,唤醒后续节点需要满足一定的条件,主要需要满足 2 个条件: : 代表获取资源之后剩余的资源数量,如果 ,则可以唤醒后续线程去获取资源。 :这里的 节点是执行 之前的 节点。判断 时使用 ,主要为了确定 节点的状态为 或 。如果 节点为 ,则可以唤醒后续节点;如果 节点状态为 ,也可以唤醒后续节点(这是为了解决并发场景下出现的问题,后续会细讲)。 代码中关于 唤醒后续等待节点 的 判断稍微复杂一些,这里来讲一下为什么这样写: : 用于防止空指针异常。正常情况下 h 不会为 ,因为执行到这里之前,当前节点已经加入到队列中了,队列不可能还没有初始化。 主要判断 节点的状态是否为 或者 ,直接使用 来判断比较方便。 :如果到这里说明之前判断的 ,说明存在并发。 同时存在其他线程在唤醒后续节点,已经将 节点的值由 修改为 了。因此,这里重新获取新的 节点,这次获取的 节点为通过 设置的当前线程节点,之后再次判断 状态。 如果 条件判断通过,就会走到 方法唤醒后续等待节点,如下: 在 方法中,会判断 节点的 状态来决定接下来的操作,有两种情况: 节点的状态为 :表明 节点存在后继节点需要唤醒,因此通过 操作将 节点的 状态更新为 。通过清除 状态来表示已经对 节点的后继节点进行唤醒操作了。 节点的状态为 :表明存在并发情况,需要将 修改为 来保证在并发场景下可以正常唤醒线程。 为什么需要 状态? 在 释放资源时,第 3 步不太容易理解,即如果发现 节点的状态是 ,就将 节点的状态由 更新为 。 AQS 中,Node 节点的 就是为了处理并发场景下可能出现的无法唤醒线程节点的问题。 只在 方法中用到一次。 接下来通过案例分析,为什么需要 状态? 在共享模式下,线程获取和释放资源的方法调用链如下: 线程获取资源的方法调用链为: 。 线程释放资源的方法调用链为: 。 如果在释放资源时,没有将 节点的状态由 改为 : 假设总共有 4 个线程尝试以共享模式获取资源,总共有 2 个资源。初始 和 线程获取到了资源, 和 线程没有获取到,因此在队列中排队等候。 在时刻 1 时,线程 和 在等待队列中, 和 持有资源。此时等待队列内节点以及对应状态为(括号内为节点的 状态): 。 在时刻 2 时,线程 释放资源,通过 方法将 节点的状态由 更新为 ,并唤醒线程 ,之后线程 退出。 线程 被唤醒之后,通过 获取到资源,但是此时还未来得及执行 将自己设置为 节点。此时等待队列内节点状态为: 。 在时刻 3 时,线程 释放资源,由于此时 节点的状态为 ,如果 中 时什么都不做(即没有 状态),那么 无法唤醒 的后继节点,之后线程 退出。 在时刻 4 时,线程 继续执行 方法。先保存旧 ( ),再执行 将自己设置为 节点。 此时 ,旧 ,前两个条件不满足。但由于 不会重置 ,新 (即原来的 节点)的 仍然是 (SIGNAL),所以第二次判断 仍然成立,会调用 唤醒 。 在这个时序下,二次 head 判断确实能兜住。那 解决的是什么问题呢? 在时刻 5(更极端的时序),考虑这样的交错:线程 执行完 之后、判断新 之前 ,线程 的 恰好执行了 唤醒了 , 快速获取资源并执行 将自己设为新的 。由于 原来是队列尾部节点,其 为 。当 恢复执行读取新 时,读到的是 节点( ),此时二次判断也无法通过了。 在这种极端并发时序下, 、旧 、新 ,三个条件全部不满足, 不会调用 。如果此时队列中还有后续等待节点,就会导致唤醒信号丢失。 对应时刻表如下: | 时刻 | 线程 T1 | 线程 T2 | 线程 T3 | 线程 T4 | 等待队列 | | | | | | | | | 时刻 1 | 等待队列 | 等待队列 | 持有资源 | 持有资源 | | | 时刻 2 | (执行)被唤醒后,获取资源,但未来得及将自己设置为 节点 | 等待队列 | (执行)释放资源 | 持有资源 | | | 时刻 3 | | 等待队列 | 已退出 | (执行)释放资源。但 节点状态为 ,无法唤醒后继节点 | | | 时刻 4 | (执行) 完成,尚未判断新 | 等待队列 | 已退出 | 已退出 | | | 时刻 5 | | (执行)被 T4 唤醒,获取资源,执行 成为新的 | 已退出 | (执行) 唤醒 T2 | | | 时刻 6 | (执行)读取新 为 T2 节点, ,二次判断失败,不唤醒后续节点 | 已获取资源 | 已退出 | 已退出 | | 如果在线程释放资源时,将 节点的状态由 改为 ,则可以解决上边出现的并发问题,如下: 的关键在于:它修改的是 旧 的状态,而旧 的引用在 方法开头就已经保存到了局部变量 中,不会被后续的并发修改影响。 在时刻 1 2 时,与上述场景相同: 时刻 1: 。 时刻 2: 释放资源, 状态变为 并唤醒 。 在时刻 3 时,线程 释放资源,由于此时 节点的状态为 , 会将 节点的状态由 更新为 ,之后线程 退出。此时等待队列内节点状态为: 。 在时刻 4 时,线程 继续执行 方法。先保存旧 到局部变量 ,此时 。再执行 将自己设置为 节点。 在时刻 5 时,即使发生了与之前相同的极端交错( 唤醒了 , 成为新 ), 在判断时: → → false → false → → true ! 由于旧 的引用 在方法开头就已保存,不受后续 和并发操作的影响,所以 状态确保了 一定能通过。因此线程 会在 方法中调用 唤醒后续节点。 有了 状态,就可以避免极端并发时序下唤醒信号丢失的问题。对应时刻表如下: | 时刻 | 线程 T1 | 线程 T2 | 线程 T3 | 线程 T4 | 等待队列 | | | | | | | | | 时刻 1 | 等待队列 | 等待队列 | 持有资源 | 持有资源 | | | 时刻 2 | (执行)被唤醒后,获取资源,但未来得及将自己设置为 节点 | 等待队列 | (执行)释放资源 | 持有资源 | | | 时刻 3 | 未继续向下执行 | 等待队列 | 已退出 | (执行)释放资源。此时会将 节点状态由 更新为 | | | 时刻 4 | (执行)保存旧 ( ),执行 | 等待队列 | 已退出 | 已退出 | | | 时刻 5 | (执行)判断旧 ( )成立,调用 唤醒后续节点 | 等待队列 | 已退出 | 已退出 | | | 时刻 6 | 已退出 | (执行)线程 被唤醒后,获取到资源,并将自己设置为 节点 | 已退出 | 已退出 | | 简单总结: 状态和 中的二次 head 判断是 JDK 7 中同一个 bug fix(JDK 6801020)的 双重保险 。 通过修改旧 的状态来提供更可靠的保障,因为旧 的引用在方法开头就已保存到局部变量,不会被并发的 操作替换。 AQS 资源释放源码分析(共享模式) AQS 中以共享模式释放资源的入口方法是 ,代码如下: 其中 方法是 AQS 提供的模板方法,这里同样以 来讲解,如下: 在 实现的 方法中,会在死循环内不断尝试释放资源,即通过 操作来更新 值。 如果更新成功,则证明资源释放成功,会进入到 方法。 方法在前文获取资源(共享模式)的部分已进行了详细的源码分析,此处不再重复。 Condition 条件队列的工作机制 前面在 状态表格中提到过 (值为 2)状态,表示节点在 Condition 条件队列中等待。这里系统讲解 Condition 条件队列的工作机制。 什么是 Condition? 是 包中定义的接口,它提供了类似于 / 的线程等待/通知机制,但功能更加强大和灵活。 必须与 配合使用,就像 必须与 配合使用一样。 与 的 相比, 的主要优势在于: 支持多个等待队列 :一个 可以创建多个 实例,不同的线程可以在不同的条件上等待,实现更精细的线程协作。而 只有一个等待队列。 支持不响应中断的等待 : 提供了 方法。 支持超时等待 : 提供了 和 方法,可以设定等待的截止时间。 AQS 中的两种队列 在 AQS 内部实际上维护了 两种队列 : 1. 同步队列(CLH 变体队列) :就是前面详细分析过的双向队列,用于存放获取资源失败而等待的线程节点。 2. 条件队列(Condition Queue) :是一个单向链表,用于存放调用了 方法而等待的线程节点。每个 实例维护一个独立的条件队列。 条件队列中的节点使用 的 指针来链接下一个节点,形成单向链表。条件队列的头节点为 ,尾节点为 。 Condition 的核心工作流程 AQS 的内部类 实现了 接口,其核心方法为 和 。 方法的工作流程: 1. 将当前线程封装为 节点( 设置为 ),加入到条件队列的尾部。 2. 完全释放当前线程持有的锁(即将 值置为 0),并保存释放前的 值。 3. 阻塞当前线程,等待被 唤醒或被中断。 4. 被唤醒后,重新通过 进入同步队列竞争锁,并恢复之前保存的 值(重入次数)。 方法的工作流程: 1. 检查调用 的线程是否持有锁(不持有则抛出 )。 2. 将条件队列中第一个等待的节点从条件队列移除。 3. 将该节点的 从 修改为 ,并通过 方法将其加入到同步队列的尾部。 4. 如果同步队列中前驱节点的状态异常( )或者 CAS 设置前驱节点状态为 失败,则直接唤醒该线程。 方法与 类似,区别在于它会将条件队列中的 所有 节点都转移到同步队列中。 下面的代码示例展示了 的典型用法——实现一个简单的有界阻塞队列: 在上面的例子中, 和 是两个独立的 实例,分别维护各自的条件队列。生产者在队列满时在 上等待,消费者在队列空时在 上等待。这种分离等待条件的设计,避免了不必要的线程唤醒,比 + 更加高效。 核心源码分析 方法中有两个关键操作: :完全释放锁(而不是只释放一次),这样即使线程重入了多次锁,也能在等待期间让其他线程获取到锁。被唤醒后会通过 恢复之前的重入次数。 :判断节点是否已经被转移到同步队列。当其他线程调用 时,节点会从条件队列转移到同步队列,此时 返回 ,线程退出 循环,开始竞争锁。 公平锁与非公平锁的性能差异分析 前面的源码分析中,以 的非公平锁为例讲解了 的实现。实际上 同时支持公平锁和非公平锁两种模式。这里深入分析二者的实现差异及其对性能的影响。 源码层面的差异 默认使用非公平锁,通过构造参数可以切换为公平锁: 二者的核心差异在于 方法的实现。非公平锁的 前面已经分析过,下面看公平锁的实现: 唯一的区别 就是公平锁在 CAS 修改 之前多了一个 判断: 这个方法用于判断当前线程之前是否有其他线程在排队。如果有,则当前线程不能直接获取锁,必须排队等待,从而保证了 FIFO 的公平性。 而非公平锁没有这个判断,当锁刚好释放时,新来的线程可以直接通过 CAS 抢到锁,即使同步队列中已经有其他线程在等待。 性能差异对比 | 对比维度 | 非公平锁(默认) | 公平锁 | | | | | | 吞吐量 | 更高。新线程有机会直接获取锁,减少了线程上下文切换 | 较低。所有线程都必须排队,增加了上下文切换的开销 | | 线程饥饿 | 可能发生。极端情况下某些线程长时间无法获取锁 | 不会发生。严格按照请求顺序分配锁 | | 上下文切换 | 较少。持有锁的线程释放锁后,新到达的线程可能直接获取锁,不需要唤醒队列中的线程 | 较多。每次释放锁都需要唤醒队列中的下一个线程 | | 适用场景 | 大多数场景(对响应时间和吞吐量要求较高) | 对公平性有严格要求的场景(如资源分配、任务调度) | 为什么非公平锁性能通常更好? 关键原因在于 减少了线程上下文切换的次数 。当持有锁的线程 A 释放锁后: 非公平锁 :此时如果恰好有线程 B 正在尝试获取锁(还没有进入同步队列),线程 B 可以直接通过 CAS 获取到锁并立即执行,省去了唤醒队列中线程的开销。而队列中等待的线程被唤醒后发现锁被占用,会重新阻塞,虽然看起来“浪费”了一次唤醒,但总体上减少了线程切换次数。 公平锁 :线程 B 必须排到队列尾部,然后唤醒队列头部的线程。从线程被唤醒到真正开始执行之间,存在一段 调度延迟 (线程状态从阻塞切换到运行),在这段延迟期间锁处于空闲状态,降低了锁的利用率。 Doug Lea 在 的文档中指出:使用公平锁的程序在多线程环境下的总体吞吐量通常低于使用非公平锁的程序(即更慢),因此 默认使用非公平模式。但在需要保证请求处理顺序或避免线程饥饿的场景中(如连接池分配),公平锁是更好的选择。 下面通过代码示例来演示公平锁与非公平锁在行为上的差异: 运行上面的代码可以观察到:非公平锁模式下,同一个线程可能连续多次获取到锁(因为它释放锁后立即又