十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

理解 AQS 核心原理:从排队模型到自定义同步器实战 先从一个场景说起你写了一个接口里面用了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 个状态数值含义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 的队列在初始化时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 如何撑起整个 AQSAQS 的核心状态就是一个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重入则 state1tryRelease中 state-1减到 0 才真正释放Semaphore可用许可数量tryAcquireShared中 state 减去许可数必须不小于 0tryReleaseShared中 state 加回许可数CountDownLatch剩余未到达的计数如果 state 不等于 0进入队列等待state-1减到 0 时唤醒所有排队的线程这里有一个经常被忽略的点ReentrantLock的tryRelease并不是把 state 直接置 0而是递减。只有递减到 0 才说明锁完全释放可以唤唤醒后继者。如果子类在tryRelease里没有返回 trueAQS 的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 加锁路径公平与否先看 tryAcquireReentrantLock的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 自旋与阻塞的完美配合acquireQueuedacquireQueued是整个加锁过程最核心的方法也是线程会长时间停留的地方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。如果前驱状态大于 0CANCELLED说明前驱已经放弃了排队需要往前遍历跳过所有取消的节点。如果前驱状态为 0 或 PROPAGATE通过 CAS 把它改为 SIGNAL然后返回 false外层循环会再跑一遍。这段代码的目的是保证每个线程在 park 之前它的前驱节点一定处于 SIGNAL 状态。这样当前驱释放资源时才能准确地找到并唤醒它。4.3 解锁路径从 state 到 unpark 的关键一跳解锁的入口是releasepublic 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.waitCondition.await条件队列数量1 个可创建多个互不干扰超时支持wait(long)粒度到毫秒awaitNanos、await(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)state1同时唤醒排队的线程。这里注意一个细节如果直接用 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 队列长度可以快速判断是锁粒度太大、还是死锁、还是业务线程意外长期持有锁。这种排查能力比死记源码要实用得多。
返回列表