AQS这三个字母,几乎是Java并发学习路上的一道分水岭。全称AbstractQueuedSynchronizer,抽象队列同步器,Java并发包里的半壁江山都建立在它上面:ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock,底层清一色是AQS。这篇文章不打算把源码逐行翻译一遍,那是你打开IDE就能干的事。我打算换个讲法,从设计思路、核心机制、加锁释放的完整链路,再到手写一个基于AQS的同步工具、用jstack观测真实的排队现场,把AQS讲成你能记住、能复述、能自己落地验证的东西。适合两类人:准备面试、想把并发底层彻底啃明白的Java开发,以及工作中遇到过线程莫名卡死、想学会定位问题的实战派。
1. 整体设计思路拆解:AQS到底在解决什么问题
1.1 所有锁的本质,其实是三件事
我第一次带着“锁的本质是什么”这个问题去读AQS时,整个人通透了。任何一把锁、任何一个同步器,不管表现形式多花哨,核心都只有三件事:状态管理、排队等待、唤醒交接。
状态管理管的是“当前资源能不能被访问”,排队等待管的是“拿不到资源的人去哪歇着”,唤醒交接管的是“资源释放后怎么通知下一个人”。synchronized把这套事情全部藏在JVM内部,你只管用。而AQS把这套流程抽出来做成一个框架,把状态管理的判断逻辑留给你自己定义,把排队和唤醒这些相对固定的流程写成公共模板。这是整个AQS设计的灵魂。
打个比方,AQS就像一个图书馆的自习室管理员。管理员手里有一个计数器,记录自习室还能进几个人;有一个排队名单,满了就在门口登记排队;有人离开时,管理员会通知名单上最靠前的那个进来。你作为使用者,只需要告诉管理员“什么条件下算有位置”,剩下的登记、排队、通知、取消全都由管理员代劳。
这就能解释一个常见的困惑:AQS的子类代码看起来都很短,明明几十行代码,怎么能支撑起ReentrantLock这种重量级同步器?答案是模板方法模式。AQS把“能不能获得锁”“怎么计算释放后的状态”留给子类,自己把“获锁失败怎么入队”“线程如何阻塞”“释放锁后如何唤醒后继节点”这些通用逻辑做成固定模板。子类只管回答两个问题:能不能去抢锁、释放时状态怎么变。其他的,AQS全包了。
1.2 三个核心组件:state、队列、CAS
AQS内部有三个东西构成了它的一切:一个volatile修饰的int型状态变量state、一个CLH变体的双向等待队列、以及贯穿全程的CAS原子操作。
state是整个AQS的“语义核心”,它本身只是一个int值,具体代表什么完全由子类决定。ReentrantLock把它当重入计数,获取一次锁加1,释放一次减1,减到0才算真正锁释放;Semaphore把它当剩余许可数;CountDownLatch把它当剩余倒计数。这个设计允许同一个框架支撑完全不同的同步需求,这才是AQS能成为并发工具基座的真正原因。
CLH队列是AQS的“线程停车场”。所有拿不到锁的线程,都会被包装成一个Node节点,挂到双向链表尾部,然后通过LockSupport.park让自己睡过去。设计成双向链表而不是单向,是为了方便在队列中从前向后、从后向前双向查找,这个细节后面讲唤醒流程时会体现出来。
CAS则是整个框架的“原子地基”。修改state、往队列尾部挂新节点,全靠compareAndSet这类操作保证并发下的安全性。简单理解就是:改之前先看一眼当前值是不是预期值,是就更新,不是就重试,全程不依赖重量级锁。
1.3 为什么用模板模式而不是直接写死
如果你自己实现一把锁,最自然的做法是把锁的逻辑全部写在一个类里。AQS偏不这么干,它用模板方法模式把可变和不可变的部分做了切割。不可变的是排队、阻塞、唤醒、取消这些算法骨架,可变的是“状态值怎么判定”“状态值怎么更新”这些策略判断。
这种设计的直接好处是:一套排队阻塞框架可以服务无数种同步语义。你写一个新的同步器,只需要继承AQS,实现几个以try开头的方法,就能获得完整的线程排队、阻塞、唤醒、超时、中断处理能力。比如要做一个一次性闸门,实现tryAcquireShared和tryReleaseShared两个方法就够用了,排队和唤醒的部分AQS早就帮你写好了。
理解了这套设计哲学,再看源码就不会迷路。你在AQS里看到的所有复杂逻辑,都围绕着一个核心目标:让上层使用者只需要关心state的业务含义,其他并发控制细节框架全扛。
2. 核心机制详解:state怎么设计、队列怎么运转
2.1 state的语义由你定义,但可见性由volatile保证
state是AQS里唯一的共享可变状态,定义是private volatile int state。volatile这个关键字不是随便加的,它保证了state的修改对所有线程立即可见,同时禁止指令重排序。在实际运行中,一个线程把state从0改成1,另一个线程必须立刻能看到这个变化,否则锁的互斥性就崩了。
使用state时的几个常见操作是:getState()读取当前值、compareAndSetState(expect, update)用CAS的方式把期望值更新为新值、setState()直接赋值。注意setState只在持锁状态下使用,因为持锁状态下没有竞争,直接赋值就够了,不需要CAS。
我强烈建议你第一次写自定义同步器时,用一个简单的规则来约束自己:所有跨线程的计数操作,要么用compareAndSetState,要么在保证独占的前提下用setState。否则并发修改state会出现经典的丢失更新问题。有一个办法可以验证:写一个线程不安全的计数器,多线程去加,最终结果永远小于预期值。放到AQS里同理,多线程并发把state从0累加,不加CAS的话结果一定会丢。
2.2 等待队列的内部结构:双向链表和哑节点
AQS内部类Node是队列的节点,每个节点包含:thread(排队的线程)、waitStatus(节点状态)、prev和next(前后指针)。整个队列有一个head头节点和一个tail尾节点。
这里有一个让人迷惑的设计:head是一个哑节点,它不保存实际等待的线程,或者说它保存的是“当前持有锁的线程”或“最后一次成功获取锁的位置”。真正等待锁的线程,从head.next开始排。为什么这么设计?因为每个新线程都是通过CAS尾插法进入队列的,只要把tail指对了,整个链表就一致了,而head只会在持锁线程交接时被后移,不需要频繁CAS。
双向链表的意义,在唤醒后继节点时体现得特别明显。AQS在唤醒一个线程后,需要用前驱指针做反方向扫描,找到一个没有被取消的、状态合法的节点。如果只有单向链表,从tail到head找最近的有效节点就很费劲。还有一个细节:入队时新节点的next暂时为null,直到自己成为head前驱才会被填上,这就是为什么某些遍历场景需要从tail往前退。
2.3 waitStatus的五种状态:每个值都有明确含义
Node节点的waitStatus取值为int型,一共有五种含义:
| 状态值 | 名称 | 含义 |
|---|---|---|
| 1 | CANCELLED | 节点已取消,线程因超时或中断放弃了竞争 |
| -1 | SIGNAL | 当前节点释放或取消时,需要唤醒后继节点 |
| -2 | CONDITION | 节点在条件队列中等待 |
| -3 | PROPAGATE | 共享模式下,释放信号需要无条件向后传播 |
| 0 | 初始状态 | 新节点刚入队的默认状态 |
这些状态里最核心也最容易搞混的是SIGNAL。你要理解它不是“当前节点需要被唤醒”,而是“当前节点承诺,当自己释放锁时会唤醒后继节点”。换句话说,我把前驱节点的waitStatus设成SIGNAL,是在告诉前驱:你已经排到我前面了,等你完事叫我一嗓子。
为什么需要这个状态?因为park唤醒是有代价的,不能每一次自旋都去唤醒别人。AQS的做法是:每个节点入队后先检查前驱状态,如果前驱不是SIGNAL,就把前驱改成SIGNAL,然后再次检查自己是否仍要排队,确认需要后才会真正park自己。这个双重检查机制保证了“修改前驱状态”和“自己睡眠”之间不会出现永远等不到唤醒的竞态。
CANCELLED状态也很关键。线程因为超时或中断退出竞争时,会把自己的waitStatus标记为CANCELLED,但不会立刻从队列里移除。移除操作延迟到后续的取消清理中:要么是被唤醒时发现前驱被取消了,要么是后继节点在对接时发现它已死。这个延迟清理的设计,是为避免竞争条件——如果一个线程正在排队,另一个线程直接把它从链表里拽出来,两者之间很容易产生并发问题。
2.4 独占模式与共享模式:两套模板方法
AQS支持两种资源获取模式:独占模式和共享模式。独占模式对应tryAcquire和tryRelease,共享模式对应tryAcquireShared和tryReleaseShared。
独占模式很好理解,state只允许一个线程拥有,拿到锁就独占,其他线程一律排队。ReentrantLock的写锁就是独占模式。共享模式则允许多个线程同时“拥有”资源,读写锁的读锁就是共享模式——多个读线程可以同时持有读锁。
共享模式与独占模式最大的区别,体现在获取和释放的返回值语义上。独占模式的tryAcquire返回boolean:true表示获取成功,false表示失败去排队。共享模式的tryAcquireShared返回int:负数表示失败,0表示成功但没有剩余,正数表示成功且还有剩余资源可以继续分发给后续线程。释放时,共享模式的tryReleaseShared返回boolean:true表示资源可被其他人继续获取,false表示资源暂时仍被占用。
这个返回值语义决定了唤醒的传播方式。独占模式唤醒后继后,后继线程获锁,仍是一条单链传递。共享模式则不同,一个持有共享资源的线程释放后,可能同时唤醒多个排队的线程,这就是PROPAGATE状态存在的意义——释放信号需要向后传播,避免在多个共享线程并发释放时出现信号丢失。
3. 核心流程剖析:加锁、入队、释放、唤醒的完整链路
3.1 acquire加锁路径:一个请求的五步曲
用一句话概括acquire:每个线程来抢锁,先尝试一次,抢不到就排队,排队后循环检查自己是否到了队首,到了就再抢一次,抢到了就出队,中间被打断则补一次自我中断。完整流程可以拆成五步:
第一,调用子类实现的tryAcquire尝试立即获取资源。这一步是非阻塞的,成功就直接返回。失败进入第二步。
第二,调用addWaiter把当前线程包装成Node,通过CAS挂到等待队列的尾部。如果队列还没初始化,会先创建一个空的head节点,再让新节点排在其后。
第三,进入acquireQueued方法,在一个for循环里做“排队自旋”。每次循环检查前驱是否为head,如果是,就再试一次tryAcquire。成功则把自己设为新的head,代表自己持有了资源。失败则进入第四步。
第四,判断自己是否真的需要睡眠。通过shouldParkAfterFailedAcquire检查前驱状态:如果前驱已取消,就跳过它,向前寻找有效节点;如果前驱还不是SIGNAL,就把它改成SIGNAL,然后继续循环;如果前驱已经是SIGNAL,说明前驱会在释放时唤醒自己,这时才调用LockSupport.park真正睡眠。
第五,被唤醒后,继续循环,重复“检查前驱-尝试获锁-决定是否睡眠”这个循环,直到获锁成功。如果中途发生了中断,acquireQueued会记录中断标记,然后在成功获锁后,通过selfInterrupt补一次中断。这是AQS的一个巧妙设计:获取锁的过程中,线程不会因为中断而放弃排队,中断只是被记录,真正拿到锁后才补上。
3.2 入队后的“假自旋”:为什么先转圈再睡眠
AQS的队列等待不完全是传统意义上的自旋锁。它采用了一种“有限自旋+睡眠”的组合策略。线程入队后并不会立刻park,而是先以循环的方式检查自己是否成为了head的后继。如果前驱刚好是head,而且锁刚好被释放,这个线程有机会在不经历线程切换的情况下直接拿到锁。
线程切换和park唤醒的开销在几百纳秒到几微秒之间,而一次CAS操作可能只有几十纳秒。如果每次抢锁失败都立刻睡眠,成本反而不划算。AQS的折中方案是:在park前先“转几圈”,每次循环都有机会直接获锁。只有连续失败多次,确认短期内抢不到,才真正睡过去。
一个常见的理解误区是:acquireQueued在for循环里每次循环都会盲试一次tryAcquire,这样会不会造成大量无意义的CPU消耗?实际上不会。因为一旦前驱状态被置为SIGNAL,当前线程就会park,循环就停住了。只有被unpark唤醒、或者前驱是head时,循环才会重新活跃。真正空转的那一刻,只发生在“刚好轮到我”的边缘。——这是AQS性能的关键所在:它把自旋控制在极短窗口内,把长时间的等待交给操作系统调度。
3.3 release释放路径:状态回置与后继唤醒
释放路径相对简单,核心方法是release,流程是:先把state改回释放后的值,再找到合适的后继节点,将其唤醒。
第一步,调用子类的tryRelease将state递减或清零。对可重入锁来说,每次unlock只把state减1,只有当state减成0时,才认为锁真正释放,返回true。这一步很重要:如果state还没归零,说明锁仍被当前线程持有,不需要去唤醒任何人。
第二步,当真释放时,AQS拿到当前head节点,检查它的waitStatus。如果小于0(比如SIGNAL),说明有后继线程在等待唤醒,调用unparkSuccessor处理后继。
unparkSuccessor里最反直觉的一段代码,是从tail向前扫描,找到离head最近的有效节点。为什么不是直接head.next?因为入队的CAS尾插法不保证next指针总是一致:节点A刚入队时,它的next还是null,但node已经通过CAS挂到了tail上。如果此刻head恰好要释放锁,用head.next去找后继,很可能找到的就是尚未设置next的A,或者找到的是一个已取消的节点。从tail倒着找,能稳定地定位到最靠前且仍未取消的线程。这是我看源码时印象最深的一个设计决策:它宁可牺牲一点扫描性能,也要保证在弱一致链表上做操作时的正确性。
唤醒后继节点后,后继线程醒来继续跑acquireQueued循环,尝试获锁。获锁成功后它会把自己设为新的head,此时旧head的thread被置null,成为新的哑节点。整个队列就是这样通过“头节点后移”完成一次锁交接。
3.4 条件变量的隐藏舞台:ConditionObject
很多人读AQS源码时忽略后半部分:ConditionObject。它打开了另一条队列链:条件等待队列。正是这个对象,实现了await/signal的能力,支撑起ReentrantLock的Condition机制。
条件队列的核心思想是:持锁的线程因为某个业务条件不满足,主动让出锁并进入睡眠;等到条件满足时,被唤醒并重新抢占锁。这个流程通过两个队列协作完成:同步队列(AQS的main queue)和条件队列(ConditionObject自己维护的单向链表)。
调用condition.await()时,线程会被包装成一个CONDITION状态的节点挂到条件队列尾部,然后释放当前持有的锁,最后park自己。调用condition.signal()时,AQS会把条件队列头部的节点转移到同步队列的尾部,并尝试唤醒它。只是转移,不是直接获得锁。被转移的线程在同步队列里还要和普通阻塞的线程一样排队抢锁。
所以,使用Condition时为什么必须在锁保护范围内进行?因为条件队列本身没有并发保护,你要保证“检查条件-await挂起”这两个动作与“修改条件-signal唤醒”这两个动作是互斥的。否则会出现经典的“信号丢失”:线程先检查了条件不满足,正准备await,另一个线程已经把条件修好了并signal了,然后这个signal唤醒了一个还没入队的人,条件就永远丢掉了。这个坑在业务代码里极难排查,症状往往是线程悄悄全部睡死,没有任何报错。
4. 动手实操:基于AQS实现一个共享限量闸门
4.1 需求场景与设计思路
看源码十遍,不如自己写一个AQS子类。我分享一个我自己做过的小工具:共享限量闸门。
业务场景是:某个接口限流,同一时间最多允许N个线程进入,超过的线程排队等待,前面的线程处理完释放位置,后面的线程才能进来。语义上等价于“共享资源数为N,每个线程占一个,用完归还”。
这个工具实现起来非常简洁,因为AQS把最复杂的排队和唤醒都封装好了。我只需要实现共享模式的两个方法:tryAcquireShared和tryReleaseShared。先是tryAcquireShared:读取当前state,如果state大于0,就用CAS尝试减1,成功则返回剩余值;如果state已经等于0,直接返回负数,表示无资源可用,线程会被AQS带去排队。再是tryReleaseShared:用一个for循环做CAS加1,多线程并发释放时必须保证不丢失计数。
4.2 完整实现代码
核心同步器部分很简单,代码如下:
import java.util.concurrent.locks.AbstractQueuedSynchronizer; public class SharedGate { private static final class Sync extends AbstractQueuedSynchronizer { Sync(int permits) { setState(permits); } @Override protected int tryAcquireShared(int acquires) { for (;;) { int available = getState(); int remaining = available - acquires; if (remaining < 0 || compareAndSetState(available, remaining)) { return remaining; } } } @Override protected boolean tryReleaseShared(int releases) { for (;;) { int current = getState(); int next = current + releases; if (compareAndSetState(current, next)) { return true; } } } } private final Sync sync; public SharedGate(int permits) { if (permits <= 0) { throw new IllegalArgumentException("permits must be positive"); } sync = new Sync(permits); } public void enter() { sync.acquireShared(1); } public void leave() { sync.releaseShared(1); } }注意tryAcquireShared里的for循环是必要的。多个线程同时尝试从state里扣减,CAS失败就得重新读state重试,这个“读-算-写”循环是CAS编程的标准姿势。如果图省事只用getState判断而不加CAS,就会把多个线程的同时进入放进来,限流失效。
4.3 测试与运行验证
写个测试验证它是否真的限住了。场景是:创建许可数为2的闸门,启动10个线程同时执行任务,每个任务打印“进入-时间戳-线程名”,睡眠1秒后打印“离开”,再释放许可。
预期行为是:任何时刻最多只有两个线程打印“进入”,其余线程全部阻塞在enter方法里。运行结果确实如此:第一批两个线程进入,各自停留1秒后释放,第二批两个线程才被唤醒进入,以此类推,总共分成5批完成,每批都是两个线程并行。
这个结果能直接证明AQS共享模式的排队策略是有效的:state归零后,新的获取请求没有走快速通道,而是被送进了等待队列,等前面线程释放后,才被依次唤醒进来。
4.4 用jstack观察真实的排队现场
运行时用jps找到进程号,再用jstack输出线程转储,是验证AQS行为最直观的手段。被阻塞在线程栈中,能看到如下特征:
- 线程状态是WAITING (parking),说明它被LockSupport.park挂起。
- 栈顶栈帧会显示
AbstractQueuedSynchronizer$ConditionObject或AbstractQueuedSynchronizer.acquireSharedInterruptibly这样的调用链。 - 栈底部能看到你的业务方法,比如
SharedGate.enter和Test.main。
如果同时观察两个被阻塞的线程,它们在jstack里的栈位置几乎相同,只是栈帧细节不同。这说明它们都被同一套AQS模板方法管理着,排列在同一条队列里。这套观测方法在排查生产环境的“线程挂了”问题同样适用:看到线程停在AQS里的acquire或await方法,基本就能锁定是在等待某把锁或某个条件。接下来用jstack的锁信息,看它是等哪把锁的哪个锁持有者,就能往下追根了。
5. 高频问题排查与实战避坑清单
5.1 面试必追的AQS高频问题
| 追问点 | 答案要点 |
|---|---|
| 为什么用AQS而不用synchronized | synchronized无法实现非块结构的锁,不可中断,无法超时,只能用JVM内置的监视器机制。AQS提供了可中断、可超时、可公平/非公平切换、可共享/独占扩展的完整能力 |
| 重入是怎么实现的 | 子类在tryAcquire里判断持有锁的线程是否是当前线程,是则state加1,返回true。释放时同样递减,减到0才真正释放 |
| 公平锁和非公平锁代码差异在哪 | 公平锁的tryAcquire先调用hasQueuedPredecessors判断队列里是否有人排在自己前面,有人就直接返回false去排队;非公平锁直接CAS抢state,抢不到才排队 |
| 中断处理为什么要分两种 | acquireInterruptibly遇到中断立即抛异常放弃排队;acquire遇到中断后继续排队,拿到锁后补一次interrupt标记。这是“响应中断”和“延迟响应中断”两种策略 |
| state为什么用int不用long | 并发包设计之初的取舍。int在32位平台上是原子写入的,long在部分旧平台上需要两次写,有撕裂风险。业务几乎用不到百亿级以上的计数 |
5.2 实际开发中容易踩的坑
第一个坑,把state的语义定义错了。state本身是无意义的int,语义由你的tryAcquire方法决定。有人在实现一个“最多允许N个线程”的共享工具时,把state初始化为0,然后试图通过增1来实现计数,结果逻辑全乱了,因为AQS框架内部的判断是基于“state是否大于0”来处理排队和唤醒的。正确姿势是:尽量让state的语义与AQS模板的预期一致,比如共享模式下state越大代表可用资源越多。
第二个坑,中断处理方式选择错误。业务代码里如果调用的是lock.lock(),线程被中断时不会抛异常,而是继续等待锁,直到拿到锁后中断标记才恢复。很多人不明白为什么“中断了还在等”,就是因为默认实现选择的是“延迟响应”。希望在等待锁期间可被中断,正确做法是用lock.lockInterruptibly(),这个API内部走的是acquireInterruptibly路径,中断立即抛InterruptedException。
第三个坑,条件变量的signal丢失。这是并发编程里的经典死锁井。我用Condition实现一个生产者-消费者时,第一次就踩了:生产者在没持锁的状态下调用signal,导致唤醒动作和条件修改不在同一个临界区里,消费者线程全部睡着后再也没有醒来。排查到最后,发现signal虽然执行了,但对应线程还没调用await,信号先到,等待后到,信号丢在时间里了。后来只在持锁状态下调用signal,问题立刻消失。
5.3 生产环境排查工具与定位手段
碰到线上线程卡死,第一步不要猜,用jstack把所有线程的转储拉出来,然后按“阻塞点”分组。看线程的栈帧停在哪一行,就能判断它在等什么:如果等的是AQS的acquire开头的方法,它是在等同步器资源;如果等的是ConditionObject的await方法,它是在等条件满足。再配合jstack顶部的“Locked ownable synchronizers”段落,能看到当前线程持有哪些锁——这个段落里会直接列出线程持有的AQS同步器实例和锁级别状态,用它反向定位锁的持有者非常有效。
如果同时有多个线程互相等待,而各自的“持有锁”历史串联起来刚好成环,基本就能断定是死锁。JDK自带的jconsole里有一个“死锁检测”按钮,它会自动做环检测并高亮死锁线程,比肉眼快得多。另一个实用技巧是:把jstack连续打两次,间隔3秒,对比两次堆栈中某个线程的状态是否在变化。如果每个线程的状态没有任何变化,说明它们都卡在等待点上,是典型的“等待型死锁”。
排查线程问题时一个容易忽略的点是:只看线程栈不够,还要看它由谁唤醒。AQS的唤醒链是“head向后传递”的,如果某个节点的前驱处于CANCELLED状态,后面的线程唤醒时可能会有一段停顿。遇到“线程集体睡着但没有任何报错”的现象,优先怀疑条件变量的signal丢失,其次怀疑错误地调用了park/unpark。前者在业务代码里很隐蔽,后者在自研框架里很常见。
5.4 从读源码到写框架:AQS理解的三个层次
我在带团队时总结过,读懂AQS有三个层次。第一层是能看懂源码,能讲清acquire和release的执行顺序;第二层是能独立实现一个同步工具,并且能在生产场景压测通过;第三层是能根据业务需求设计新的同步原语,比如超时共享锁、可降级读写锁、带优先级的排队锁。大多数人卡在第一层到第二层之间,因为源码看懂了,但手上没有写过一个真正的子类。
动手写一个自定义同步器时,我建议从最简单的互斥锁开始:只有一个state,0表示空闲,1表示被占用。跑通acquire和release后,再加重入支持(判断owner线程再累加state),再加公平性(检查队列里是否有人排前面),再加超时版本(用tryAcquireNanos)。每加一个特性,你对模板方法的理解就深一层。这个循序渐进的过程,比直接啃ReentrantLock的完整源码高效得多。
回到开头那句话:AQS难,难在它把并发控制的所有复杂性收拢在了一个看似简单的框架里。真正读完它的设计,再回头看那些基于它构建的高级工具,你会忽然理解为什么整个并发编程体系会如此设计——因为“有没有资格访问某资源”的判定可以千变万化,但“没有资格时如何优雅地等待”这件事,其实只有一套标准答案。我自己的习惯是每次做一个新的并发工具前,都先在纸上画出它的state状态流,然后问自己三个问题:什么时候把state改大、什么时候改小、改到什么程度时唤醒等待的人。想清楚这三件事,剩下的,AQS都帮你干完了。