先从一个场景说起:你写了一个接口,里面用了ReentrantLock,某天线上突然出现线程阻塞,dump 一看,一堆线程卡在AbstractQueuedSynchronizer.acquireQueued,你大概知道是锁竞争,但如果此时面试官追问一句“AQS 内部到底怎么排队的?公平锁和非公平锁差在哪?Condition 挂起线程时节点怎么转移?”——要是当时没完整啃过 AQS,多半会卡壳。
这大概是 Java 并发包里最值得花时间去啃的一个类了。AQS,全称AbstractQueuedSynchronizer,是ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock这些同步器共同的底层地基。我当初啃它的时候,光是“CLH 队列变种”“state 状态位”“双向隐式队列”这几个词就绕了很久,等真正梳理清楚后,回头再看那堆并发工具,基本就是一层窗户纸。这篇文章不打算做源码逐行翻译,而是把我理解 AQS 的完整思路讲清楚,从设计动机到队列结构,从加锁/解锁链路到 Condition 机制,最后带一个自定义同步器的实战。
1. AQS 到底在解决什么问题:锁竞争与排队等待的本质
很多人初看 AQS 源码会一头雾水,因为没有先想明白它解决的核心问题。AQS 要回答的是并发编程里最朴素的一个问题:多个线程竞争同一个资源时,被拒绝的线程应该怎么办。
1.1 从 synchronized 到显式锁:为什么还要再造一个锁
synchronized从 JDK 1.6 开始引入了偏向锁、轻量级锁、重量级锁的升级路径,在大多数场景下性能已经足够好。但它的短板也很明显:不响应中断、不支持超时、非公平、不能像读写锁那样分离读和写。synchronized是 JVM 内部实现的,开发者无法在这个基础上扩展出“同时最多 N 个线程通过”这种语义,更没法自己定义获取锁的条件。
ReentrantLock等显式锁出现后,这些能力全部开放给了开发者。而它们之所以能实现这些丰富的语义,恰恰是因为它们最终都交给了 AQS 来管排队和等待。AQS 本身不关心“锁”到底是什么语义,它只做两件事:记录一个状态位,维护一个等待队列。
1.2 排队模型:AQS 的核心抽象
所谓排队模型,说白了就是让获取不到资源的线程进一个队列去等着。AQS 用的是 CLH 锁队列的变种——一个 FIFO 的双向队列。每个请求资源的线程会被包装成一个Node节点,挂在队列尾部,自旋查看前驱节点的状态,当前驱节点释放资源后才尝试获取。
这种设计的好处是:入队和出队只需要 CAS 操作尾指针和头指针,不需要全局加锁。线程之间的协调通过volatile修饰的节点状态字段完成,配合LockSupport的park/unpark实现真正的阻塞唤醒。
1.3 我最初的理解误区:AQS 不等于“锁”
刚开始看 AQS 时,我总习惯把 AQS 当成一把锁,后面的代码看着很别扭。实际上 AQS 只是一个同步器的框架,它规定了一套“如何排队”、“如何阻塞和唤醒”的规则,但“能不能获取资源”这个判断逻辑是留给子类去实现的。
AQS 里几个模板方法,比如tryAcquire、tryRelease、tryAcquireShared、tryReleaseShared,默认是直接抛出UnsupportedOperationException,意思就是:你可以用 AQS 实现独占锁,也可以实现共享锁,甚至可以像ReentrantReadWriteLock那样同时支持独占和共享两套逻辑,但判断条件得你自己写。
拿生活中的场景打比方:AQS 就像餐厅门口的排队栏杆。栏杆本身只是物理设施,餐厅到底 “一桌坐几位”“有没有包间”“是不是会员优先”,那是餐厅自己的规则。AQS 管的是排队,餐厅管的是准入规则。
2. CLH 变种队列:AQS 排队系统的底层结构
理解了 AQS 是“排队框架”,接下来就得钻进队列本身。AQS 内部的等待队列不是简单的先进先出链表,它在经典 CLH 队列基础上做了几个关键改动,理解这几个改动是彻底看懂源码的分水岭。
2.1 为什么选用 CLH 队列而不是直接用一个锁保护队列
经典 CLH 锁的核心思想是:每个线程自旋检查前驱节点的 locked 状态,而不是去轮询一个全局变量,这样能避免多核 CPU 下的缓存行竞争。AQS 借鉴了这个思路,但把自旋改成阻塞,把单向列表改为双向。
为什么要改成双向?因为 AQS 里线程一旦被阻塞,后续需要通过LockSupport.unpark精准唤醒。如果只是单向列表,一个线程被唤醒后想找到自己的后继节点,只能从头遍历,代价太大。双向列表每个节点都持有前驱和后继的引用,释放锁的节点可以直接通过next指针找到下一个需要唤醒的线程。
2.2 Node 节点:状态位、线程引用和前后指针
AQS 的静态内部类Node是队列的基本单元,它的核心字段包括:
| 字段 | 作用 | 关键点 |
|---|---|---|
waitStatus | 节点等待状态 | 是理解排队的核心,见下表 |
prev/next | 前驱/后继节点 | 双向链表需要 |
thread | 当前线程引用 | 入队时设置,唤醒后清空 |
nextWaiter | 指向 Condition 队列的下一个节点,或者在共享模式下作为 SHARED 标记 | 注意它与 next 字段是两条不同的链 |
waitStatus的值有 5 个:
| 状态 | 数值 | 含义 |
|---|---|---|
CANCELLED | 1 | 线程因超时或中断被取消,节点处于废弃状态 |
SIGNAL | -1 | 当前节点释放资源后需要唤醒后继节点 |
CONDITION | -2 | 节点在 Condition 队列中等待 |
PROPAGATE | -3 | 共享模式下,释放资源时需要向后传播唤醒 |
| 0 | 0 | 新节点初始状态 |
常见误区是认为SIGNAL表示当前节点正在等待被唤醒,恰恰相反,SIGNAL表示当前节点有责任在释放锁后去唤醒后继节点。每次park前必须把前驱节点状态设置为SIGNAL,正是为了“确保自己不会错过被唤醒的机会”,这是 AQS 避免竞态的关键设计。
2.3 入队过程:尾插法配合 CAS
当一个线程加入等待队列时,AQS 先尝试用 CAS 把新节点直接插入队尾,不成功才进入enq的自旋循环。为什么会有 CAS 失败?因为可能有多个线程同时撞上来竞争同一个尾指针。
入队的核心逻辑可以简化为:
private Node enq(final Node node) { for (;;) { Node t = tail; if (t == null) { // 注意:初始化时头尾同时指向一个空节点 if (compareAndSetHead(new Node())) tail = head; } else { node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }这里值得注意的一个细节:AQS 的队列在初始化时,head和tail都指向一个 dummy 节点,这个节点不绑定任何线程。很多初学者会困惑“为什么队列里第一节点是一个空节点”,原因是 AQS 需要区分“头节点本身持锁”和“头节点仅仅作为哨兵”这两种情况。在释放锁时,通过把head指向下一个节点并清空其thread字段,来完成出队操作,整个过程不需要加锁。
2.4 出队与唤醒:一环扣一环的传播链
正常释放锁时,出队过程是这样的:
- 持有锁的线程调用
release(独占模式)或releaseShared(共享模式)。 - 先调用子类实现的
tryRelease/tryReleaseShared,判断资源是否真的释放到可以唤醒后继的程度。比如重入锁,state 从 2 减到 1 时不满足释放条件,就直接返回 false。 - 如果确实释放完了,就找到当前的
head节点,检查它的waitStatus,如果小于 0(即 SIGNAL),就用 CAS 把状态改为 0,然后调用LockSupport.unpark(s.thread)唤醒后继节点的线程。 - 被唤醒的线程在
acquireQueued的自旋循环里,尝试调用tryAcquire获取资源。
共享模式和独占模式最大的不同在于:共享模式在setHeadAndPropagate中,如果发现propagate > 0或者头节点的waitStatus == PROPAGATE,会继续向后唤醒。Semaphore就是典型例子,释放一个许可时,可能一次性唤醒多个等待的线程。
3. state 状态位:一个 int 如何撑起整个 AQS
AQS 的核心状态就是一个volatile int state,它是整个同步器语义的“唯一真相源”。独占锁用它记录重入次数,信号量用它记录剩余许可,倒计时门闩用它记录还有多少事件未完成。
3.1 为什么是 int 而不是 long
一个原因是volatile变量的读写在 32 位 JVM 上对 int 是原子的,而 long 类型在部分 32 位平台上需要两次写入,虽然有volatile保证最终一致,但 CAS 操作在 JDK 底层支持的原子性上,int 更高效。另一个原因是 int 的 32 位已经能满足绝大多数场景——状态值很少会超过几十亿。
3.2 基于 state 的三种典型应用模式
| 同步器 | state 语义 | 获取资源逻辑 | 释放资源逻辑 |
|---|---|---|---|
| ReentrantLock | 0 表示无锁,非 0 表示锁已被持有且重入次数为 state | tryAcquire中 CAS state 0→1,重入则 state+1 | tryRelease中 state-1,减到 0 才真正释放 |
| Semaphore | 可用许可数量 | tryAcquireShared中 state 减去许可数,必须不小于 0 | tryReleaseShared中 state 加回许可数 |
| CountDownLatch | 剩余未到达的计数 | 如果 state 不等于 0,进入队列等待 | state-1,减到 0 时唤醒所有排队的线程 |
这里有一个经常被忽略的点:ReentrantLock的tryRelease并不是把 state 直接置 0,而是递减。只有递减到 0 才说明锁完全释放,可以唤唤醒后继者。如果子类在tryRelease里没有返回 true,AQS 的release方法就不会做任何唤醒操作。这也解释了为什么ReentrantLock一定要在 finally 里 unlock 并保证成对调用——一旦少了一次解锁,state 永远不等于 0,所有排队线程都会一直 park 下去。
3.3 一个 int 的“多义性”带来的设计灵活性
AQS 的子类可以通过不同的读法把 state 玩出花来。读写锁ReentrantReadWriteLock把 state 拆成高 16 位和低 16 位,分别表示读锁持有数和写锁重入次数。它的做法是基于 int 是 32 位,直接做位移分割:
static final int SHARED_SHIFT = 16; static final int SHARED_UNIT = (1 << SHARED_SHIFT); static final int MAX_COUNT = (1 << SHARED_SHIFT) - 1; static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1; // 获取读锁计数 static int sharedCount(int c) { return c >>> SHARED_SHIFT; } // 获取写锁重入次数 static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }所以如果你要写一个自定义同步器,第一步就要想清楚 state 到底代表什么。它可以是许可数、计数、重入次数,甚至可以是多种语义的叠加。它不需要状态机那么复杂,一个 int 就够用,但你必须保证通过compareAndSetState修改它的过程是安全的。
4. 核心链路剖析:一次 lock() 和一次 unlock() 的完整旅程
前面把零件拆完了,这一节把零件装回去。从ReentrantLock.lock()出发,看一次加锁解锁在 AQS 里到底发生了哪些事情。
4.1 加锁路径:公平与否,先看 tryAcquire
ReentrantLock的lock()方法实际调用的是sync.lock()。sync是ReentrantLock内部的静态抽象类,它有两个实现:FairSync和NonfairSync。默认是非公平锁,这一点是经过性能权衡的——非公平锁允许新来的线程直接参与竞争,减少了线程挂起和唤醒的开销,在高并发下吞吐量往往更高。
非公平锁的加锁路径:
final void lock() { // 非公平锁特有的“抢跑”操作 if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }非公平锁的处理方式:新线程不会先去排队,而是先尝试直接修改 state,成功就立刻拿到锁。只有在 CAS 失败后,才走acquire(1)的完整逻辑。公平锁则没有这个抢跑分支,直接进入acquire(1)。
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这段代码分成三步:
tryAcquire(arg):由子类实现的快速尝试,成功就结束,失败则继续。addWaiter(Node.EXCLUSIVE):把当前线程包装成节点加入等待队列尾部。acquireQueued(...):在队列中自旋,尝试获取资源,必要时调用park阻塞自己。
其中tryAcquire在ReentrantLock中的典型实现(以非公平锁为例):
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { // 非公平锁这里不需要检查队列中是否有等待者 if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }注意重入判断逻辑:如果当前持有锁的线程就是自己,直接setState(nextc)更新重入计数,不需要 CAS,因为本来就是独占状态,不存在并发竞争。
4.2 自旋与阻塞的完美配合:acquireQueued
acquireQueued是整个加锁过程最核心的方法,也是线程会长时间停留的地方:
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; // help GC failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }这个循环做了三件关键事:
- 只有在自己成为头节点的后继节点时,才去尝试获取资源。这就是 FIFO 公平性的体现。就算是非公平锁,一旦排队了,也严格遵守队列顺序。
- 如果前驱节点还不是 head,或者
tryAcquire失败,就调用shouldParkAfterFailedAcquire,把前驱节点的waitStatus从 0 更新为 SIGNAL,然后parkAndCheckInterrupt挂起自己。 - 如果循环中被中断,并不会立即抛异常,而是把中断标记记录下来,最后通过调用
selfInterrupt()把中断状态补回。AQS 选择“晚中断”而不是“立即响应中断”,是为了保证线程在获取到锁之前,中断信号不会破坏排队结构。
shouldParkAfterFailedAcquire的逻辑值得展开一下。它检查前驱节点的状态:
- 如果前驱已经是 SIGNAL,说明后面的线程可以放心阻塞,直接返回 true。
- 如果前驱状态大于 0(CANCELLED),说明前驱已经放弃了排队,需要往前遍历,跳过所有取消的节点。
- 如果前驱状态为 0 或 PROPAGATE,通过 CAS 把它改为 SIGNAL,然后返回 false,外层循环会再跑一遍。
这段代码的目的是保证:每个线程在 park 之前,它的前驱节点一定处于 SIGNAL 状态。这样当前驱释放资源时,才能准确地找到并唤醒它。
4.3 解锁路径:从 state 到 unpark 的关键一跳
解锁的入口是release:
public final boolean release(int arg) { if (tryRelease(arg)) { Node h = head; if (h != null && h.waitStatus != 0) unparkSuccessor(h); return true; } return false; }tryRelease由子类实现。ReentrantLock的实现是递减 state,只有当 state 减到 0 时才算完全释放。重入 3 次解锁 3 次,前两次tryRelease都返回 false,不会触发唤醒;最后一次返回 true,才真正唤醒后继线程。
unparkSuccessor里有一个容易忽略的细节:它从尾节点往前遍历,找到距离头节点最近的未取消节点来唤醒。为什么不直接从head.next开始找?因为next指针可能被取消节点或者入队过程中的竞态打破,而prev指针在入队时是先于 CAS 设置的,所以向前遍历更可靠。
private void unparkSuccessor(Node node) { int ws = node.waitStatus; if (ws < 0) compareAndSetWaitStatus(node, ws, 0); Node s = node.next; // 从 tail 往前找第一个未取消的后继 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); }这段代码里的“从后往前找”被很多面试题直接拿来当考点。背后的原因是:入队时node.prev = t先执行,然后 CAS 尾指针,最后才执行t.next = node。这中间如果突然有线程释放锁,从头往后找可能会漏掉刚入队但next还没来得及设置的节点,从后往前找能避免这个问题。
5. 公平锁与非公平锁的差异:一个参数引发的“性能争议”
ReentrantLock构造方法传入的boolean fair参数决定了同步器的行为模式,但公平与非公平的差别远比“新线程能不能插队”这一个点复杂得多。
5.1 从源码看公平与非公平的实质差别
先看公平锁的tryAcquire在 state 为 0 时的处理:
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; }hasQueuedPredecessors检查队列中是否有比自己更早排队的线程。如果队列为空,或者自己恰好是队列头部,才可以尝试获取锁。而非公平锁直接compareAndSetState(0, acquires),完全不看队列。所以两者真正的差异只在“无人持锁时,新来的线程是否可以直接抢跑”。
5.2 锁饥饿问题与插队带来的性能提升
非公平锁允许插队,理论上存在线程饥饿的风险:一个线程反复在队列中等待,每次都会碰到新线程抢跑成功。但在实际系统中,非公平锁之所以是默认选择,是因为插队通常发生在锁刚被释放的窗口期,此时持有锁的线程还在做一些收尾工作,比如退出同步块、计算下一步,新线程直接接管可以省去一次线程切换。
公平锁的优势是执行时间可预测,适合对延迟有严格要求的场景。劣势是吞吐量明显低于非公平锁,因为每次唤醒都要精确地出队一个线程再入队一个线程,线程切换次数至少是公平锁的两倍。
5.3 一个反直觉的结论:非公平锁不一定“不公平”
从概率上讲,非公平锁的“插队”窗口非常短,而且插队成功的前提是在 CAS 上赢了所有正在排队的线程。多数场景下,新来的线程抢到锁的时机刚好处于锁释放的过渡期,并不会严重影响排队线程的等待时间。真正极端情况下,比如持锁时间非常长、线程数非常多,才会有明显的饥饿现象。
所以如果业务没有严格的公平性要求,非公平锁是更稳妥的选择。synchronized也是非公平的,这里面不仅仅是实现难度的问题,而是公平机制本身会带来额外的性能开销。
6. Condition 是怎么在 AQS 上“借壳上市”的
synchronized配合Object.wait/notify能实现基本的等待通知机制,但Object.wait只支持非超时等待,而且一个监视器只有一个条件队列。AQS 的ConditionObject提供了更丰富的语义:一个锁上可以挂多个条件队列,每个条件独立 wait,独立 signal。
6.1 Condition 的队列结构与 await/signal 流程
ConditionObject内部维护了一个单向队列,复用Node类,使用nextWaiter指针串联。当一个线程调用condition.await()时:
- 把当前节点从 AQS 的主等待队列中移除(实际上是 node 从 AQS 队列出队)。
- 把这个节点加入条件队列的尾部。
- 完全释放锁,记录释放前的 state 值。注意这里是“完全释放”,不是递减置零,因为条件等待时不能让锁还被自己持有。
- 调用
LockSupport.park阻塞自己,直到被 signal。
signal()的逻辑是:从条件队列头开始,找到第一个没有被取消的节点,调用transferForSignal把它从条件队列移到 AQS 主队列的尾部。这个节点会像普通排队线程一样,重新参与锁的获取。
这里最容易出问题的地方是:必须在持有锁的前提下调用signal。为什么?因为条件队列本身不受 AQS 主队列的 CAS 保护,如果多个线程同时 signal 条件队列,需要互斥访问来保证节点的安全转移。
6.2 await 与 wait 的对比
| 能力 | Object.wait | Condition.await |
|---|---|---|
| 条件队列数量 | 1 个 | 可创建多个,互不干扰 |
| 超时支持 | wait(long),粒度到毫秒 | awaitNanos、await(long, TimeUnit)、awaitUntil(Date) |
| 中断响应 | 抛 InterruptedException | 可配置:awaitUninterruptibly不响应中断,await响应 |
| 唤醒单个/全部 | notify/notifyAll | signal/signalAll |
多条件队列的价值在一个经典的生产者消费者模型里非常清晰:一个ReentrantLock配两个Condition,一个notEmpty,一个notFull。生产者往队列放元素后signalnotEmpty,消费者取走元素后signalnotFull。如果用synchronized只有一套 wait/notify,要避免用一个大的notifyAll把所有线程都唤醒再重新竞争,效率会差很多。
6.3 一个坑:Condition 的 signal 丢失问题
signal只会唤醒条件队列中的一个节点,如果这一时刻恰好没有线程在 await,那这个 signal 就丢失了。这不像 AQS 主队列里 lockset 的记录,signal 不具备“记忆性”。所以正确用法是:先修改共享状态(比如队列中已有数据),再调用signal。如果先 signal 后修改状态,被唤醒的线程可能因为状态还没变化而再次休眠。
7. 上手实战:基于 AQS 写一个自定义同步器
理论都看完了,不动手写一个始终不够深刻。AQS 官方文档里给了两个经典示例:一个是一次只允许一个线程通过的 Mutex,另一个是允许多个线程同时通过的 BooleanLatch。我这里基于它们改造一个“限流闸门”的同步器——允许最多N个线程同时通过,超过的排队等待,并且支持“重置闸门容量”。
7.1 需求定义与 state 的语义设计
这个同步器我叫它ThrottleGate,核心语义是:state 表示还可以放行多少个线程。每个线程进入时acquire(1),成功则 state-1,说明放行;失败则排队。线程离开时release(1),state+1,同时唤醒排队的线程。
这里注意一个细节:如果直接用 acquire/release 的独占模式,就无法实现“同时 N 个线程通过”。必须用共享模式——acquireShared和releaseShared。共享模式才支持多个线程同时持有资源,这是 AQS 语义层面的关键区别。
7.2 完整代码与逐步说明
public class ThrottleGate { private final Sync sync; public ThrottleGate(int maxConcurrent) { sync = new Sync(maxConcurrent); } public void acquire() throws InterruptedException { sync.acquireSharedInterruptibly(1); } public boolean tryAcquire() { return sync.tryAcquireShared(1) >= 0; } public void release() { sync.releaseShared(1); } private static final class Sync extends AbstractQueuedSynchronizer { Sync(int maxConcurrent) { if (maxConcurrent <= 0) { throw new IllegalArgumentException("maxConcurrent must be positive"); } // 注意:state 表示剩余许可数,初始值即最大并发数 setState(maxConcurrent); } @Override protected int tryAcquireShared(int arg) { for (;;) { int current = getState(); int remaining = current - arg; // 小于 0 说明许可不足,阻塞当前线程 if (remaining < 0 || compareAndSetState(current, remaining)) { return remaining; } } } @Override protected boolean tryReleaseShared(int arg) { for (;;) { int current = getState(); int next = current + arg; // 加回许可数,理论上不会溢出,但需要保证 CAS 成功 if (compareAndSetState(current, next)) { return true; } } } } }这段代码有几个关键设计点:
tryAcquireShared的返回值含义:正数或 0 表示获取成功;负数表示失败,需要排队。我返回的是remaining,如果许可充足,返回正数;如果许可刚好用完,返回 0;如果许可不足,返回负数。tryAcquireShared和tryReleaseShared都用了自旋 CAS。因为 state 可能同时被多个线程修改,自旋保证了“读取到比较设置”的原子性。- 用
acquireSharedInterruptibly而不是acquireShared,是为了让调用方在大门外面等待时能被中断。
7.3 测试场景:限流器到底是好是坏
写个测试验证一下效果。模拟 20 个线程同时争抢 5 个许可,打印每个线程进入临界区的时间戳:
public static void main(String[] args) throws Exception { int maxConcurrent = 5; ThrottleGate gate = new ThrottleGate(maxConcurrent); CountDownLatch start = new CountDownLatch(1); CountDownLatch done = new CountDownLatch(20); AtomicInteger concurrent = new AtomicInteger(0); AtomicInteger maxRecorded = new AtomicInteger(0); for (int i = 0; i < 20; i++) { final int id = i; new Thread(() -> { try { start.await(); gate.acquire(); try { int now = concurrent.incrementAndGet(); maxRecorded.accumulateAndGet(now, Math::max); // 模拟业务处理 Thread.sleep(50); } finally { concurrent.decrementAndGet(); gate.release(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); } }, "worker-" + id).start(); } start.countDown(); done.await(); System.out.println("max concurrent: " + maxRecorded.get()); }运行后max concurrent应该始终等于 5,说明同一时刻最多只有 5 个线程在临界区中。这个自定义同步器虽然简单,但已经体现了一个完整 AQS 子类所必需的三个要素:state 语义定义、tryAcquireShared的资源判断逻辑、tryReleaseShared的资源归还逻辑。
8. 源码阅读避坑指南:我啃 AQS 时的几个痛苦时刻
最后这部分是实战经验,记录那些“看源码看到怀疑人生”的瞬间,以及我的解决方法。
8.1 不要被“自旋 + CAS + LockSupport”三件套吓到
第一次面对getState、compareAndSetState、LockSupport.park循环的时候,容易把 AQS 想成非常高深的数据结构。实际上 AQS 的核心技巧就是一个三重循环模式:先读状态,再尝试 CAS 修改,失败就重试,实在不行就 park。所以当你看到for (;;)里的 CAS,把它理解成一个“乐观的 if 判断”就好了。
有一个粗略的规则可以帮助梳理:for (;;)循环里如果只有 CAS 而没有 park,它的作用是修复状态或者完成一个小的状态转换;如果有 park,说明线程可能要挂起等待唤醒。
8.2 时刻问自己:这个节点当前在哪个锁里,状态是怎样的
AQS 的 Node 有三个引用字段:prev/next用于主队列,nextWaiter用于条件队列,还有一个thread字段在不同阶段发生变化。阅读时我习惯画一个位置图:当前 node 是在主队列里等待获取锁,还是在条件队列里等待被 signal,还是在从条件队列往主队列转移的过程中。这三个状态的引用关系完全不同,waitStatus也会不同(主队列是 SIGNAL 或 0,条件队列是 CONDITION)。
8.3 中断响应策略:先记住结论再看代码
AQS 里有两个公开的获取方法:acquire和acquireInterruptibly。前者的响应方式是“记下中断标记,拿到锁后补上”,后者是在 park 中被唤醒时发现中断标志就立刻抛出异常。理解这个差异后,你再看acquireQueued里的parkAndCheckInterrupt返回中断标记却只把它存在局部变量里,就不会觉得奇怪了。
8.4 调试 AQS 的可行方法
如果想亲眼看到 AQS 队列的变化,最直接的方法是在自定义同步器里打日志。在tryAcquire、tryRelease中打印当前线程名、state 值和队列长度,可以很直观地看到线程如何进队出队。我自己写 ThrottleGate 时,就在tryAcquireShared里打印了“当前线程 + state + 队列头的引用”,效果比单纯读源码清楚得多。
还有一种方法是利用AbstractQueuedSynchronizer的监控方法:getQueueLength()返回排队线程数,hasQueuedThreads()判断是否有线程等待。这些方法源码里就有,可以帮助你在不打断代码逻辑的情况下观察内部状态。
8.5 把 AQS 和上一个经验联系起来:自己的并发组件也要保持这个“获取即判断,失败即排队”的习惯
写完自定义同步器后,回头再看Semaphore、CountDownLatch的代码,几乎可以直接照搬理解框架。Semaphore就是tryAcquireShared中判断剩余许可够不够,CountDownLatch是判断 state 是否已经为 0,前者需要 CAS 修改状态,后者只需要读取判断。
这也让我理解了为什么 AQS 作者 Doug Lea 会不停强调“state 只是一个可扩展的概念”。一个 int 的状态字段,加上一套排队唤醒机制,就足以支撑 Java 并发工具包里几乎所有的同步器变体。真正有创造力的地方,在于你如何定义 state 的语义,设计出适合业务场景的同步器来。
最后再说一个实际开发里的经验:理解 AQS 之后,排查线程死锁、锁长时间不释放这些问题会顺手很多。线上 dump 文件里只要能看到parkAndCheckInterrupt的调用栈,基本就能定位到某个锁竞争点;再结合jstack的线程状态和 AQS 队列长度,可以快速判断是锁粒度太大、还是死锁、还是业务线程意外长期持有锁。这种排查能力比死记源码要实用得多。