如果你跟我一样,最初接触AbstractQueuedSynchronizer时只记住了"它是一个队列同步器",那这篇文章就是写给你的。我在某个高并发项目的压测阶段被ReentrantLock的源码逼着去啃 AQS,啃完之后最大的感触是:Java 并发包里几乎所有同步工具——ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock——都只是 AQS 这同一个骨架上的不同器官。理解了 AQS,你就等于拿到了并发工具类的"源码级地图"。
这篇文章我会从设计者的视角把 AQS 拆开:为什么用一个int表示同步状态、为什么等待队列选双向链表、模板方法怎么被复用。中间会穿插源码走读和一个自定义同步器的完整落地,最后分享我接入 AQS 时踩过的几个坑。内容偏原理和实操结合,适合有一定 Java 并发基础、想彻底搞懂锁机制的开发者,也适合准备面试想系统梳理 AQS 的人。
1. 为什么每个并发工具背后都藏着同一个AQS
1.1 从ReentrantLock的一个反直觉现象说起
先抛个现象:ReentrantLock里的lock()和unlock(),底层其实调的都不是"锁对象"自己的方法,而是一个叫Sync的内部类方法,这个Sync又继承了 AQS。也就是说,你每天在业务代码里写的lock.lock(),真正的执行者是一个 AQS 实例。
我最初不理解这种"套娃"设计的意义,直到我自己用synchronized和ReentrantLock做对比测试。synchronized加锁解锁是 JVM 直接管的,你没法干预"获取锁失败时干什么";而ReentrantLock却能实现"获取锁最多等 3 秒""获取锁期间响应中断""获取不到锁就去做别的事"这些灵活行为。这些行为的底层支撑,恰恰就是 AQS 提供的模板流程加上子类自定义的策略钩子。
所以 AQS 解决的核心问题,用大白话说就是:把"线程抢锁、抢不到排队、排队中阻塞唤醒、释放锁后通知下一个"这套通用流程固化下来,把"什么条件下算抢锁成功、什么条件下算释放成功"这类具体判断留给子类。通用骨架做好,同步器作者只需要关心业务规则,不需要重复造轮子。
1.2 一个int如何撑起加锁的底层设计思路
AQS 里最核心的成员变量之一,是private volatile int state。很多人以为锁的状态应该是一个复杂对象,比如记录持有者线程、重入次数、等待队列,但设计者只用了一个int。
原因在于:所有同步器本质上都在回答一个问题——"当前可用的资源数量是多少"。这个state在不同的同步器里有完全不同的语义:
| 同步器 | state 的语义 | 获取资源操作 | 释放资源操作 |
|---|---|---|---|
| ReentrantLock | 重入次数(0 表示无锁) | CAS 从 0 改成 1 | 每次减 1 |
| Semaphore | 剩余许可数 | 减 1(不能为负) | 加 1 |
| CountDownLatch | 剩余待计数 | 不获取,只等待 | 减 1 |
| ReentrantReadWriteLock | 高 16 位读锁计数,低 16 位写锁计数 | 按位运算 | 按位运算 |
我一开始犯过一个错误认知:以为state改变必须靠synchronized保护。实际上 AQS 用的是 CAS,因为state是volatile的,CAS 操作能保证"检查-修改"的原子性。这在高并发抢锁场景下非常关键——上千个线程同时执行lock(),大家都在对同一个state做 CAS,谁成功了谁就拿到锁,失败的线程才进入后面的排队流程。
这种"先尝试再排队"的设计,其实是乐观锁思想。大部分情况下锁竞争并不激烈,CAS 一次就能成功,不需要把线程挂起,性能开销极小。只有当 CAS 失败(资源确实被占用)时,才会走阻塞流程。
2. 同步状态state与CLH等待队列的设计哲学
2.1 state的可见性保证与三组方法
既然state被多个线程同时读写,光声明volatile还不够,AQS 还提供了三组访问方法:
getState():读取同步状态setState(int):直接写入状态,通常用于非竞争场景compareAndSetState(int expect, int update):CAS 更新状态
这三组方法中,compareAndSetState是并发修改的核心。举个例子,ReentrantLock的非公平锁获取逻辑大概是:先 CAS 试探state从 0 改 1,成功就设置"当前线程为锁持有者";失败才走完整流程。
这里有个设计细节很容易被忽略:setState是直接写,为什么要暴露一个普通方法?因为它服务于"已经持有锁的线程"在释放锁时减少重入次数。比如可重入锁中线程多次lock(),state变成 3,每次unlock()就执行setState(state - 1),此时线程已经持有锁,不存在竞争,用 CAS 反而多余。
而getState()之所以需要 volatile 保证,是因为等待队列里的线程可能被唤醒后要读取最新状态。比如唤醒线程已经释放锁并执行setState(0),被唤醒的线程必须以最新值做 CAS 抢锁,如果读到旧值,整个同步器就乱套了。
2.2 CLH队列的变体:为什么是双向链表而不是别的结构
AQS 等待队列是 CLH(Craig, Landin, Hagersten)锁队列的一个变体。原始 CLH 队列是单向链表,每个节点自旋检查前驱节点的状态;AQS 改成了双向链表,节点状态也更复杂。
结构上就是一个Node双向链表:
head:指向头节点,头节点对应的线程是当前持有资源的线程(或已释放资源的哨兵节点)tail:指向尾节点,新节点通过 CAS 追加到尾部- Node 中维护
prev和next指针,以及thread(该节点对应的线程)和waitStatus(节点状态)
为什么要用双向链表?因为在线程被唤醒后,需要从后往前找可唤醒的节点,而且取消排队(节点状态变为 CANCELLED)时要把自己从链表中摘除,这都需要prev指针。单向链表做不到高效的前驱回溯。
实际上 AQS 还有一个隐藏优化:新线程入队不一定立刻阻塞。acquireQueued方法里有个自旋阶段,当前线程入队后会先尝试获取资源,只有获取失败且前驱节点的状态是SIGNAL(表示前驱有义务唤醒自己)时才真正调用LockSupport.park挂起。这减少了一次"线程挂起再唤醒"的昂贵开销。
2.3 Node节点上的四种状态到底在表达什么
Node 的waitStatus是另一个容易被忽略的细节。它告诉周围节点"我这个节点接下来想干什么"。常见值有:
| 状态值 | 含义 | 触发场景 |
|---|---|---|
| CANCELLED (1) | 线程已放弃竞争 | 超时或中断 |
| SIGNAL (-1) | 当前节点的后继需要被唤醒 | 释放资源时要 unpark 后继 |
| CONDITION (-2) | 节点在 Condition 队列中等待 | 调用await() |
| PROPAGATE (-3) | 共享锁场景下唤醒要向后传播 | Semaphore释放许可时 |
SIGNAL 状态是理解整个阻塞唤醒机制的关键。新入队的节点在阻塞之前,会把前驱节点的waitStatus改成 SIGNAL,意思是"前驱释放资源时记得唤醒我"。这是一个约定的信号传递机制:每个节点都通过检查前驱的状态来决定自己要不要阻塞。
我在看这段源码时最大的困惑是:为什么不是检查后继节点状态?后来想通了——唤醒动作发生在"释放资源"的线程上,它只关心自己的后继,而"是否需要阻塞"的决策发生在"获取资源"的线程上,它只关心自己的前驱。职责分离让每一环都简单清晰。
3. 模板方法模式:排他锁与共享锁共用一套骨架的巧妙之处
3.1 模板加钩子:AQS预留的五个口子
整个 AQS 的骨架是acquire(int arg)和release(int arg),还有共享版的acquireShared和releaseShared。骨架流程如下:
获取:尝试获取 -> 失败则入队 -> 入队后循环尝试或阻塞 -> 获取成功后返回 释放:尝试释放 -> 成功后唤醒后继节点骨架本身是"固定演出流程",但"尝试获取"和"尝试释放"的具体判断条件完全开放给子类。AQS 暴露了五个可覆写的钩子方法:
tryAcquire(int arg):排他方式尝试获取,返回 true 表示成功tryRelease(int arg):排他方式尝试释放tryAcquireShared(int arg):共享方式尝试获取,返回负数表示失败,0 表示成功但无剩余,正数表示成功且有剩余tryReleaseShared(int arg):共享方式尝试释放isHeldExclusively():当前线程是否独占资源,主要用于 Condition
这套设计的精妙之处在于:子类只需要实现这几个钩子,完全不用碰队列操作、线程阻塞唤醒这些复杂逻辑。翻一下ReentrantLock的Sync子类,你会发现它只实现了tryAcquire和tryRelease,其他全是 AQS 自带。
我还记得第一次在自定义同步器里只实现两个钩子就能工作时的惊讶感。后来我用一个比喻理解它:AQS 是流水线,钩子是流水线上的质检员,不同工厂只需要换质检员,流水线本身不用动。
3.2 公平锁与非公平锁的差异源自一个钩子
ReentrantLock有公平锁和非公平锁两个版本,两者的代码差异比很多人想象的小得多。它们甚至共用同一个tryAcquire核心逻辑,唯一的区别在于:
- 非公平锁:
lock()进来先直接 CAS 抢一次,抢不到再走排队 - 公平锁:任何时候都先检查队列里有没有排队的节点,有就老老实实排队
用源码语言来说,公平锁的tryAcquire里多了一个!hasQueuedPredecessors()判断。这个判断会查看队列中当前节点之前是否有其他等待者——如果head不等于tail且当前线程不是head的直后继,说明有人比自己先来,当前线程必须放弃这次尝试。
这个设计带来的结果是:非公平锁吞吐量更高(减少线程挂起唤醒带来的上下文切换),但可能出现"插队"现象;公平锁按顺序获取,但代价是更多线程进入阻塞再唤醒的流程。具体项目里怎么选,我后面会详细说。
3.3 共享模式:x个许可怎么同时放行
排他锁和共享锁在 AQS 里走的是两套不同骨架,但设计思路一脉相承。共享模式的关键方法是doAcquireShared,它的返回值有三类:负数表示失败,0 表示成功但没有剩余资源,正数表示成功后仍有剩余资源。
拿Semaphore举例:初始化 5 个许可,4 个线程同时acquire()都成功,第 5 个线程的第 6 次尝试返回负数进入等待队列。每次release()会把许可数加 1,并触发共享释放的传播唤醒——不仅唤醒后继一个节点,如果资源还有富余,后继被唤醒后再尝试获取成功,还会继续唤醒它的后继。
这种"链式唤醒"是共享模式和排他模式最大的区别。排他锁释放时unparkSuccessor只唤醒一个后继;共享锁释放时,只要资源没有被耗尽,唤醒会像多米诺骨牌一样继续传递。我用CountDownLatch验证过一次:一个闸门计数归零的瞬间,所有等待线程几乎同时被唤醒,而不是一个个排队苏醒。
4. 从acquire到release的完整生命周期源码走读
4.1 获取:tryAcquire失败后addWaiter如何入队
这一节我带你走一遍ReentrantLock加锁的完整链路。调用lock()实际进入的是acquire(1):
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) { selfInterrupt(); } }分解一下这行代码:
- 第一步
tryAcquire(arg):子类实现的钩子尝试获取资源。成功就直接返回,性能最优路径结束。 - 第二步
addWaiter(Node.EXCLUSIVE):获取失败,创建一个排他模式的 Node,通过 CAS 追加到队列尾部。尾部竞争由 CAS 保证原子性,多个线程同时入队不会出错。 - 第三步
acquireQueued:入队后不断尝试获取资源,或判断是否需要阻塞。 - 第四步
selfInterrupt():acquireQueued返回 true 表示"等待过程中被中断过",这里补上中断标记。
注意addWaiter有一个细节:如果 tail 不为空,先尝试一次快速入队(CAS),失败才走enq的自旋入队流程。这又是一种乐观优化——大部分情况 CAS 一次就成功,不需要进入自旋循环。
4.2 等待:shouldParkAfterFailedAcquire的信号机制
acquireQueued中的核心循环逻辑,是for (;;)自旋里做三件事:
final boolean acquireQueued(final Node node, int arg) { boolean failed = true; try { boolean interrupted = false; for (;;) { final Node p = node.predecessor(); if (p == head && tryAcquire(arg)) { setHead(node); p.next = null; failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }这里最关键的一点:当前节点的前驱是head时,才会再次尝试tryAcquire。因为 head 代表"资源刚被释放的线程",它后面的节点是最有资格抢资源的。这种"只有 head 的下一代才尝试"的设计,保证了队列整体有序。
shouldParkAfterFailedAcquire的逻辑是:
- 前驱节点状态是
SIGNAL,说明前驱承诺释放时会唤醒自己,可以安心阻塞 - 前驱状态是
CANCELLED,跳过它,继续向前找有效节点,把链表中的取消节点摘除 - 前驱状态是 0,用 CAS 把前驱状态改成
SIGNAL,下次循环再阻塞
这个设计我理解了很久才明白它的精妙之处:线程不是一入队就立刻挂起,而是至少自旋检查两轮——第一轮把前驱状态改成 SIGNAL,第二轮才真正阻塞。给"刚释放资源的瞬间"预留了一个时间窗口,很多竞争在这个窗口期内就直接结束了,避免了线程的挂起和唤醒开销。
4.3 释放:unparkSuccessor怎么精确唤醒后继
释放路径比获取路径简单。release(1)先调用tryRelease,成功后获取当前 head,调用unparkSuccessor:
private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; if (s == null || s.waitStatus > 0) { s = null; for (Node t = tail; t != null && t != node; t = t.prev) if (t.waitStatus <= 0) s = t; } if (s != null) LockSupport.unpark(s.thread); }我重点讲两个设计决策:
为什么要把 head 的 waitStatus 先改回 0?因为waitStatus为 SIGNAL 表示"需要唤醒后继",一旦唤醒动作确实发生了,这个标记就没用了。如果不重置,后面的排队节点会误以为还需要再次唤醒。
为什么后继节点为空或已取消时要从 tail 往前遍历?这是 AQS 源码里最容易被忽略的边界处理。next指针可能因为并发入队而暂时为空(CAS 入队是先设置tail,再设置pre/next关联,中间有个空隙窗口),所以不能依赖next找后继,必须从 tail 向前找到第一个非取消节点。这个"从后往前"的回溯逻辑,是并发场景下保证不丢唤醒的关键。
5. 手写一个自定义同步器:把AQS模板用起来
5.1 需求:做一个限流闸门
理论说了这么多,不落一次地不算真懂。我来做一个自定义同步器:一个只允许 N 个线程同时进入的闸门,类似"最多 N 个并发"的限流器。
这里用共享模式更合适。初始 state = N,每个线程进入时tryAcquireShared将 state 减 1,成功表示获得通行证;离开时tryReleaseShared将 state 加 1。
为什么不直接用Semaphore?一是练习 AQS 的钩子实现,二是明确这样一个事实:Semaphore本身也不过是一个 AQS 钩子实现而已。
5.2 源码实现:仅用tryAcquireShared和tryReleaseShared
继承 AQS,实现共享模式的两个钩子:
public class SimpleGate extends AbstractQueuedSynchronizer { public SimpleGate(int permits) { setState(permits); } @Override protected int tryAcquireShared(int arg) { for (;;) { int current = getState(); int remain = current - arg; if (remain < 0 || compareAndSetState(current, remain)) { return remain; } } } @Override protected boolean tryReleaseShared(int arg) { for (;;) { int current = getState(); int next = current + arg; if (compareAndSetState(current, next)) { return true; } } } public void enter() { acquireShared(1); } public void exit() { releaseShared(1); } }注意tryAcquireShared的循环写法。先读current,计算remain,如果remain < 0直接返回负数表示失败,否则 CAS 尝试更新。CAS 失败说明有其他线程同时修改了 state,自旋重试。这比 synchronized 保护整个方法的写法吞吐量高得多。
enter()和exit()是同步器对外暴露的业务方法,其实内部就是调用 AQS 的acquireShared/releaseShared。我没有覆写isHeldExclusively,因为共享模式不需要独占判断。
5.3 测试:60个线程抢10个许可证
写个简单的并发测试验证:
public static void main(String[] args) throws Exception { SimpleGate gate = new SimpleGate(10); AtomicInteger inside = new AtomicInteger(0); AtomicInteger maxInside = new AtomicInteger(0); for (int i = 0; i < 60; i++) { new Thread(() -> { gate.enter(); try { int current = inside.incrementAndGet(); // 统计最大同时在线人数 while (true) { int observed = maxInside.get(); if (current <= observed || maxInside.compareAndSet(observed, current)) { break; } } Thread.sleep(50); // 模拟业务处理 } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { inside.decrementAndGet(); gate.exit(); } }).start(); } Thread.sleep(3000); System.out.println("最大同时在线数: " + maxInside.get()); }实测最大在线数严格等于 10。这说明 state 的 CAS 计数逻辑完全正确,AQS 的共享队列也确实做到了"资源不够就排队"的约束。你可以把它换成Semaphore试试,行为几乎一致——因为Semaphore内部就是这么实现的。
6. 接入AQS后,我遇到过的坑和排查方法
6.1 中断状态被吞掉导致的线程永不停止
我第一次写自定义同步器时,犯了经典错误:acquireShared返回后没有检查中断状态。业务代码调Thread.interrupt()想终止一个正在抢闸门的线程,结果线程完全无视中断,继续阻塞。这在异步场景下非常危险。
排查过程:我先加日志打印线程状态,发现线程始终是 WAITING 状态。随后翻了acquireSharedInterruptibly的源码,对比才发现——普通acquireShared版本确实会忽略中断,只有Interruptibly版本的才会抛出InterruptedException。想要中断响应,必须显式调acquireSharedInterruptibly。
修法很简单:enter()里改用带Interruptibly的方法。但这段排查经历让我明白一个道理:AQS 把"中断是否被处理"这个决策交给了同步器作者,不同的 API 方法语义差别很大,选中之前要看清楚。
6.2 公平模式比非公平模式慢一倍的真实数据
某次我用自定义锁做过一个基准测试:8 个线程对同一个计数器各累加 100 万次。非公平模式耗时约 120ms,公平模式耗时约 430ms。差距非常大,而且线程数越多差距越明显。
原因很好理解:公平模式下每个线程释放锁后必须立即挂起,下一个线程被唤醒后才能执行。每次锁交接都要经历"挂起→唤醒"的完整上下文切换。非公平模式下,释放锁的线程可能立刻又重新抢到锁,省去了挂起唤醒的过程。
实际项目里的取舍:如果你有严格的先来后到需求,选公平锁;如果只是保护临界区,非公平锁的性能优势明显。绝大多数业务场景其实不需要公平性,默认用非公平锁就够了。
6.3 只想看有没有人排队时的hasQueuedPredecessors陷阱
hasQueuedPredecessors是一个高频面试点,也是一个隐蔽的坑。它的返回含义是"是否有线程排队早于当前线程"。如果队列是空的,返回 false;如果队列非空且队首不是当前线程,返回 true。
但要注意:它判断的是"在当前时刻"的队列状态,存在竞态窗口。比如线程 A 刚释放锁,线程 B 正在入队的过程中,hasQueuedPredecessors可能返回 false,导致公平锁出现短暂的"伪插队"。这不是 bug,而是并发系统的固有窗口,了解这一点能在排查"为什么公平锁还会插队"的问题时少走弯路。
我用这个现象解释过一个问题:某同学起了公平锁的ReentrantLock,却发现自己一个线程连续两次拿到锁,其他线程却长期得不到执行。我说这本质上是公平锁只保证"排队顺序",不保证"不插队窗口",加上线程调度顺序不可控,看起来不公正是正常的。
6.4 死锁排查时jstack里的AQS队列意味着什么
线上遇到锁等待超时,最常用的排查手段是jstack dump线程快照。AQS 相关的线程状态会显示在java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject或直接显示" parked"等待。
有一次排查一个"服务卡死"问题,线程快照里几个线程全部停在LockSupport.park,而且等待的锁对象是同一个 AQS 子类。我第一反应是死锁。后来看jstack里的持有关系,发现根本不是死锁,而是某个线程持有锁后去调一个外部接口,外部接口超时长达 120 秒,导致所有排队线程全部阻塞。
这个案例给我的教训是:看到 AQS 队列在park不等于死锁,更常见的是"持锁线程自己有问题"。要判断是不是真死锁,核心看锁的持有者线程是否在等待另一个锁。
6.5 Condition里的signal和signalAll选择
用Condition时有个经典问题:signal()只唤醒一个等待线程,signalAll()唤醒所有。如果业务语义是"释放一个资源",用signal()即可;但如果是"状态翻转"类事件,比如缓冲区由空变非空,必须用signalAll()。
我踩过的坑是这样的:一个生产者消费者模型里,两个消费者都在await(),生产者放入一个任务后调signal()。结果唤醒的恰好是那个"不该消费"的类型,任务白白等了一个超时周期。换成signalAll()后,所有等待线程都醒来检查条件,不满足条件的重新挂起,虽然多了一些空转,但不会漏事件。
AQS 在ConditionObject的实现里,被signal的节点会从 Condition 等待队列转移到 AQS 同步队列。这个转移过程同样是 CAS 操作,可以看成一个"队列之间的平移"。理解这一点,你对 I/O 多路复用里"从等待队列到就绪队列"的模型也会更有画面感。
收尾:一页纸记住AQS
最后分享一个我整理给自己团队的口诀式总结,也当作这篇文章的收尾:
- 一个状态:
volatile int state,所有同步语义都压在这个 int 上 - 一个队列:CLH 变体双向链表,排队、取消、唤醒都靠它
- 三个模板方法:
acquire/release/acquireShared/releaseShared,骨架由 AQS 写死 - 五个钩子:
tryAcquire/tryRelease/tryAcquireShared/tryReleaseShared/isHeldExclusively,规则由子类实现 - 两个队列:AQS 同步队列和 Condition 等待队列,
signal做节点平移
我在实际项目里最深的体会有两条。一是读 AQS 源码不要从头读到尾,先跑通"正常获取正常释放"的最短路,再加并发、加中断、加超时这些分支条件去理解边界。二是 AQS 的设计思路可以反哺业务系统——很多"资源配额""排队限流"的中间件模型,本质上就是一套状态变量加等待队列的管理,只不过没有 AQS 帮你处理并发细节而已。把 AQS 吃透之后,再去读ReentrantReadWriteLock、StampedLock甚至ForkJoinPool里的同步逻辑,都会顺畅很多。