23 窥探锁的核心——队列同步器AQS原理
Diego38 83 1

1. 前言

上节讲到 Lock 锁与 Synchronize 的锁不同,Lock 锁是通过代码实现而非底层 JVM,主要借助队列同步器 AQS 完成同步操作的。 AQS 的全称是 AbstractQueuedSynchronizer,是一个抽象类,内部维护一个同步队列,是整个并发包的基础类,也是面试的重点。 本文会从 AQS 的介绍到 AQS 实现做详细阐述,学好本节内容,后续并发工具章节学习将更加轻松。

2. 队列同步器 AQS 介绍

AQS 的全称是 AbstractQueuedSynchronizer,是构建锁或并发工具类的基础框架,内置一个先进先出队列,来实现线程获取资源同步策略。

这里可以给 AQS 总结一句广告语 ----“做线程同步,请用 AQS”, 有人问提出质疑,做线程同步不是使用锁么?

首先,ReentrantLock 独占锁也是依赖 AQS 实现的,而且做线程同步时获取锁和释放锁的时机和条件是多种多样的,比如支持共享的锁,支持重入的锁,再比如 CountDownLatch 中线程获取资源的条件是 countDown 操作被执行了 N 次。

本身是一个抽象类,内部封装了同步器的大量细节,比如线程的入队出队,线程的等待和唤醒,同时暴露了 4 个核心 API,运行实现类只需要继承 AQS,并定义获取资源和释放资源的触发条件,分别是:

  • tryAcquire(int)
  • tryAcquireShared(int)
  • tryRelease(int)
  • tryReleaseShared(int)

    2.1 AQS 在整个 JUC 包的地位

    JUC 包中有很多并发工具类 (如 ReentrantLock、ReentrantReadWriteLock、CountDownLatch、Semaphore、ThreadPoolExecutor), 如果将这些类比如不同类型的汽车,那 AQS 好比驱动这些汽车的发动机。如果并发工具类比作手机,那么 AQS 就好比手机里的芯片。

用一张图来表示如下: image

AQS 实现类通常被定义为同步组件的内部静态类,以 ReentrantLock 为例,代码里有两个 AQS 实现类 FairSync、NonfairSync,分别代表公平锁同步器和非公平锁同步器。

在开源项目中 AQS 的应用也十分广泛,比如 JDBC 连接池 HikariCP 内部借助 AQS 实现高性能连接池,Binlog 增量订阅组件借助 AQS 实现 boolean 互斥锁。

2.2 AQS 中 State 的作用

AQS 中有一个 volatile 修饰的 state int 变量, 用于保存同步状态

    /**
     * The synchronization state.
     */
    private volatile int state;

而 AbstractQueuedLongSynchronizer 保存的是一个 long 类型的 state。 AQS 将获取资源和释放资源的满足条件交由实现类管理,同时附赠一本 “记录本”, 用于记录同步状态 ,这个 “记录本” 就是 state 整形变量, 每次获取资源和释放资源可以修改这个 “记录本”,比如 ReentantLock 在每次重入时将 state 记录加 1,表示重入的次数,相应的释放锁时对 state 做减 1 操作。而 CountDownLatch 在每次 countDown 操作 state 减 1,直到为 0,处于等待的线程才获取资源;Semaphore 使用 state 来表示当前还剩下多少个许可。

AQS 提供 3 个方法来访问同步状态 state

  • getState () : 获取当前同步状态的值
  • setState (int newState) : 设置当前同步状态的值
  • compareAndSetState (int expect,int update):通过 CAS 修改同步状态

    2.3 AQS 抽象类 API

    AQS 是抽象类,所有 AQS 的实现类只需要实现 4 个抽象 API,就可实现丰富的同步策略。 按模式,将 API 分为两类,一种是独占模式,一个是共享模式。

独占模式是指同一时间只有一个线程获取资源。 共享模式是指同一时间可以多个线程获取资源

独占模式就两个方法 tryAcquire 和 tryRelease,共享模式也有两个方法即 tryRelease 和 tryReleaseShared。

假设我们要编写一个 AQS 同步实现类,仅需要关心这四个方法应该做什么以及与 AQS 的交互流程。下图展示了一个简化后的交互流程: image

这其中记住两点,这对后续的原理掌握非常重要。

  • AQS 负责将获取资源失败的线程入队 (AQS 内置同步队列) 休眠,释放资源成功后唤醒队列中的线程出队,被唤醒后的线程重新获取资源操作

  • 4 大 API 只需要定义 “获取资源成功的条件” 以及 “释放资源成功的条件” 接下来我们去分析下 4 个 API 要求实现类做什么,如果要实现 AQS,这四个 API 要熟练掌握。

  • boolean tryAcquire(int arg) 独占模式场景。表示什么条件下可以获取许可,成功返回 true,失败返回 flase。 arg 是任意整形参数 (比如独占锁可以将 arg 表示成重入次数) 用于与 state 做计算。未获取许可的线程将进入同步队列进行休眠。 被唤醒后,还会再次做 tryAcquire,对应图上的斜箭头。

  • boolean tryRelease(int arg) 独占模式场景,表示什么条件下可以唤醒队列中的后续线程操作,满足条件返回 true,否则 false,比如重入记录 State 降为 0 则进行真正的释放。

  • int tryAcquireShared(int releases) 共享模式场景,同一时间可以多个线程获取许可。表示什么条件下可以获取共享许可,如果返回值 >=0, 表示获取许可,如果返回 < 0,表示未获取许可。arg 可以是获取许可的个数。 在同步队列的元素尝试获取锁时返回值大于 0,将帮助进行后续的传播,返回值是可以传播的个数。若未获取许可,将进入队列进行休眠。

  • boolean tryReleaseShared(int releases) 共享模式场景。表示什么条件下 (比如共享资源数 state 大于 0 时) 可以进行后续的唤醒操作,满足条件返回 true,否则返回 false。

接下来以 ReentrantLock 为例,展示一个独占模式下 API 之间调用关系图:

image 以 CountDownLatch 为例,展示一个共享模式下 API 之间调用关系图:

image 通过 API 说明以及图示,我们可以猜到共享模式相比独占模式,多了一步操作 — 线程在获取资源许可成功后,会去唤醒队列中的其他线程,即传播唤醒。

让我们看下 AQS 内部是如何实现线程同步管理的。

3. 队列同步器 AQS 的实现

以三种模式划分来分析 AQS 的实现,分别是独占模式、共享模式、等待通知机制,前两者我们之前学习了它们的定义。相比 Synchronized,Lock 锁的等待通知机制,是基于 AQS 内部 ConditionObject 来实现的,ConditionObject 内部管理着一个条件队列。

我们接下来分别以流程图和文字说明,来阐述三种模式的内部原理。

3.1 AQS 独占模式

独占模式的使用场景,比如

  • ReentrantLock 内部类 Sync
  • ThreadPoolExecutor 内部类 Worker
  • ReentrantReadWriteLock 内部类 Sync 以下代表两个线程在同时执行 tryAcquire 时触发的内部流程: image 队列中结点包括四个属性分别是结点状态、线程、前置结点、后置结点。

假设线程 thread1 和线程 thread2 同时获取许可执行 tryAcquire 操作,thread1 作为获取许可成功的一方走的是绿色序号标记的流程,thread2 作为获取许可失败的一方走的是蓝色序号标记的流程。

休眠是借助 LockSupport 类的 park 方法将自己休眠,通过 unpark (Thread) 唤醒其他休眠线程

  • 线程 1 是获取独占许可成功的线程,线程 2 是获独占许可失败的线程
  • state 只是个状态,在 tryAcquire 和 tryRelease 可以选择进行操作
  • 线程 1 的执行顺序是 [获取许可,判断 state]–> 执行具体业务–>[释放许可, 修改 State,唤醒头结点]
  • 线程 2 的执行顺序是 获取许可失败–> 进入队尾–> 重新获取许可 (如果第二结点)–> 入队休眠–> 被唤醒–> 获取许可–> 成为 head 头结点
  • 同步队列同时也是双向链表,每个结点保持上下结点的引用,独占模式下结点有 Signal 和 Cancelled 两种状态。
  • Signal 意味着后继结点可以被唤醒
  • 队列中的线程所处的线程状态是 waiting 或 time_waiting。
  • 超时获取许可操作时做限时 parking,超时发生则设置为 canelled 状态;可中断获取许可操作时,尤其是在 park 时中断,中断将置为 cancelled 状态;中断和超时都会置为 cancelled 状态。 cancelled 状态的节点会在置为 cancelled 进行删除,同时在 park 时也会直接跳过,不会出现因 cancel 过多队列膨胀的情况。
  • 同步队列中的 head 节点是一个虚节点,不参与排队及休眠,冷启动时它只是一个虚节点无任何信息,之后保存的是在队列中被唤醒立即获锁成功的线程。

通过上述的流程解释,可能会产生这样一个疑问,线程 2 进入队列为什么发现自己的第二个结点会尝试获取锁?会不会出现线程进入同步队列永远无法被唤醒的情况发生?

前面介绍过,头结点是一个虚结点,如果发现自己处在第二个结点,实际上排在队列第一位,当发现自己处于队首时尝试获取锁实际上就是为了避免进入同步队列后永远无法被唤醒。

分析这个问题前,我们将线程 1 和线程 2 的流程进一步简化

线程2 : 入队 --> 获许可 --> 休眠 线程1 : 释放许可 --> 唤醒队列首结点

  • 假设线程 1 唤醒在线程 2 入队前面,有重试获许可来保证不会永眠;
  • 假设线程 1 唤醒在线程 2 休眠前面,线程 2 将不会进入休眠而是去获许可。因为 LockSuupport.park 有一个特性 LockSuupport.park 发生之前执行过对该线程的 unpark 将不会进入休眠,这个留给大家去试验。 举个通俗的例子,小区夜晚需要留一个值班保安 (获取资源的线程),其他人 (获取资源失败的线程) 进宿舍 (同步队列) 睡觉,采用轮班制,每隔 1 小时值班室会打电话换一个人。只要宿舍里有人你就可以安心睡觉,因为宿舍的人肯定排在你前面去值班,否则你需要打个电话给值班室,检查是否有人值班,检查到有人值班你就可以安心睡觉了,它换班时肯定打电话 (唤醒休眠的线程) 到宿舍里的。

假设你去宿舍的路上,此时宿舍一个人都没有,这时正赶上值班室换班,值班保安打电话也没人接就去约会了,你进入宿舍后如果不去向值班室打电话确认 (发现自己是第二个结点就尝试获取锁),你将永远进入睡眠。

3.2 AQS 共享模式

共享模式的使用场景,比如:

  • ReentrantReadWriteLock 的 Sync

  • Semaphore 的 Sync 类

  • JDK7 的 FutureTask image 学会了独占模式的原理,共享模式会相对轻松

  • 共享模式 state 可以是有限资源的个数,可以随意定义。

  • 和独占模式的唯一区别是如果队列节点获许可 tryAcquireShared 大于等于 0,并且 tryAcquireShared 返回值大于 0 时,会传播式唤醒后继节点,唤醒的数量等于返回值大小。

  • 在获取许可成功就能唤醒后继结点,让队列中的多个线程处于活跃状态重新尝试获取许可。

    3.3 AQS 等待通知机制

    前一章中有介绍过基于原生锁 Synchronized 的等待通知机制,Lock 的等待通知机制也是 AQS 内部类 ConditionObject 来完成的。

常见的应用场景有:

  • ReentrantLock 中的 Sync

  • ReentrantReadWriteLock 中的 Sync 核心流程与原生锁 Synchronized 的等待通知机制类似,Condition 只适用于独占模式,我们就将之前的小明小红打羽毛球的案例改造成等待通知机制

    public class AwaitAndSignalTest {
      static Lock badminton = new ReentrantLock();// 羽毛球
    
      static Condition condition = badminton.newCondition();
      static Thread xiaoming = new Thread(() ->{
          while (true) {
              badminton.lock();
              try {
                  System.out.println("【小明】击球");
                  condition.signal(); //去通知处有可能处在badminton等待队列的小红线程
                  System.out.println("【小明】释放了羽毛球并等待小红回球");
                  try {
                      condition.await();  // 进入badminton等待队列,并释放badminton的锁
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  System.out.println("【小明】收到了小红回球准备回球");
              }finally {
                  badminton.unlock();
              }
    
          }
      } ,"xiaoming");
    
      static Thread xiaohong = new Thread(() ->{
          while (true) {
              badminton.lock();
              try {
                  System.out.println("【小红】击球");
                  condition.signal();//去通知处有可能处在badminton等待队列的小明线程
                  System.out.println("【小红】释放了羽毛球并等待小明回球");
                  try {
                      condition.await(); // 进入badminton等待队列,并释放badminton的锁
                  } catch (InterruptedException e) {
                      e.printStackTrace();
                  }
                  System.out.println("【小红】收到了小明回球准备回球");
              }finally {
                  badminton.unlock();
              }
          }
      } ,"xiaohong");
    
      public static void main(String[] args) {
          xiaoming.start();
          xiaohong.start();
      }
    }

    原理如下图所示 : image

  • 线程 1 小明的流程

  1. 获取独占许可 tryAcquire
  2. 成功后执行具体 “【小明】击球”
  3. 发送唤醒信号 (此时条件队列为空什么都不做) 并执行 await 操作
  4. await 操作是将当前线程塞入条件队列的队尾
  5. 内部执行释放许可 tryRelease
  6. 内部执行 unpark 处于同步队列的小红线程,触发小红线程第 6 步操作
  7. 唤醒同步队列头结点并休眠当前线程,此时存在于条件队列,需要其他线程唤醒转移到同步队列
  8. 经过小红线程的第 7,8 步之后操作后,小红线程成功释放许可,小明线程重新获取许可 tryAcquire,继续执行 “【小明】收到了小红回球准备回球”
  9. 第 8 步之后,小明线程释放许可,进入第 1 步,依此循环往复。
  • 线程 2 小红的流程
  1. 获取独占许可 tryAcquire
  2. 失败进入开始执行入队操作
  3. 创建包含小红线程的结点,通过 cas 追加到同步队列尾部,并休眠当前线程
  4. 如果发现自己是第二个结点也就是首结点,则重新获取许可
  5. 等待小明线程执行 await 操作完成第 6 步后,小红线程被唤醒,重新执行 tryAcquire 成功,将虚结点 Head 结点指向当前结点。
  6. 执行 “【小红】击球” 动作,回应小明的击球。
  7. 执行 signal 通知处在条件队列的小明线程
  8. 之后的 8-13 步实际上和小明线程第 4 步之后的线程保持一致,依此循环往复。

等待通知机制有几个特点

  • condition 只用于独占模式,当调用 condition.await 方法会做入队条件队列 + release 操作
  • signal * 操作是将位于 condition 的队列头结点转移到同步队列末尾,从图中我们看到标红 thread1 是如何从这两个队列中做转移的。
  • 本例演示了一个 lock 关联一个 Condition 的情况,高级锁 Lock 实际上可以关联多个 Condition。

    4. 总结

    本节我们学习了 JUC 包的核心 AQS,AQS 是并发工具和组件的基础引擎,掌握 AQS 的原理尤为重要,后续章节我们会基于 AQS 做定制开发,构建不同同步场景需求的同步组件。
预览图
评论区

索引目录