news 2026/9/10 6:31:28

理解 AQS 核心原理:从排队模型到自定义同步器实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
理解 AQS 核心原理:从排队模型到自定义同步器实战

先从一个场景说起:你写了一个接口,里面用了ReentrantLock,某天线上突然出现线程阻塞,dump 一看,一堆线程卡在AbstractQueuedSynchronizer.acquireQueued,你大概知道是锁竞争,但如果此时面试官追问一句“AQS 内部到底怎么排队的?公平锁和非公平锁差在哪?Condition 挂起线程时节点怎么转移?”——要是当时没完整啃过 AQS,多半会卡壳。

这大概是 Java 并发包里最值得花时间去啃的一个类了。AQS,全称AbstractQueuedSynchronizer,是ReentrantLockSemaphoreCountDownLatchReentrantReadWriteLock这些同步器共同的底层地基。我当初啃它的时候,光是“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修饰的节点状态字段完成,配合LockSupportpark/unpark实现真正的阻塞唤醒。

1.3 我最初的理解误区:AQS 不等于“锁”

刚开始看 AQS 时,我总习惯把 AQS 当成一把锁,后面的代码看着很别扭。实际上 AQS 只是一个同步器的框架,它规定了一套“如何排队”、“如何阻塞和唤醒”的规则,但“能不能获取资源”这个判断逻辑是留给子类去实现的。

AQS 里几个模板方法,比如tryAcquiretryReleasetryAcquireSharedtryReleaseShared,默认是直接抛出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 个:

状态数值含义
CANCELLED1线程因超时或中断被取消,节点处于废弃状态
SIGNAL-1当前节点释放资源后需要唤醒后继节点
CONDITION-2节点在 Condition 队列中等待
PROPAGATE-3共享模式下,释放资源时需要向后传播唤醒
00新节点初始状态

常见误区是认为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 的队列在初始化时,headtail都指向一个 dummy 节点,这个节点不绑定任何线程。很多初学者会困惑“为什么队列里第一节点是一个空节点”,原因是 AQS 需要区分“头节点本身持锁”和“头节点仅仅作为哨兵”这两种情况。在释放锁时,通过把head指向下一个节点并清空其thread字段,来完成出队操作,整个过程不需要加锁。

2.4 出队与唤醒:一环扣一环的传播链

正常释放锁时,出队过程是这样的:

  1. 持有锁的线程调用release(独占模式)或releaseShared(共享模式)。
  2. 先调用子类实现的tryRelease/tryReleaseShared,判断资源是否真的释放到可以唤醒后继的程度。比如重入锁,state 从 2 减到 1 时不满足释放条件,就直接返回 false。
  3. 如果确实释放完了,就找到当前的head节点,检查它的waitStatus,如果小于 0(即 SIGNAL),就用 CAS 把状态改为 0,然后调用LockSupport.unpark(s.thread)唤醒后继节点的线程。
  4. 被唤醒的线程在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 语义获取资源逻辑释放资源逻辑
ReentrantLock0 表示无锁,非 0 表示锁已被持有且重入次数为 statetryAcquire中 CAS state 0→1,重入则 state+1tryRelease中 state-1,减到 0 才真正释放
Semaphore可用许可数量tryAcquireShared中 state 减去许可数,必须不小于 0tryReleaseShared中 state 加回许可数
CountDownLatch剩余未到达的计数如果 state 不等于 0,进入队列等待state-1,减到 0 时唤醒所有排队的线程

这里有一个经常被忽略的点:ReentrantLocktryRelease并不是把 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

ReentrantLocklock()方法实际调用的是sync.lock()syncReentrantLock内部的静态抽象类,它有两个实现:FairSyncNonfairSync。默认是非公平锁,这一点是经过性能权衡的——非公平锁允许新来的线程直接参与竞争,减少了线程挂起和唤醒的开销,在高并发下吞吐量往往更高。

非公平锁的加锁路径:

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阻塞自己。

其中tryAcquireReentrantLock中的典型实现(以非公平锁为例):

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); } }

这个循环做了三件关键事:

  1. 只有在自己成为头节点的后继节点时,才去尝试获取资源。这就是 FIFO 公平性的体现。就算是非公平锁,一旦排队了,也严格遵守队列顺序。
  2. 如果前驱节点还不是 head,或者tryAcquire失败,就调用shouldParkAfterFailedAcquire,把前驱节点的waitStatus从 0 更新为 SIGNAL,然后parkAndCheckInterrupt挂起自己。
  3. 如果循环中被中断,并不会立即抛异常,而是把中断标记记录下来,最后通过调用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()时:

  1. 把当前节点从 AQS 的主等待队列中移除(实际上是 node 从 AQS 队列出队)。
  2. 把这个节点加入条件队列的尾部。
  3. 完全释放锁,记录释放前的 state 值。注意这里是“完全释放”,不是递减置零,因为条件等待时不能让锁还被自己持有。
  4. 调用LockSupport.park阻塞自己,直到被 signal。

signal()的逻辑是:从条件队列头开始,找到第一个没有被取消的节点,调用transferForSignal把它从条件队列移到 AQS 主队列的尾部。这个节点会像普通排队线程一样,重新参与锁的获取。

这里最容易出问题的地方是:必须在持有锁的前提下调用signal。为什么?因为条件队列本身不受 AQS 主队列的 CAS 保护,如果多个线程同时 signal 条件队列,需要互斥访问来保证节点的安全转移。

6.2 await 与 wait 的对比

能力Object.waitCondition.await
条件队列数量1 个可创建多个,互不干扰
超时支持wait(long),粒度到毫秒awaitNanosawait(long, TimeUnit)awaitUntil(Date)
中断响应抛 InterruptedException可配置:awaitUninterruptibly不响应中断,await响应
唤醒单个/全部notify/notifyAllsignal/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 个线程通过”。必须用共享模式——acquireSharedreleaseShared。共享模式才支持多个线程同时持有资源,这是 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;如果许可不足,返回负数。
  • tryAcquireSharedtryReleaseShared都用了自旋 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”三件套吓到

第一次面对getStatecompareAndSetStateLockSupport.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 里有两个公开的获取方法:acquireacquireInterruptibly。前者的响应方式是“记下中断标记,拿到锁后补上”,后者是在 park 中被唤醒时发现中断标志就立刻抛出异常。理解这个差异后,你再看acquireQueued里的parkAndCheckInterrupt返回中断标记却只把它存在局部变量里,就不会觉得奇怪了。

8.4 调试 AQS 的可行方法

如果想亲眼看到 AQS 队列的变化,最直接的方法是在自定义同步器里打日志。在tryAcquiretryRelease中打印当前线程名、state 值和队列长度,可以很直观地看到线程如何进队出队。我自己写 ThrottleGate 时,就在tryAcquireShared里打印了“当前线程 + state + 队列头的引用”,效果比单纯读源码清楚得多。

还有一种方法是利用AbstractQueuedSynchronizer的监控方法:getQueueLength()返回排队线程数,hasQueuedThreads()判断是否有线程等待。这些方法源码里就有,可以帮助你在不打断代码逻辑的情况下观察内部状态。

8.5 把 AQS 和上一个经验联系起来:自己的并发组件也要保持这个“获取即判断,失败即排队”的习惯

写完自定义同步器后,回头再看SemaphoreCountDownLatch的代码,几乎可以直接照搬理解框架。Semaphore就是tryAcquireShared中判断剩余许可够不够,CountDownLatch是判断 state 是否已经为 0,前者需要 CAS 修改状态,后者只需要读取判断。

这也让我理解了为什么 AQS 作者 Doug Lea 会不停强调“state 只是一个可扩展的概念”。一个 int 的状态字段,加上一套排队唤醒机制,就足以支撑 Java 并发工具包里几乎所有的同步器变体。真正有创造力的地方,在于你如何定义 state 的语义,设计出适合业务场景的同步器来。

最后再说一个实际开发里的经验:理解 AQS 之后,排查线程死锁、锁长时间不释放这些问题会顺手很多。线上 dump 文件里只要能看到parkAndCheckInterrupt的调用栈,基本就能定位到某个锁竞争点;再结合jstack的线程状态和 AQS 队列长度,可以快速判断是锁粒度太大、还是死锁、还是业务线程意外长期持有锁。这种排查能力比死记源码要实用得多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/10 6:30:54

基于Vue.js和Node.js的线上美术馆网站平台设计与实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/10 6:30:16

STM32F103 AB双分区OTA升级方案:从Bootloader到Ymodem完整实战

STM32F103&#xff0c;一颗卖了十几年的Cortex-M3单片机&#xff0c;到现在依然是无数产品的中枢。我接触过的不少项目里&#xff0c;它还在稳定跑着产线逻辑、通信网关和各类变流器控制。但这两年有个问题越来越绕不开——产品要联网、要迭代、要能远程修复bug。尤其当你需要给…

作者头像 李华
网站建设 2026/9/10 6:30:03

用Python做统计分析:从描述统计到假设检验的完整实践指南

早几年自己做数据分析那会儿&#xff0c;最头疼的不是模型多复杂&#xff0c;而是手边明明有数据&#xff0c;却不知道怎么科学地“说清楚”结论。Excel里算个p值还得装分析工具库&#xff0c;SPSS和EViews这类专用软件又不便宜&#xff0c;换了电脑还要重新激活。后来切到Pyth…

作者头像 李华
网站建设 2026/9/10 6:29:56

AI文本去机味实战:从词语到情感重构人味表达

作为一个常年和文字打交道的人&#xff0c;我最近被"humanizer"这个词反复刷屏。一开始我以为又是什么新出的AI工具&#xff0c;后来才发现&#xff0c;它既是一种工具&#xff0c;更是一套正在被反复讨论的"技能"——humanizer skill。简单说&#xff0c;…

作者头像 李华
网站建设 2026/9/10 6:28:29

CANN/GE图引擎SetTargets函数

SetTargets 【免费下载链接】ge GE&#xff08;Graph Engine&#xff09;是面向昇腾的图编译器和执行器&#xff0c;提供了计算图优化、多流并行、内存复用和模型下沉等技术手段&#xff0c;加速模型执行效率&#xff0c;减少模型内存占用。 GE 提供对 PyTorch、TensorFlow 前端…

作者头像 李华