在构建任何高并发、高性能的Java后端服务时,java.util.concurrent (JUC) 包是绕不开的基石,而理解其设计的精髓,是每一位资深工程师和架构师的必修课。在这个包的众多实现中,AbstractQueuedSynchronizer (AQS) 扮演着绝对核心的角色。它不仅是 ReentrantLock, Semaphore, CountDownLatch 等一系列关键同步组件的底层支撑,其设计本身更是一场关于操作系统、CPU指令和数据结构权衡的艺术。本文旨在彻底剖析AQS,从其要解决的并发问题出发,下探到底层硬件与OS原理,再回到其精巧的源码实现,最终探讨其在架构设计中的权衡与演进。本文面向的是希望在并发编程领域“知其然,并知其所以然”的工程师。
现象与问题背景:从 synchronized 的局限到 AQS 的诞生
在Java的早期版本中,同步(Synchronization)的几乎唯一选择就是 synchronized 关键字。它作为JVM内置的锁机制,通过与每个Java对象关联的监视器锁(Monitor Lock)来实现互斥。对于简单的并发场景,synchronized 足够简单、有效。然而,随着分布式、高并发应用场景的复杂化,它的局限性也日益凸显:
- 功能单一性: 它是一种非黑即白的互斥锁,要么获取锁,要么无限期阻塞。它缺乏更丰富的语义,比如尝试获取锁(try-lock)、可超时的锁等待、可被中断的锁等待等。
- 缺乏公平性控制:
synchronized是非公平的。当锁被释放时,任何一个正在等待的线程都有可能获得锁,这可能导致某些线程长时间“饥饿”(Starvation),即永远无法获得执行机会。 - 性能瓶颈: 在高竞争环境下,
synchronized会导致大量的线程上下文切换。早期版本的JVM中,它是一种重量级操作,因为线程的阻塞和唤醒严重依赖操作系统的互斥量(Mutex),这涉及到用户态到内核态的频繁切换,开销巨大。尽管后续版本通过锁膨胀(自旋锁、轻量级锁、偏向锁)进行了优化,但其内在机制的局限性依然存在。 - 灵活性缺失:
synchronized只能作用于代码块或方法,其锁的获取和释放是隐式且绑定的。我们无法在一个方法中获取锁,而在另一个方法中释放它。此外,它无法实现读写锁、信号量等更复杂的同步模式。
正是为了解决这些问题,JUC的作者Doug Lea设计了AQS。AQS的核心目标并非提供一个具体的同步实现,而是构建一个通用的、高性能的、可扩展的同步器框架。它将同步状态的管理与线程的排队、阻塞、唤醒机制分离开来,开发者只需要关注具体场景下的状态变更逻辑,而无需处理复杂的线程调度问题。这是一种典型的模板方法设计模式,也是一种深刻的“关注点分离”架构思想的体现。
关键原理拆解:从硬件原子指令到 CLH 队列锁
要理解AQS的精妙之处,我们必须回归到计算机科学的底层。作为一位架构师,我更倾向于从第一性原理出发,而不是直接看API。同步问题的根源在于,多处理器环境下,多个线程并发修改共享内存时,如何保证操作的原子性(Atomicity)。
第一层基石:CPU原子指令
现代CPU为此提供了一系列特殊的硬件指令,用于保证对单个内存地址的读-改-写操作是原子的,不可被中断。其中最著名的就是 Compare-and-Swap (CAS)。CAS指令包含三个操作数:内存位置V、预期原值A和新值B。当且仅当V的值等于A时,CPU才会原子地将V的值更新为B,并返回操作成功;否则,它什么都不做,返回失败。在Java中,sun.misc.Unsafe 类和后续的 java.util.concurrent.atomic 包将CAS操作暴露给了开发者。AQS内部大量使用CAS来原子地修改其核心状态变量 `state` 和管理等待队列的指针,从而避免了使用更重的锁。
第二层基石:线程阻塞与唤醒的成本
当一个线程无法获取锁时,它有两种选择:要么自旋(Spinning),即在一个循环中不断尝试获取锁,这会持续消耗CPU资源;要么阻塞(Blocking),即放弃CPU执行权,进入休眠状态,等待被唤醒。线程的阻塞和唤醒是由操作系统内核(Kernel)来完成的,这个过程涉及:
- 用户态到内核态的切换: 这是一个有显著开销的操作,需要保存当前线程的用户态上下文(寄存器、栈指针等)。
- 线程调度: 内核需要将该线程从运行队列中移除,放入等待队列,并选择另一个就绪线程来执行。
- 上下文切换: 将CPU的控制权从旧线程交给新线程,包括加载新线程的上下文。
显然,频繁的线程阻塞和唤醒是性能杀手。一个优秀的同步器必须在“自旋的CPU开销”和“阻塞的调度开销”之间做出明智的权衡。
第三层基石:CLH 队列锁模型
AQS的线程排队机制,其思想源于一种名为CLH (Craig, Landin, and Hagersten) 队列锁的算法。CLH锁是一种可扩展的、公平的自旋锁。其核心思想是构建一个隐式的链表(队列),每个希望获取锁的线程都封装成一个节点,并加入队列尾部。每个节点内部有一个状态位,线程在自己的节点上自旋,而不是在全局的锁变量上自旋。当一个线程释放锁时,它会修改其后继节点的状态位,从而唤醒下一个等待者。这种“接力”式的方式极大地减少了对共享内存的竞争,尤其是在多核(NUMA)架构下,每个线程主要访问自己的或前驱节点的缓存行,缓存一致性流量得到有效控制。
AQS巧妙地借鉴并改造了CLH模型。它并非让线程无限自旋,而是将CLH队列作为一个“等候区”。线程在入队后,会进行短暂的自旋尝试,如果失败,就会通过 LockSupport.park() 方法将自己挂起,进入阻塞状态,从而让出CPU。这是一种自旋与阻塞相结合的策略,既避免了长时间自旋的CPU浪费,也通过队列机制保证了唤醒的公平性和有序性。
AQS 架构总览:一个状态,两套队列,三组核心方法
AQS的内部架构可以用一个高度浓缩的模型来描述,理解了这个模型,就抓住了AQS的脉络。
一个核心状态 (State)
AQS内部维护一个 private volatile int state; 变量。这个32位的整型变量是所有同步语义的核心。它被声明为 volatile,以保证其在多线程间的可见性。对它的所有修改都通过CAS操作(或其它原子方式)进行。这个 `state` 的具体含义由AQS的子类来定义:
- 在
ReentrantLock中,它表示锁的重入次数。0表示未被锁定,1表示被锁定,大于1表示锁的重入。 - 在
Semaphore中,它表示当前可用的许可证数量。 - 在
CountDownLatch中,它表示需要等待的事件数量。
两套队列 (Queues)
AQS内部维护了两类队列,它们都是基于内部类 Node 构建的双向链表。
- 同步队列 (Sync Queue): 这是AQS的核心,一个基于CLH模型改造的双向队列。所有请求锁但失败的线程都会被封装成一个 `Node` 节点,并加入到这个队列的尾部。当锁被释放时,会从头部唤醒一个节点代表的线程。
- 条件队列 (Condition Queue): 这是为支持
Condition接口而设计的。每个Condition对象都拥有一个独立的条件队列。当线程调用condition.await()时,它会释放持有的锁,并被加入到相应的条件队列中等待。当其它线程调用condition.signal()时,会从条件队列中取出一个节点,并将其移动到同步队列中,让它重新参与锁的竞争。
三组核心方法 (Methods)
AQS通过模板方法模式,将功能清晰地划分为三类:
- 状态访问方法:
getState(),setState(int),compareAndSetState(int expect, int update)。这些是子类用来读取和原子更新state的基础工具。 - 可定制的同步语义方法(模板方法): 这是需要子类去重写的方法,用于定义具体的锁获取和释放逻辑。
protected boolean tryAcquire(int arg):独占模式下尝试获取资源。protected boolean tryRelease(int arg):独占模式下尝试释放资源。protected int tryAcquireShared(int arg):共享模式下尝试获取资源。protected boolean tryReleaseShared(int arg):共享模式下尝试释放资源。protected boolean isHeldExclusively():判断当前线程是否持有独占锁。
- 框架内置的队列管理方法:
acquire(int arg),release(int arg),acquireShared(int arg)等。这些方法是final的,封装了线程排队、阻塞、唤醒的通用逻辑,它们会调用上述的模板方法。开发者通常直接调用这些方法,而无需关心其内部实现。
这种设计堪称经典:AQS负责管理线程排队的“机制”,而子类负责定义状态变更的“策略”。
核心模块设计与实现:深入 ReentrantLock 的源码肌理
纸上谈兵终觉浅,我们直接深入最常用的AQS实现——ReentrantLock 的源码,看看这些原理是如何落地的。ReentrantLock 内部有两个AQS的实现类:NonfairSync (默认) 和 FairSync。
独占锁获取 `acquire(1)` 流程
当我们调用 `lock.lock()` 时,实际上是调用了 `sync.acquire(1)`。我们以非公平锁 NonfairSync 为例,分析这个过程。
// AbstractQueuedSynchronizer.java
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
这个流程非常清晰:
步骤 1: `tryAcquire(arg)` 尝试获取锁
这是子类实现的核心策略。对于 NonfairSync,它的实现充满了“极客”的 pragmatism:
// ReentrantLock.java$NonfairSync
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) { // 锁是自由的
if (compareAndSetState(0, acquires)) { // 尝试用CAS抢占
setExclusiveOwnerThread(current); // 成功,设置自己为锁持有者
return true;
}
}
else if (current == getExclusiveOwnerThread()) { // 锁已被持有,检查是否是自己(重入)
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc); // 重入,直接更新state
return true;
}
return false; // 获取失败
}
非公平性体现在这里:只要锁是自由的(`c == 0`),新来的线程就直接尝试CAS抢占,而不管同步队列里是否已经有线程在等待。这种“插队”行为(Barging)是其高性能的关键之一。
步骤 2: `addWaiter(Node.EXCLUSIVE)` 加入等待队列
如果 `tryAcquire` 失败,说明锁被其它线程持有。此时,当前线程需要被封装成一个 `Node` 并加入同步队列的尾部。`addWaiter` 的实现是一个无锁的、通过CAS自旋来保证线程安全的队尾添加操作。
// AbstractQueuedSynchronizer.java
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// 快速尝试,如果尾节点不为空,直接CAS接在后面
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 快速尝试失败(比如tail被并发修改),进入完整的enq循环
enq(node);
return node;
}
private Node enq(final Node node) {
for (;;) { // 无限循环,直到成功
Node t = tail;
if (t == null) { // 队列未初始化
if (compareAndSetHead(null, new Node()))
tail = head;
} else { // CAS设置新的尾节点
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
这里的 `for (;;)` 无限循环是无锁编程的典型模式。它通过CAS不断重试,直到成功将节点原子地添加到队尾。在高竞争下,这个循环可能会执行多次,但这是用户态的CPU空转,通常比陷入内核态的成本要低。
步骤 3: `acquireQueued(node, arg)` 在队列中等待并再次尝试
入队后,线程并不会立刻休眠,而是进入 `acquireQueued` 的核心循环。这个方法是AQS的精髓所在,它完美地结合了自旋和阻塞。
// AbstractQueuedSynchronizer.java
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
// 如果前驱是head,说明自己是队列的下一个竞争者,再次尝试获取锁
if (p == head && tryAcquire(arg)) {
setHead(node); // 成功,将自己设为新的head
p.next = null; // help GC
failed = false;
return interrupted;
}
// 如果获取失败,判断是否应该挂起
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) // 挂起线程
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
这个循环的核心逻辑是:只有当节点的前驱是头节点时,它才有资格去尝试获取锁。这保证了FIFO的顺序。如果尝试失败,`shouldParkAfterFailedAcquire` 会检查前驱节点的状态位(`waitStatus`)。如果前驱的状态是 `SIGNAL`,表示前驱节点在释放锁时有责任唤醒自己,那么当前线程就可以安全地调用 `LockSupport.park(this)` 进入阻塞状态了。这个 `park` 调用就是从用户态到内核态的转换点,线程在此处交出CPU,等待被 `unpark`。
独占锁释放 `release(1)` 流程
当线程调用 `lock.unlock()` 时,实际上是调用了 `sync.release(1)`。
// AbstractQueuedSynchronizer.java
public final boolean release(int arg) {
if (tryRelease(arg)) {
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h); // 唤醒后继者
return true;
}
return false;
}
步骤 1: `tryRelease(arg)` 尝试释放锁
这同样是子类实现的策略。对于 ReentrantLock,它会减少重入计数,只有当计数归零时,才真正释放锁。
// ReentrantLock.java$Sync
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) { // 计数归零,完全释放
free = true;
setExclusiveOwnerThread(null);
}
setState(c); // 更新state
return free;
}
步骤 2: `unparkSuccessor(h)` 唤醒后继节点
如果 `tryRelease` 返回 `true`(锁被完全释放),AQS就会调用 `unparkSuccessor` 来唤醒队列中的下一个等待线程。它会找到头节点的下一个有效等待者,并调用 `LockSupport.unpark(thread)`。这个 `unpark` 操作会将目标线程从阻塞态唤醒为就绪态,使其可以重新进入 `acquireQueued` 的循环,并最终获取到锁。
对抗与权衡:吞吐量、公平性与响应性的博弈
作为架构师,我们不仅要理解实现,更要洞察其背后的设计权衡。AQS及其实现(如ReentrantLock)提供了丰富的选项,而这些选项背后是深刻的系统性能博弈。
公平性 vs. 吞吐量 (Fairness vs. Throughput)
- 非公平锁 (Non-fair Lock): 这是
ReentrantLock的默认选择。它的优势在于高吞吐量。当一个线程释放锁时,如果恰好有一个新线程请求锁,这个新线程可以立即“插队”并获取锁,而无需进入队列等待。这减少了线程上下文切换的次数。因为刚释放锁的线程和刚请求锁的线程很可能还在CPU上运行,它们的缓存也是热的,这进一步提升了性能。但它的缺点是可能导致队列中的线程饥饿。 - 公平锁 (Fair Lock): 通过 `ReentrantLock(true)` 构造。它严格按照线程请求的顺序(FIFO)来分配锁。`FairSync` 的 `tryAcquire` 实现会先检查 `hasQueuedPredecessors()`,如果队列中已经有等待者,它会拒绝获取锁。这保证了公平,不会有线程饿死,但代价是较低的吞吐量,因为几乎每次锁的交接都会伴随着一次线程阻塞和唤醒,即一次上下文切换。
工程抉择: 在绝大多数场景下,非公平锁的吞吐量优势远比其可能带来的饥饿问题更重要。只有在业务逻辑严格要求时序性,或者饥饿现象确实成为瓶颈时,才应考虑使用公平锁。
自旋 vs. 挂起 (Spinning vs. Blocking)
AQS采用的是一种混合策略。在 `enq` 和 `acquireQueued` 的初始阶段,它存在短暂的自旋。这是一种乐观的假设:或许锁马上就要被释放了。如果短暂自旋后仍未成功,再通过 `park` 将线程挂起。这是一种对现代多核CPU架构的深刻理解。如果临界区非常短,自旋的开销(CPU周期)可能小于一次上下文切换的开销。JVM的即时编译器(JIT)也会进行逃逸分析和锁消除等优化,甚至在更底层进行自适应自旋,动态调整自旋次数。
可中断性与超时 (Interruptibility & Timeout)
与 `synchronized` 不同,AQS框架提供了 `acquireInterruptibly` 和 `tryAcquireNanos` 两个关键的变体。这使得基于AQS构建的锁(如 `ReentrantLock`)能够响应中断(`Thread.interrupt()`)和支持超时。这在构建健壮、高响应性的系统中至关重要。例如,在一个分布式调用中,如果下游服务长时间无响应,我们可以中断等待锁的线程,或让其超时失败,从而避免资源被无限期占用,防止雪崩效应。这是 `synchronized` 无法提供的关键能力。
架构演进与落地路径:从内置锁到自定义同步器
在技术选型和团队能力建设上,对并发工具的理解和使用也应遵循一个演进路径。
第一阶段:熟练使用 `synchronized`
对于初中级工程师,首先要掌握的是Java内置的 `synchronized`。在竞争不激烈、功能要求简单的场景下,它依然是最简单、最不易出错的选择。JVM对它的持续优化也使其在很多场景下性能并不差。
第二阶段:精通JUC常用组件
对于中高级工程师,必须精通JUC提供的现成同步器。这包括:
ReentrantLock:需要更丰富功能(公平性、可中断、超时、条件变量)的互斥锁场景。ReentrantReadWriteLock:读多写少的场景,通过读锁共享来大幅提升并发度。Semaphore:控制同时访问特定资源的线程数量,常用于资源池、流量控制。CountDownLatch/CyclicBarrier:用于线程间的协调与同步,等待多个事件完成。
在99%的业务场景中,这些工具已经足够强大和灵活。关键在于深刻理解每个工具的适用场景和语义,避免误用。
第三阶段:基于AQS构建自定义同步器
对于架构师或框架开发者,当面临一些高度定制化的并发场景,JUC提供的标准组件无法满足需求时,就到了利用AQS构建自定义同步器的阶段。这通常出现在需要将特定业务逻辑与线程同步状态紧密结合的场景。例如:
- 自定义资源池: 比如一个管理数据库连接池的组件,可以用AQS的 `state` 表示当前可用的连接数,`acquire` 表示获取连接,`release` 表示归还连接。
- 分布式锁的客户端实现: 在与Redis或ZooKeeper集成的分布式锁客户端中,可以用AQS来管理本地线程的排队。当客户端成功获取到分布式锁时,相当于本地AQS的 `tryAcquire` 成功;获取失败时,线程进入AQS队列等待,直到收到分布式锁释放的通知,再由客户端逻辑唤醒AQS队列中的线程。
下面是一个极简的、不可重入的互斥锁的实现,用以展示基于AQS的开发是多么简洁:
public class SimpleMutex extends AbstractQueuedSynchronizer {
// 判断是否被持有
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
// 尝试获取锁
@Override
protected boolean tryAcquire(int acquires) {
assert acquires == 1; // Otherwise unused
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
return true;
}
return false;
}
// 尝试释放锁
@Override
protected boolean tryRelease(int releases) {
assert releases == 1; // Otherwise unused
if (getState() == 0) throw new IllegalMonitorStateException();
setExclusiveOwnerThread(null);
setState(0);
return true;
}
// 提供给外部的接口
public void lock() { acquire(1); }
public void unlock() { release(1); }
public boolean isLocked() { return isHeldExclusively(); }
}
通过继承AQS并重写几个`try*`方法,我们仅用几十行代码就实现了一个功能完备的同步锁。所有复杂的线程排队、中断处理、超时逻辑都由AQS父类搞定了。
结论: AQS不仅仅是一个类,它是一种并发编程的范式。它将并发控制中不变的、复杂的部分(线程排队与调度)固化为框架,而将易变的部分(同步状态的定义与修改)作为模板暴露给开发者。对AQS的深入理解,是从“使用API”到“掌握原理”的飞跃,是衡量一位Java工程师技术深度的重要标尺。它所蕴含的分层、抽象和权衡思想,将对我们的系统设计产生深远的影响。
延伸阅读与相关资源
-
想系统性规划股票、期货、外汇或数字币等多资产的交易系统建设,可以参考我们的
交易系统整体解决方案。 -
如果你正在评估撮合引擎、风控系统、清结算、账户体系等模块的落地方式,可以浏览
产品与服务
中关于交易系统搭建与定制开发的介绍。 -
需要针对现有架构做评估、重构或从零规划,可以通过
联系我们
和架构顾问沟通细节,获取定制化的技术方案建议。