基于本文回答
0
评论

AQS的原理是什么

知识点图片

本文讲解Java并发框架AQS:它通过一个状态变量state和一个FIFO队列,为构建锁和同步器提供基础。利用模板方法模式,A-Q-S封装了线程排队和阻塞唤醒的通用逻辑,开发者只需定义状态的获取与释放规则即可。

我们来深入探讨一下 AQS (AbstractQueuedSynchronizer) 的设计原理。AQS 是 Java 并发包(JUC)的基石,理解了它,就等于理解了 JUC 中大部分锁和同步器的核心。

1. AQS 是什么?

一句话概括:AQS 是一个用来构建锁和同步器的框架。

它提供了一个通用的、基于 FIFO 等待队列的机制,并管理一个 state(状态)变量。开发者只需要继承 AQS 并实现其提供的几个 protected 方法来管理这个 state,就可以轻松地创建一个自定义的同步器,而无需关心线程的排队、阻塞、唤醒等底层细节。

像我们熟知的 ReentrantLock, Semaphore, CountDownLatch, ReentrantReadWriteLock 等,它们的内部都是通过一个继承了 AQS 的私有静态内部类来实现核心同步逻辑的。

2. AQS 的核心设计思想

AQS 的设计精髓在于 “模板方法模式”“关注点分离”

  1. 关注点分离:它将同步器最核心的两个部分分离开来:

    • 资源状态的管理:例如,锁是否被占用?信号量还剩多少个许可?这是具体同步器关心的业务逻辑。
    • 线程的管理:例如,获取资源失败的线程如何排队?何时阻塞?何时被唤醒?这是所有同步器都需要的通用逻辑。
  2. 模板方法模式

    • AQS (父类/模板):负责实现线程的管理。它定义好了获取资源(acquire)和释放资源(release)的顶级逻辑框架,这个框架包含了线程入队、阻塞、出队、唤醒等一系列标准操作。
    • 具体同步器 (子类):负责实现资源状态的管理。它只需要重写 AQS 提供的几个 protected 方法(如 tryAcquire, tryRelease),来定义“如何算是成功获取资源”和“如何算是成功释放资源”。

通过这种方式,AQS 封装了复杂的底层操作,开发者只需要关注最核心的“状态”判断即可。

3. AQS 的三大核心组件

AQS 的设计围绕以下三个核心组件展开:

1. State (状态)

这是 AQS 的核心,一个 volatile 修饰的 int 类型的变量。它代表了被同步的资源状态。

  • volatile:保证了 state 在多线程之间的可见性。
  • 原子操作:AQS 提供了 getState(), setState(), compareAndSetState() (CAS) 这三个方法来安全地读写 state。子类必须通过这些方法来修改状态,以保证原子性。

state 的不同含义举例:

  • ReentrantLock 中,state 表示锁的重入次数。0 表示未被占用,1 表示被占用,>1 表示锁被重入了。
  • Semaphore 中,state 表示剩余的许可证数量。
  • CountDownLatch 中,state 表示计数器的值。

2. FIFO 等待队列 (CLH 队列)

当一个线程尝试获取资源失败后,它会被封装成一个 Node 对象,并加入到一个先进先出 (FIFO) 的双向链表中。这个队列就是 AQS 实现线程排队的基础。

  • 数据结构:一个变体的 CLH (Craig, Landin, and Hagersten) 锁队列,它是一个双向链表
  • Node 节点:队列中的每个节点(Node)都包含了一些关键信息:
    • thread: 封装的线程本身。
    • waitStatus: 节点状态(如 SIGNAL, CANCELLED),用于控制线程的阻塞和唤醒。
    • prev, next: 双向链表的前后指针。
  • 无锁入队:通过 CAS 操作(compareAndSetTail)来保证 Node 入队操作的原子性,避免了在队列操作上再加锁。

3. 两种资源共享模式

AQS 定义了两种资源共享模式,来满足不同场景的需求:

  • Exclusive (独占模式):资源在同一时刻只能被一个线程持有。例如 ReentrantLock。相关方法包括 acquire, release 等。
  • Shared (共享模式):资源在同一时刻可以被多个线程持有。例如 Semaphore, CountDownLatch。相关方法包括 acquireShared, releaseShared 等。

子类在实现时,需要根据自己的特性选择合适的模式,并实现对应模式的 try 方法。

4. AQS 工作流程详解(以独占模式为例)

我们来详细看一下一个线程获取和释放锁的完整流程。

(A) 获取资源 (acquire 方法)

当一个线程调用 lock.lock() 时,实际上是在调用 AQS 的 acquire(1) 方法。

  1. tryAcquire(arg)

    • 线程首先调用子类实现的 tryAcquire() 方法,尝试获取锁。这是一个快速路径
    • 如果成功 (例如,CAS 修改 state 从 0 到 1 成功),acquire 方法直接返回,线程继续执行,整个过程非常快,没有线程排队和阻塞。
    • 如果失败,则进入下面的慢速路径。
  2. addWaiter(Node.EXCLUSIVE)

    • 如果 tryAcquire() 失败,AQS 会将当前线程封装成一个独占模式的 Node
    • 然后通过一个 CAS 自旋循环,安全地将这个 Node 添加到等待队列的尾部
  3. acquireQueued(node, arg)

    • 节点入队后,线程并不会立刻阻塞,而是进入一个自旋(死循环)过程。
    • 在循环中,它会检查自己的前一个节点是不是头节点 (head)
      • 如果是,说明自己是队列中排在最前面的等待者,有资格去获取锁。于是它会再次调用 tryAcquire() 尝试获取锁。
        • 如果这次成功了,它会将自己设置为新的头节点(老的头节点出队),并从 acquireQueued 方法返回,线程恢复执行。
        • 如果还是失败(比如锁恰好被另一个线程抢到),则进入阻塞阶段。
      • 如果不是,说明自己前面还有别的线程在等待,不应该去抢锁。
  4. shouldParkAfterFailedAcquireparkAndCheckInterrupt

    • 当线程发现自己不应该或无法获取锁时,它会检查前一个节点的 waitStatus
    • 它会通过 CAS 将前一个节点的 waitStatus 设置为 SIGNAL。这个状态的含义是:“前面的兄弟,当你释放锁的时候,请务必唤醒我(unpark me)”。
    • 设置成功后,当前线程就会调用 LockSupport.park(this) 将自己安全地挂起(阻塞),等待被唤醒。

(B) 释放资源 (release 方法)

当一个线程调用 lock.unlock() 时,实际上是在调用 AQS 的 release(1) 方法。

  1. tryRelease(arg)

    • 线程调用子类实现的 tryRelease() 方法,尝试释放锁(例如,将 state 减 1)。
    • 如果成功(例如,state 变为 0),则返回 true,表示锁已被完全释放。
    • 如果失败(例如,在可重入锁中,state 只是从 2 减到 1,锁仍被当前线程持有),则返回 false,什么也不做。
  2. 唤醒后继者

    • 如果 tryRelease() 返回 true,AQS 会找到队列的头节点(head)。
    • 如果头节点有后继者(head.next != null),AQS 就会调用 LockSupport.unpark(successor.thread)唤醒这个后继节点中封装的线程。
    • 被唤醒的线程将从之前 park 的地方醒来,回到 acquireQueued 的自旋循环中,再次尝试获取锁。

5. 设计原理总结

  1. 核心数据结构volatile int state + FIFO 双向队列。
  2. 核心机制:利用 CAS 实现对 state 和队列的无锁原子操作,利用 LockSupport.park/unpark 实现线程的阻塞和唤醒。
  3. 核心模式:模板方法模式,将通用的队列管理和线程调度逻辑(在 AQS 中)与具体的资源状态管理逻辑(在子类中)解耦。
  4. 两大流程
    • 获取:尝试 tryAcquire -> 失败则入队 -> 在队列中自旋,检查是否轮到自己 -> 如果是,则再次 tryAcquire -> 仍然失败或没轮到自己,则 park 阻塞。
    • 释放:尝试 tryRelease -> 成功则唤醒(unpark)队列中的下一个等待线程。
  5. 性能优势
    • 在无竞争的情况下,获取锁的成本极低,仅为一次 CAS 操作。
    • 在有竞争的情况下,通过队列有序地管理线程,避免了“惊群效应”(唤醒所有线程去争抢一个锁),只唤醒下一个需要获取锁的线程,提高了效率和公平性。

AQS 的设计是 Java 并发编程的典范,它巧妙地结合了多种底层技术,为上层应用提供了一个强大、高效且易于扩展的同步框架。

右滑查看面试常问