1. 项目概述:为什么AQS是Java并发编程的“定海神针”?
如果你写过Java并发程序,用过ReentrantLock、Semaphore或者CountDownLatch,那你其实已经在和AQS打交道了。AQS,全称AbstractQueuedSynchronizer,是Java并发包java.util.concurrent.locks里的一个核心基础框架。我第一次深入接触它,是因为一个线上死锁问题排查了整整两天,最后发现是对锁的获取和释放状态理解有偏差,而问题的根源就藏在AQS的实现细节里。从那以后,我意识到,不理解AQS,所谓的“精通Java并发”就像是在沙地上盖楼,基础不牢。
简单来说,AQS是一个用于构建锁和同步器的“脚手架”。它封装了同步状态管理、线程排队、等待与唤醒这些复杂且容易出错的底层机制。ReentrantLock、Semaphore、CountDownLatch,甚至ReentrantReadWriteLock,这些我们耳熟能详的同步工具,其内部的核心同步逻辑,都是基于AQS这个“骨架”搭建起来的。你可以把它想象成一个高度抽象、可定制的“同步状态机”模板。我们只需要关注“资源是否可用”这个核心状态,并定义“如何获取”和“如何释放”资源的规则,AQS就会帮我们自动处理好线程的排队、阻塞和唤醒。
为什么需要彻底搞懂它?因为它是理解Java并发库高级用法的钥匙。当你明白了ReentrantLock的公平与非公平模式在AQS队列里是如何体现的,你就能在写代码时做出更合适的选择;当你清楚了Semaphore的“许可证”在AQS里只是一个int类型的状态值,你就能更好地理解其资源控制的本质。更重要的是,在排查复杂的并发问题时,比如线程卡死、性能瓶颈,能够深入到AQS的层面去分析队列状态、线程状态,往往是定位问题的关键。这不仅仅是面试八股文,更是解决实际生产问题的硬核技能。
2. AQS核心设计思想与原理拆解
要理解AQS,不能一上来就钻到代码里,而是要先把握住它的顶层设计。它的核心思想非常巧妙,可以用一个“银行柜台办理业务”的模型来类比。
2.1 状态(State)与资源模型
AQS内部维护了一个核心的volatile int类型的变量,叫做state。这个state就是整个同步器的“灵魂”,它代表了共享资源的状态。至于这个状态具体表示什么,完全由子类来决定,这就是“抽象”的含义。
- 在
ReentrantLock中:state表示锁的重入次数。state == 0表示锁空闲;state == 1表示锁被一个线程持有;state > 1表示被同一个线程重入了多次。 - 在
Semaphore中:state表示当前可用的许可证(Permit)数量。state == 0表示没有空闲许可证,新来的线程需要等待;state > 0表示还有许可证可以获取。 - 在
CountDownLatch中:state表示倒计时的初始计数。线程调用countDown()会使state减1,调用await()的线程会等待state变为0。
这个设计非常精妙:AQS不关心state的具体语义,它只提供对state进行原子性读写的getState()、setState()和compareAndSetState()(CAS操作)方法。具体的资源获取和释放逻辑,则交给子类去实现。这完美符合了“模板方法”设计模式。
2.2 核心方法模板:tryAcquire与tryRelease
AQS定义了获取和释放资源的顶级入口,如acquire(int arg)和release(int arg)。但这些方法内部,并不会直接操作state,而是会调用子类必须实现(或可以选择实现)的“钩子”方法。
protected boolean tryAcquire(int arg):尝试以独占模式获取资源。子类需要实现这个方法,定义“在什么条件下算获取成功”。通常,这个方法里会检查当前state,如果满足条件(比如state == 0),就通过CAS操作将state设置为目标值(比如1),成功返回true,否则返回false。protected boolean tryRelease(int arg):尝试释放独占模式下的资源。子类实现此方法,通常是将state进行减少或归零,并返回true表示释放成功。
对于共享模式(如Semaphore、CountDownLatch),也有对应的tryAcquireShared和tryReleaseShared方法。
这里的关键点在于:AQS的acquire()方法会先调用子类的tryAcquire()。如果tryAcquire()成功,线程就直接获取资源继续执行,非常高效。如果失败,AQS才会介入,将当前线程包装成一个节点(Node),加入到等待队列中,然后进行复杂的排队、阻塞和唤醒管理。这个“先尝试,失败再排队”的流程,是理解AQS性能的关键。
2.3 等待队列(CLH队列变体)
当线程通过tryAcquire尝试获取资源失败后,AQS不会让它“空转”或立即阻塞,而是将其放入一个先进先出(FIFO)的等待队列中。这个队列是AQS的另一个核心组件,它是一个双向链表,每个节点(Node)代表一个等待线程。
这个队列的设计借鉴了CLH锁队列的思想,但做了很多优化。每个Node节点里保存了线程引用、等待状态(waitStatus)以及前驱(prev)、后继(next)指针。waitStatus非常重要,它标识了节点的状态,比如:
SIGNAL (-1):表示该节点的后继节点需要被唤醒。当前驱节点释放锁时,需要唤醒其后继节点。CANCELLED (1):表示该节点对应的线程已超时或被中断,需要从队列中移除。CONDITION (-2):表示该节点在条件队列中(与Condition对象相关,这是另一个话题)。PROPAGATE (-3):用于共享模式下的传播唤醒。
一个常见的误解是:队列是严格公平的。实际上,在非公平锁的实现中(如ReentrantLock的非公平模式),新来的线程在进入队列前,会再次尝试“插队”获取锁(调用tryAcquire)。如果此时锁恰好被释放,它就能抢在队列头部的线程之前获得锁。这种设计虽然牺牲了绝对的公平性,但减少了线程挂起和唤醒的开销,在大多数场景下能显著提升吞吐量。理解这一点,对性能调优至关重要。
3. 从零解析AQS工作流程:以ReentrantLock为例
理论说再多,不如看一个完整的流程。我们以最常用的ReentrantLock的非公平锁模式为例,拆解一个线程从加锁到解锁,AQS内部到底发生了什么。
3.1 加锁(lock)流程深度剖析
假设我们有一个ReentrantLock lock = new ReentrantLock();,线程A调用lock.lock()。
首次尝试(插队):
lock()方法会直接调用AQS的acquire(1)。在acquire方法内部,首先会调用子类(ReentrantLock中的Sync内部类)实现的tryAcquire方法。在非公平模式下,tryAcquire的逻辑是:先读取当前的state。- 如果
state == 0(锁空闲),它会立即尝试用CAS操作将state从0设置为1。如果成功,则将当前线程设置为独占锁的拥有者(setExclusiveOwnerThread),然后lock()方法直接返回,线程A成功获取锁,继续执行。这个过程完全没有涉及队列操作,是最快的路径。 - 如果
state != 0,但当前持有锁的线程就是线程A自己(重入),那么就将state加1,然后返回成功。 - 如果以上都不满足,
tryAcquire返回false。
- 如果
入队与等待:由于
tryAcquire失败,AQS开始执行acquire方法的后续逻辑。首先,调用addWaiter(Node.EXCLUSIVE)方法,将线程A包装成一个独占模式的Node节点,然后通过一个“自旋+CAS”的方式,将这个新节点安全地添加到等待队列的尾部。如果队列尚未初始化(头节点head为空),会先创建一个虚拟的哑元节点(dummy node)作为头节点。自旋、检查与阻塞:节点入队后,会进入
acquireQueued方法。这是一个核心的自旋循环。- 在循环中,它会检查自己的前驱节点是不是头节点(
head)。因为只有头节点的线程有资格尝试获取锁。 - 如果前驱是头节点,它会再次调用
tryAcquire尝试获取锁。如果成功,则将当前节点设置为新的头节点(原头节点出队),并清空其中的线程引用(因为该线程已获得锁),然后返回。 - 如果前驱不是头节点,或者尝试获取锁再次失败,则会调用
shouldParkAfterFailedAcquire方法。这个方法会检查前驱节点的waitStatus。如果前驱节点状态是SIGNAL,则返回true,表示当前线程“可以安心去睡了”,因为前驱节点释放锁时会负责唤醒它。如果前驱节点状态不是SIGNAL,则通过CAS将其设置为SIGNAL,然后返回false,进行下一轮循环检查。 - 当
shouldParkAfterFailedAcquire返回true后,会调用parkAndCheckInterrupt方法,底层使用LockSupport.park(this)将当前线程挂起(进入WAITING状态)。线程A就在这里安静地等待被唤醒。
- 在循环中,它会检查自己的前驱节点是不是头节点(
3.2 解锁(unlock)流程与唤醒链
现在,持有锁的线程(可能是线程A,也可能是其他线程)执行完了临界区代码,调用lock.unlock()。
尝试释放:
unlock()调用AQS的release(1)。release方法首先调用子类实现的tryRelease方法。在ReentrantLock中,tryRelease会将state减1。如果减1后state == 0(完全释放),则清空独占线程持有者,并返回true;否则(重入情况)返回false。只有完全释放时,release方法才会继续执行唤醒逻辑。唤醒后继:如果
tryRelease返回true,release方法会检查头节点(head)。如果头节点不为空且其waitStatus != 0(通常为SIGNAL),则调用unparkSuccessor方法。unparkSuccessor会从尾节点向前遍历(为了处理可能存在的取消节点),找到离头节点最近的、状态正常的(非CANCELLED)后继节点。- 找到之后,调用
LockSupport.unpark(s.thread)唤醒该节点对应的线程。
被唤醒线程的后续:之前被
park挂起的线程(比如队列中的第二个节点线程B)被唤醒后,会从parkAndCheckInterrupt方法中返回,然后继续它所在的acquireQueued自旋循环。- 被唤醒后,它再次检查自己的前驱节点是否变成了头节点(因为原头节点在释放锁后已出队)。
- 如果是,它再次调用
tryAcquire尝试获取锁。此时锁是空闲的,所以这次tryAcquire通常会成功。 - 成功后,线程B将自己所在的节点设置为新的头节点,然后从
lock()方法返回,正式获得锁,开始执行自己的临界区代码。
这个流程的精髓在于:线程的阻塞和唤醒是由AQS队列精确管理的,避免了忙等待(busy-waiting)的巨大CPU开销。同时,“前驱节点负责唤醒后继节点”的责任链机制,以及“只有头节点能尝试获取锁”的规则,保证了等待队列的有序性和正确性。
4. AQS在JUC同步器中的典型应用
理解了AQS的骨架,我们再看看几个“血肉”是如何长上去的。这能让你更深刻地体会到AQS的灵活与强大。
4.1 ReentrantLock:独占锁的典范
ReentrantLock是AQS最经典的应用。它内部有两个主要的同步器实现:NonfairSync(非公平锁)和FairSync(公平锁),它们都继承自Sync,而Sync继承自AQS。
非公平锁
tryAcquire:逻辑如前面所述,新线程有机会直接“插队”获取锁。其tryAcquire方法的核心代码如下(简化):final boolean nonfairTryAcquire(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; setState(nextc); // 注意,这里直接setState,因为线程已持有锁,不存在竞争 return true; } return false; }注意:在重入时,它直接使用
setState,而不是compareAndSetState,因为当前线程已经持有锁,不存在竞争,这是性能上的一个优化点。公平锁
tryAcquire:公平锁的tryAcquire多了一个检查:hasQueuedPredecessors()。这个方法会检查等待队列中是否有比当前线程等待时间更长的线程。如果有,即使state == 0,公平锁也会“礼貌地”返回false,让当前线程乖乖去排队。这就是公平性的体现。
选择建议:在锁竞争不激烈或持有时间非常短的场景,非公平锁的吞吐量更高。在需要严格保证线程获取锁的顺序(避免饥饿)的场景,使用公平锁。默认情况下,ReentrantLock是非公平的,因为性能更好。
4.2 Semaphore:共享资源的流量控制器
Semaphore(信号量)是共享模式的典型代表。它允许多个线程同时访问资源池。其state表示可用许可证数量。
tryAcquireShared:尝试获取一个许可证。其逻辑是,当前state减去需要的数量(acquires,通常为1),如果结果不小于0,则用CAS更新state并返回剩余数量(正值表示获取成功,且可能还有剩余;0表示获取成功但无剩余);如果结果为负,则返回负值表示失败。protected int tryAcquireShared(int acquires) { for (;;) { // 自旋 int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) return remaining; // 关键:返回剩余量 } }tryReleaseShared:释放一个许可证。逻辑是将state增加释放的数量,由于可能有多个线程同时释放,所以必须用CAS在循环中操作。
这里有个关键点:protected final boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); int next = current + releases; if (next < current) // 溢出检查 throw new Error("Maximum permit count exceeded"); if (compareAndSetState(current, next)) return true; } }tryAcquireShared的返回值。在AQS的acquireShared方法中,如果tryAcquireShared返回负值,表示获取失败,线程入队;如果返回0或正值,表示获取成功。对于Semaphore,返回remaining(剩余许可证数)。当remaining >= 0时,不仅当前线程获取成功,AQS还会根据返回值决定是否要“传播”唤醒后续的共享节点,这正是共享模式“一呼百应”特性的基础。
4.3 CountDownLatch:多线程协调的“发令枪”
CountDownLatch同样使用共享模式。它的state在构造时被设置为一个正数(计数)。countDown()操作对应releaseShared(1),每次将state减1。await()操作对应acquireSharedInterruptibly(1),它会尝试获取共享锁,而tryAcquireShared的逻辑非常简单:只要state == 0就返回成功(1),否则返回失败(-1)。
这意味着,在计数未减到0之前,所有调用await()的线程都会在tryAcquireShared中失败,然后被AQS放入队列并挂起。当最后一个线程调用countDown()将state减为0时,tryReleaseShared会返回true,触发AQS唤醒队列中所有等待的线程(共享模式的传播唤醒),于是所有等待的线程同时被释放,继续执行。它完美地实现了一个或多个线程等待一组操作完成再继续执行的场景。
5. 实战避坑与高级特性探讨
理解了原理,在实际使用和面试中,还有一些深坑和高级话题需要特别注意。
5.1 常见问题排查与性能调优
死锁与AQS队列:死锁通常不是AQS本身造成的,而是业务逻辑中锁的顺序问题。但AQS的等待队列状态可以帮助我们诊断。通过
jstack或jconsole等工具获取线程dump,如果看到大量线程处于WAITING (parking)状态,且锁持有者是另一个线程,同时等待队列很长,这很可能就是锁竞争激烈或发生了死锁。结合ReentrantLock等工具提供的getOwner()和getQueuedThreads()(注意是protected方法,需要继承或反射调用)可以更精确地分析。非公平锁的“线程饥饿”:在锁竞争极度激烈且持有时间较长的场景下,非公平锁可能导致某些线程长时间无法获取锁(饥饿)。虽然概率低,但在需要绝对公平性的场景(如计费、任务调度)要警惕。监控队列长度和线程等待时间可以帮助发现这个问题。
tryLock()与超时的使用:ReentrantLock提供了tryLock()和带超时的tryLock(long time, TimeUnit unit)方法。它们底层调用的是AQS的tryAcquireNanos等方法。强烈建议在可能发生竞争或死锁的地方使用带超时的tryLock,而不是无条件的lock()。这能为系统提供一定的自我恢复能力。Lock lock = ...; if (lock.tryLock(3, TimeUnit.SECONDS)) { try { // 操作共享资源 } finally { lock.unlock(); } } else { // 获取锁失败,执行降级或告警逻辑 log.warn("获取锁超时,执行备用方案"); }Condition对象的使用:AQS还提供了一个内部类
ConditionObject,用于实现更精细的线程等待/通知机制,类似于Object.wait()和Object.notify(),但更强大、更安全。每个ConditionObject都维护一个独立的条件队列。await()会将当前线程从AQS主队列移到条件队列并释放锁;signal()会将条件队列中的一个线程转移到AQS主队列去竞争锁。这在实现“生产者-消费者”等模型时非常有用,但要小心使用,确保在调用await()和signal()时持有正确的锁。
5.2 AQS的扩展与自定义同步器
虽然我们很少需要自己从头实现一个AQS同步器,但理解如何扩展它,能让你对并发控制有更深的认识。假设我们要实现一个“同时最多只有两个线程进入”的闸门(TwinsLock)。
- 定义资源状态:我们设定
state的初始值为2,表示两个许可。 - 实现
tryAcquireShared:线程尝试获取时,将state减1。如果结果>=0,则获取成功。protected int tryAcquireShared(int reduceCount) { for (;;) { int current = getState(); int newCount = current - reduceCount; if (newCount < 0 || compareAndSetState(current, newCount)) { return newCount; // 成功则返回剩余许可数 } } } - 实现
tryReleaseShared:线程释放时,将state加1。protected boolean tryReleaseShared(int returnCount) { for (;;) { int current = getState(); int newCount = current + returnCount; if (compareAndSetState(current, newCount)) { return true; } } } - 封装使用:将上述同步器封装到一个
TwinsLock类中,提供lock()和unlock()方法(内部调用acquireShared(1)和releaseShared(1))。
通过这个简单的例子,你可以看到,基于AQS,我们只需要关注“资源”的定义和“获取/释放”的规则,复杂的线程排队管理全部由AQS这个强大的框架代劳了。
6. 源码阅读技巧与学习路径建议
最后,如果你想真正“搞懂”AQS,阅读源码是必经之路。但AQS的源码(尤其是acquireQueued、cancelAcquire等方法)以晦涩难懂著称。这里分享我个人的阅读心得:
画图辅助:准备纸笔或绘图工具。在阅读
acquire、release、enq(入队)、addWaiter等方法时,随时画出队列、节点、指针(prev,next)和状态(waitStatus)的变化。图形化能极大帮助理解链表操作和状态流转。使用调试器:写一个简单的多线程测试程序,使用
ReentrantLock。在IDEA或Eclipse中,在AQS的关键方法(如tryAcquire,addWaiter,acquireQueued,unparkSuccessor)上设置断点,以Debug模式运行。观察每一步执行后,state、队列结构、线程状态的变化。这是最直观的学习方式。先抓主干,再抠细节:不要一开始就陷入
shouldParkAfterFailedAcquire中各种waitStatus判断的细节。先理解主流程:尝试获取 -> 失败入队 -> 检查前驱 -> 挂起等待 -> 被唤醒 -> 再次尝试。把这个主干流程刻在脑子里,再去研究每个环节的精细处理(如取消节点清理、状态位设置)。结合Javadoc和经典资料:AQS的作者Doug Lea在类注释里写了大量的设计说明,这是第一手资料。同时,《Java并发编程实战》和《Java并发编程的艺术》等书籍中对AQS都有深入讲解,可以对照着看。
从使用到实现:学习路径应该是:先熟练使用
ReentrantLock、Semaphore等工具 -> 思考它们的行为和特性(公平/非公平、重入、共享/独占)-> 带着问题去阅读AQS源码,看这些特性是如何实现的。这样学习更有目的性,也更容易记住。
理解AQS,就像是拿到了Java并发世界的地图。它不会让你立刻成为并发大师,但能让你在面对任何基于锁或同步器的并发问题时,心里有底,知道该从哪里入手分析。它揭示的“状态管理+队列化等待”的思想,在分布式系统、操作系统调度等更广阔的领域也同样闪耀着智慧的光芒。下次当你使用lock.lock()时,不妨在脑海里过一遍那个精巧的CLH队列和状态流转图,这或许就是程序员的一种浪漫吧。