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 的设计精髓在于 “模板方法模式” 和 “关注点分离”。
关注点分离:它将同步器最核心的两个部分分离开来:
- 资源状态的管理:例如,锁是否被占用?信号量还剩多少个许可?这是具体同步器关心的业务逻辑。
- 线程的管理:例如,获取资源失败的线程如何排队?何时阻塞?何时被唤醒?这是所有同步器都需要的通用逻辑。
模板方法模式:
- AQS (父类/模板):负责实现线程的管理。它定义好了获取资源(
acquire)和释放资源(release)的顶级逻辑框架,这个框架包含了线程入队、阻塞、出队、唤醒等一系列标准操作。 - 具体同步器 (子类):负责实现资源状态的管理。它只需要重写 AQS 提供的几个
protected方法(如tryAcquire,tryRelease),来定义“如何算是成功获取资源”和“如何算是成功释放资源”。
- AQS (父类/模板):负责实现线程的管理。它定义好了获取资源(
通过这种方式,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) 方法。
tryAcquire(arg)- 线程首先调用子类实现的
tryAcquire()方法,尝试获取锁。这是一个快速路径。 - 如果成功 (例如,CAS 修改
state从 0 到 1 成功),acquire方法直接返回,线程继续执行,整个过程非常快,没有线程排队和阻塞。 - 如果失败,则进入下面的慢速路径。
- 线程首先调用子类实现的
addWaiter(Node.EXCLUSIVE)- 如果
tryAcquire()失败,AQS 会将当前线程封装成一个独占模式的Node。 - 然后通过一个 CAS 自旋循环,安全地将这个
Node添加到等待队列的尾部。
- 如果
acquireQueued(node, arg)- 节点入队后,线程并不会立刻阻塞,而是进入一个自旋(死循环)过程。
- 在循环中,它会检查自己的前一个节点是不是头节点 (head)。
- 如果是,说明自己是队列中排在最前面的等待者,有资格去获取锁。于是它会再次调用
tryAcquire()尝试获取锁。- 如果这次成功了,它会将自己设置为新的头节点(老的头节点出队),并从
acquireQueued方法返回,线程恢复执行。 - 如果还是失败(比如锁恰好被另一个线程抢到),则进入阻塞阶段。
- 如果这次成功了,它会将自己设置为新的头节点(老的头节点出队),并从
- 如果不是,说明自己前面还有别的线程在等待,不应该去抢锁。
- 如果是,说明自己是队列中排在最前面的等待者,有资格去获取锁。于是它会再次调用
shouldParkAfterFailedAcquire和parkAndCheckInterrupt- 当线程发现自己不应该或无法获取锁时,它会检查前一个节点的
waitStatus。 - 它会通过 CAS 将前一个节点的
waitStatus设置为SIGNAL。这个状态的含义是:“前面的兄弟,当你释放锁的时候,请务必唤醒我(unparkme)”。 - 设置成功后,当前线程就会调用
LockSupport.park(this)将自己安全地挂起(阻塞),等待被唤醒。
- 当线程发现自己不应该或无法获取锁时,它会检查前一个节点的
(B) 释放资源 (release 方法)
当一个线程调用 lock.unlock() 时,实际上是在调用 AQS 的 release(1) 方法。
tryRelease(arg)- 线程调用子类实现的
tryRelease()方法,尝试释放锁(例如,将state减 1)。 - 如果成功(例如,
state变为 0),则返回true,表示锁已被完全释放。 - 如果失败(例如,在可重入锁中,
state只是从 2 减到 1,锁仍被当前线程持有),则返回false,什么也不做。
- 线程调用子类实现的
唤醒后继者
- 如果
tryRelease()返回true,AQS 会找到队列的头节点(head)。 - 如果头节点有后继者(
head.next != null),AQS 就会调用LockSupport.unpark(successor.thread)来唤醒这个后继节点中封装的线程。 - 被唤醒的线程将从之前
park的地方醒来,回到acquireQueued的自旋循环中,再次尝试获取锁。
- 如果
5. 设计原理总结
- 核心数据结构:
volatile int state+ FIFO 双向队列。 - 核心机制:利用 CAS 实现对
state和队列的无锁原子操作,利用LockSupport.park/unpark实现线程的阻塞和唤醒。 - 核心模式:模板方法模式,将通用的队列管理和线程调度逻辑(在 AQS 中)与具体的资源状态管理逻辑(在子类中)解耦。
- 两大流程:
- 获取:尝试
tryAcquire-> 失败则入队 -> 在队列中自旋,检查是否轮到自己 -> 如果是,则再次tryAcquire-> 仍然失败或没轮到自己,则park阻塞。 - 释放:尝试
tryRelease-> 成功则唤醒(unpark)队列中的下一个等待线程。
- 获取:尝试
- 性能优势:
- 在无竞争的情况下,获取锁的成本极低,仅为一次 CAS 操作。
- 在有竞争的情况下,通过队列有序地管理线程,避免了“惊群效应”(唤醒所有线程去争抢一个锁),只唤醒下一个需要获取锁的线程,提高了效率和公平性。
AQS 的设计是 Java 并发编程的典范,它巧妙地结合了多种底层技术,为上层应用提供了一个强大、高效且易于扩展的同步框架。