AQS 源码拆解:ReentrantLock 独占锁的排队、唤醒与中断机制 有些锁你每天在业务代码里加加减减但可能从没见过它的内部长什么样。ReentrantLock是多数 Java 开发者最早接触的显式锁能加锁解锁、能响应中断、能设超时、还能公平排队。可真被问到“公平排队是怎么实现的”“非公平锁凭什么性能更好”“中断信号怎么穿过一个正在等待的线程”很多人就卡住了。这篇文章想直接把AbstractQueuedSynchronizerAQS给 ReentrantLock 提供的整套独占锁机制拆开。我会从数据结构和状态机入手把获取锁、释放锁、重入计数、公平性、中断与超时这些链路完整走一遍最后落地到两个我用 AQS 思维排查过的真实锁问题。适合已经写过不少并发代码、但还没系统啃过 AQS 源码的开发者。1. 从一次锁竞争说起AQS 到底解决什么问题1.1 锁的职责比想象中多互斥、可见性、排队调度多数人理解锁第一反应是“互斥”——同一时刻只有一个线程能进入临界区。这没有错但只是锁的一半。另一半是可见性线程 A 解锁后写入的变量线程 B 获取同一把锁后必须能看到。这依赖内存屏障和 volatile 语义AQS 里的state字段正是用volatile修饰的。比这两者更难的是第三个问题拿不到锁的线程该怎么办。synchronized 会把竞争失败的线程丢给底层 monitor 去阻塞开发者几乎感知不到“排队”这个过程。而 ReentrantLock 的底层没有走 JVM 内置 monitor而是把“等待者如何排队、如何阻塞、如何被唤醒、如何响应中断”全部搬到用户态的 AQS 里。也就是说AQS 解决的真正难题不是互斥而是等待调度。这也是为什么 AQS 单独拎出来成为一个框架它把“判断能不能拿锁”和“拿不到锁之后做什么”拆成了两层。判断逻辑由子类通过tryAcquire/tryRelease决定而排队、阻塞、唤醒、取消等待这些通用机制由框架统一用一套双向队列完成。1.2 自旋与挂起两个代价对立的等待策略想象一个银行柜台。自旋就是站在窗口前反复问“轮到我了吗”不离开窗口挂起则是取号后去座位上闭眼休息等柜台叫号。自旋锁适合持锁时间极短、多核机器上的高并发场景因为它避免了线程切换的开销但代价是白白消耗 CPU。阻塞挂起则把 CPU 让给别的线程代价是等待者被唤醒时要走一遍操作系统的调度延迟高、成本大。AQS 不是二选一而是把两者编排成一条混合策略线程先尝试一次 CAS 获取失败后入队入队后如果发现自己排在 head 之后再给最后一次自旋尝试的机会还不行才调用LockSupport.park真正挂起。这样既在锁竞争不激烈时省掉了入队开销又在竞争激烈时避免了无意义的自旋空转。1.3 AQS 不是一把锁而是一套同步器框架ReentrantLock、Semaphore、CountDownLatch、ReentrantReadWriteLock本质都是 AQS 这个框架上的不同实现。区别只是子类重写了哪些模板方法独占锁重写tryAcquire/tryReleasestate 表示锁的重入次数信号量重写tryAcquireShared/tryReleaseSharedstate 表示剩余许可数倒计时锁重写tryReleaseSharedstate 每次递减减到 0 时唤醒所有等待者。AQS 采取的是模板方法模式获取和释放的整体流程是骨架方法框架内部已经是final子类只负责回答“当前条件是否允许获取/释放”这个问题。理解了这一点再看 ReentrantLock 的源码会轻松很多——你只需要关注几个钩子方法的实现剩下的排队和唤醒机制全都在基类里。2. 骨架拆解state、双向队列和 Node 状态机2.1 state 字段一个 volatile 整数承载的多重语义AQS 内部最核心的字段就一个private volatile int state;volatile保证了多线程间的可见性配合 CAS 实现原子修改。暴露给子类的三个方法分别是getState()、setState()、compareAndSetState()。其中setState通常只在独占模式下使用——因为持有独占锁的线程对 state 的访问本来就是互斥的不需要 CAS 那么重的语义。在 ReentrantLock 里state 0表示当前没有线程持有锁state 1表示锁被某个线程持有一次state 1表示同一个线程重入了多次。也就是说state 在这里被当成了重入计数器。这个设计非常聪明AQS 本身不知道锁的含义但通过一个整型状态就能同时服务Semaphore的剩余许可、CountDownLatch的剩余计数、ReentrantReadWriteLock的高低 16 位读写状态。2.2 Node 身份卡waitStatus 为什么是 int 而不是 boolean等待队列里的每个节点对应一个正在等待锁的线程。节点上最重要的字段是waitStatus它有五种取值值含义触发场景CANCELLED (1)线程已取消排队获取锁超时或中断后放弃等待SIGNAL (-1)后继节点正在等待当前节点释放锁时需要唤醒它节点准备 park 前设置给前驱CONDITION (-2)节点在条件队列中Condition.await后节点尚未转移回同步队列PROPAGATE (-3)共享模式下继续向后传播释放CountDownLatch、Semaphore等共享释放心0初始状态新节点入队时为什么不是布尔值因为一个节点可能从 CONDITION 转移到 SIGNAL也可能从 0 变成 CANCELLED而且在并发环境下这些状态会被 CAS 竞争地修改。一个 int 正好可以表达“正常等待、需要唤醒、已取消、在条件队列、等待向后传播”这五种互斥状态同时还能利用正负值做快速判断——waitStatus 0就一定是取消状态这是源码里最常见的判空扫描。2.3 队列如何从无到有哨兵节点的诞生AQS 的等待队列是一个双向链表head 和 tail 都是 volatile 字段。关键神奇之处在于队列里第一个节点并不代表等待线程而是一个永远不退出的“哨兵节点”dummy node。当第一个线程 CAS 抢锁失败进入addWaiter时它发现tail null于是先创建一个空的 Node通过 CAS 把它同时设置为 head 和 tail然后再自旋把自己追加为第二个节点。为什么非得搞一个哨兵因为释放锁时要通过head.next找到下一个需要唤醒的线程如果 head 本身就是等待者那么 head 被换掉后旧节点的 next 谁来清理会变得非常麻烦。有了哨兵head 永远指向“当前持有者或曾经持有者”的位置等待线程排在它后面整个队列结构就稳定了。3. 独占锁获取链路从 lock() 到线程真正挂起3.1 入口 fast-path为什么非公平锁可以先 CAS 碰运气先看入口。ReentrantLock默认使用NonfairSync它的lock()实现是final void lock() { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); } else { acquire(1); } }先用 CAS 直接把 state 从 0 改成 1成功说明锁刚好空闲并且被自己拿到了整个过程只需要一次原子操作连队列都不用碰。这就是非公平锁最快的路径。失败才进入通用的acquire流程。这里就能看出“非公平”的体现一个刚刚到达的线程可以和已经在队列里等了很久的线程一起争夺刚释放的锁甚至因为恰好赶上时间窗口而直接抢到。为什么这个设计反而被官方推荐因为在锁竞争不那么激烈的场景下它避免了“释放锁后必须唤醒、唤醒后还要再调度一次”的完整链路换来更低的延迟和更高的吞吐量。3.2 通用获取骨架tryAcquire、addWaiter、acquireQueued 三件套CAS 失败后进入 AQS 的骨架方法public final void acquire(int arg) { if (!tryAcquire(arg) acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }这句话浓缩了整个独占锁获取过程。tryAcquire是子类实现的钩子它尝试获取锁成功则直接返回失败则通过addWaiter入队再通过acquireQueued在队列里继续等待。注意这里的selfInterrupt是最后才执行的原因稍后讲到中断处理时会解释。NonfairSync的tryAcquire逻辑可以简化为如果state 0就再 CAS 一次抢锁如果state 0但持有者是自己就把 state 加 1 表示重入。也就是说非公平锁即使已经排队入队前还会再尝试一次“插队”每次都争取那一点点概率。3.3 addWaiter 入队CAS 追加尾节点的完整过程如果tryAcquire还是失败线程就需要把自己包装成一个 Node追加到队列尾部private Node addWaiter(Node mode) { Node node new Node(Thread.currentThread(), mode); Node pred tail; if (pred ! null) { node.prev pred; if (compareAndSetTail(pred, node)) { pred.next node; return node; } } enq(node); return node; }这里有个细节先用“读 tail再设置 node.prev再 CAS tail”的快速路径尝试一次。如果 tail 不为空且 CAS 成功就完成了入队。CAS 失败说明有其他线程也在入队发生了竞争这时才进入enq的自旋循环。enq里还有一个逻辑如果 tail 为空会先创建哨兵节点并 CAS 设置为 head 和 tail然后再继续把当前节点接上去。一个容易忽略的细节是设置node.prev是在 CAS 之前完成的。为什么因为后续acquireQueued要沿着 prev 指针向前检查前驱而一个节点的 prev 一旦赋值就不会被改写除非取消时通过 CAS 修改。把 prev 先写好可以让后一个节点入队时即使慢一步也能通过 prev 稳定找到前驱。3.4 acquireQueued排队线程的旋转门逻辑入队之后进入最核心的acquireQueuedfinal 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); } }这个 for 循环的逻辑可以分成三步查看自己的前驱是不是 head。如果是说明自己排在等待队列的最前面值得再尝试一次获取锁如果尝试失败调用shouldParkAfterFailedAcquire判断是否可以安全挂起如果可以挂起就调用LockSupport.park阻塞自己直到被唤醒后再回到循环开头。这里的 FIFO 并不是严格公平的但基本有序只有前驱是 head 的节点才有资格在唤醒后尝试抢锁。新来的线程处于队列尾部哪怕锁刚好空闲它也不知道所以非公平主要体现在“新线程在入队前的 CAS 抢锁”而不是入队后的插队。setHead有一个关键动作把当前节点的thread清空、prev也清空让它变成新的哨兵节点。这样 head 的语义始终保持不变——它不表示一个真实等待线程只表示“当前持锁者在这个位置”。被清空后的节点将永远留在队列头部直到被下一个获得锁的节点替代。3.5 park 前那一刻把前驱标记为 SIGNALshouldParkAfterFailedAcquire是很多人容易忽略但极其重要的方法。它做的事可以浓缩成一句确保自己的前驱节点的 waitStatus 是SIGNAL。为什么要这么干因为挂起之后线程唯一的唤醒途径是前驱节点释放锁时调用unparkSuccessor。而前驱节点只有在发现自己 waitStatus 是 SIGNAL 时才会去执行唤醒动作。所以当前线程在 park 之前必须先把前驱的状态 CAS 成 SIGNAL相当于把“唤醒我”的承诺写在前驱的牌子上。如果前驱是 CANCELLED 状态就得跳过它向前找把当前节点重新接到队列里。这个设计保证了“先设置好唤醒标记再挂起”不会出现“线程挂起了但没人承诺唤醒它”的丢失唤醒问题。4. 释放锁与唤醒后继unlock 的对称与严谨4.1 tryReleasestate 归零才叫真正释放释放锁的入口是unlock()内部直接调用sync.release(1)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 减去 release 参数但只有当 state 归零时才返回 true。假如线程重入了两次state 是 2第一次unlock后 state 变成 1此时并不会唤醒等待队列里的后继节点只有第二次unlock把 state 减到 0才算真正释放锁才会走到唤醒逻辑。这解释了为什么“可重入锁的 unlock 次数必须和 lock 次数配对”。如果代码里少调了一次 unlockstate 永远到不了 0锁就会一直被持有等待队列里的所有线程都会卡死。另外tryRelease会先检查当前线程是不是持有者不是则直接抛出IllegalMonitorStateException——这是所有显式锁最重要的安全校验。4.2 unparkSuccessor为什么从 tail 反向找 next终于到了唤醒后继的时刻。源码里的实现是三段逻辑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.next来唤醒因为head.next在并发入队时可能还没来得及设置或者指向的是一个已经取消的节点。举例来说如果线程 A 正在addWaiter里 CAS 设置 tail但还没执行pred.next node此刻 head 的 next 还是 null如果直接拿它来找唤醒目标就会漏掉一个排在最前面的等待线程。所以源码选择了从 tail 往回扫描一直找到离 head 最近的、waitStatus 不大于 0 的有效节点。向后遍历的可靠性来自prev指针的稳定性prev一旦赋值就不会轻易被改写而next指针可能因为取消和并发入队变得不可靠。这是一处非常反直觉但极其重要的实现细节。4.3 唤醒之后回到 acquireQueued 继续循环被unpark的线程从LockSupport.park返回后会回到acquireQueued的 for 循环顶部。此时它重新检查自己的前驱是不是 head并再次尝试tryAcquire。如果成功它就会setHead让自己变成新的哨兵节点然后把旧 head 的 next 清空完成整个交接。注意这里的旧 head 并不会主动被 GC 回收它只是把 thread 和 prev 清空然后用 next 引用指向新 head。新 head 在处理完自己的前一个节点后会把旧节点的 next 置空这样前任哨兵才真正失去引用可以进入 GC。这就是为什么源码里setHead只清 thread 和 prev而p.next null是由拿到锁的节点做最后一次清理。整个唤醒链路的严谨之处在于unlock 只在 state 归零时 unpark 一个线程而被唤醒的线程也只会尝试一次获取锁如果它抢锁失败比如又有新线程插队抢了锁它会重新回到队列继续等待并且会在下次被唤醒时再次尝试。这就是非公平锁和新线程插队的完整循环。5. 可重入、公平与非公平同一框架上的三个旋钮5.1 可重入让 state 计数而不是另搞一套有些初学者会以为可重入锁需要额外记录“哪个线程持有几次”但 AQS 的做法其实非常朴素state本身就承担计数职责。每次lock成功state 1每次unlockstate - 1只有归零才算释放。因为state是 volatile 且只在持有锁时被修改这个计数过程天然是线程安全的。这也带来一个实际约束重入次数理论上不能超过Integer.MAX_VALUE。虽然现实中几乎不可能重入这么多次但源码里每次加一都会检查溢出风险一旦溢出state会变成负数锁就彻底失效。这也是为什么即使到了 JDK 17AQS 的很多判断依然是基于int的符号位来做快速比较。理解了这个计数逻辑就不难回答面试里那个经典问题为什么重入锁必须保证 lock 和 unlock 次数匹配。5.2 hasQueuedPredecessors公平锁的插队禁令公平锁和非公平锁唯一的区别就在tryAcquire的第一行if (!hasQueuedPredecessors() compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; }hasQueuedPredecessors的语义是队列里是否已经有比当前线程更早到达的等待者。如果队列非空而且head.next不是当前线程就认为还有更早的等待者当前线程不能插队必须去排队。这里最微妙的是“刚初始化队列”的中间状态如果head ! tail但head.next null说明正有线程在入队过程中还没有完成指针设置。此时公平锁会保守地判定“有前驱”让新线程去排队。这样虽然牺牲了一点并发度但保证了公平的绝对性。如果这里不谨慎竞争激烈的瞬间就可能出现明明已经有人在排队、却被新线程插队的现象公平性就会被破坏。5.3 两种策略的取舍默认非公平是有道理的从直觉上讲公平锁似乎更“正确”那为什么 ReentrantLock 的默认策略是非公平关键在于性能。非公平锁在“锁刚好被释放的那一刻”新线程可以直接 CAS 抢到跳过了唤醒排队线程、排队线程再重新抢锁这两步。竞争激烈时每个线程都不必频繁在挂起和唤醒之间切换整体吞吐量更高。代价是队首线程可能被插队多次等待时间出现抖动极端情况下会有饥饿风险。公平锁则把每个等待线程的唤醒时机限制为严格的 FIFO 顺序等待时间更可预测不容易饿死队尾线程但代价是更多的上下文切换和唤醒开销。我的经验是默认非公平除非业务明确要求“按申请顺序获得锁”或者持锁时间很短、竞争很激烈且不能接受某个线程长期等不到锁。如果选择了公平锁还要特别小心不要在持有锁时做耗时很长的操作否则公平排队会让整个队列的响应时间线性恶化。6. 中断、超时与 ConditionAQS 的进阶能力6.1 lockInterruptibly中断信号如何打断排队普通lock()在等待过程中如果收到中断信号并不会立即停止等待而会在获取锁成功之后通过selfInterrupt()重新补上中断标志。这在很多业务场景下是不够的——比如线程池正在 shutdown等待中的线程应该尽快放弃锁而不是继续排队。这时候就要用lockInterruptibly()。它的底层链路里parkAndCheckInterrupt检测到中断后会直接抛出InterruptedException同时还会调用cancelAcquire把自己从等待队列里摘除把 waitStatus 改为 CANCELLED并重新梳理前驱和后继的指针然后异常向外传播。这里可以看到 AQS 对中断的两层处理要么“等拿到锁后再补状态”要么“立刻放弃并抛异常”。两种行为由外层方法决定底层排队机制完全复用。这也是显式锁相对 synchronized 的一大优势synchronized 想在中途取消等待是没有这样干净的手段的。6.2 tryLock(timeout)parkNanos 与取消节点tryLock(long time, TimeUnit unit)在底层走的是doAcquireNanos。它的核心思路是先用System.nanoTime()算出 deadline然后进入循环尝试获取锁如果获取不到检查剩余时间是否小于等于 0是则取消否则调用LockSupport.parkNanos(remaining)睡一小段时间再醒来尝试。和普通等待的差异在于超时后线程要主动脱离队列。AQS 里通过cancelAcquire完成节点摘除先把 waitStatus 设置为 CANCELLED然后根据情况跳过取消节点、重新连接前后节点。这里同样是依赖prev指针来安全地处理无序取消因为 CANCELLED 节点可能出现在队列的任意位置从头或尾单向断开都可能破坏一致性。实际使用中tryLock(timeout)是避免死锁的利器。当多个线程需要按顺序获取多个锁时用带超时的 tryLock 可以在拿不到后续锁时退回去释放已经拿到的锁避免一直僵持。不过要小心设置超时时间时最好不要用很小的值否则在高并发下会让大量线程因为短暂竞争而退出重试反而增加 CPU 压力。6.3 Condition等待队列到同步队列的转换AQS 内部还有一个独立的ConditionObject每个 Condition 维护一条自己的条件队列。当线程执行condition.await()时它会把当前节点从 AQS 的同步队列转移到条件队列并且释放持有的锁然后挂起。只有当别的线程调用condition.signal()时等待线程才会从条件队列移回同步队列重新参与锁竞争。这个“两个队列之间转移”的设计正好回答了“为什么 Condition 的 await 会释放锁”因为不释放锁别的线程就无法进入临界区去调用 signal条件永远也不会被满足就会形成死锁。理解了 AQS 的同步队列和条件队列的关系很多 Condition 的奇怪行为就顺理成章了比如signal之前的await即使先于signal执行节点也一定会被移动到同步队列不会丢信号。6.4 LockSupportunpark 先于 park 也不丢信号整个 AQS 的阻塞和唤醒都建立在LockSupport.park/unpark之上。它和传统的wait/notify有一个本质区别unpark不会产生“信号先发生后被错过”的问题。LockSupport为每个线程维护了一个许可permit机制可以简单理解成一张唤醒券。unpark相当于塞一张券park时如果手里有券直接消耗并返回如果没有券才真正阻塞。所以哪怕unpark发生在park之前线程随后执行 park 时也能立刻返回许可证不会丢掉。这正是 AQS 能在各种复杂的解锁时机下安全唤醒后继节点的底层保障。7. 用 AQS 思维排查两个真实锁问题7.1 持锁时间过长排队线程都卡在同一个 Node 上我在一个数据同步服务里遇到过一种诡异现象接口偶尔会突然拖慢几百毫秒但不是每次都发生。一开始怀疑是数据库问题后来用线程 dump 一看发现大量线程卡在同一个AbstractQueuedSynchronizer.park上状态是WAITING (parking)并且它们的调用栈都指向同一把 ReentrantLock。顺着持有者找下去那个线程正在临界区里做一次外部服务调用网络延迟偶尔飙高导致它持有锁的时间从正常的两三毫秒变成几百毫秒。后面排队的所有线程自然全部拉长等待时间。这个问题的解法不是换锁——换成 synchronized 后几乎一样。真正要做的是缩小临界区把外部调用挪到锁外面锁内只保留需要互斥保护的共享变量更新。AQS 只是把等待线程管理得很干净它不能替你判断业务上到底该共享什么。读线程 dump 时看到大批线程阻塞在同一把锁上首先要问的问题是“持锁线程到底在干什么”而不是“锁用错了没有”。7.2 唤醒风暴与频繁插队公平锁未必都合适另一个项目里技术团队为了解决“等待时间不公平”的问题把非公平锁换成了公平锁。结果上线后吞吐量下降部分请求的延迟反而更高了。原因是那把锁的持锁时间本身不长但竞争特别激烈公平锁让每个等待线程都要经历“被唤醒、重新排队、再次尝试抢锁”的过程上下文切换开销陡增。后来我们把加锁策略调整为“非公平锁 更小粒度的临界区”并且用带超时的 tryLock 在拿不到锁时快速失败返回给上层重试问题反而解决了。这个案例的教训是公平锁解决的是饥饿风险但它不是性能优化工具。在大多数短暂的临界区里非公平锁的吞吐量通常更高公平性反而会压低整体吞吐。判断该不该用公平锁要看业务对“等待顺序的确定性”是否有硬性要求。7.3 区分“AQS 提供能力”和“业务使用策略”排查过这几个问题后我最大的感受是AQS 只是提供了一个非常通用的“排队 阻塞 唤醒”框架但什么时候该排队、排队多久、排到了要不要响应中断这些决策全部靠上层代码控制。比如tryLock(timeout)的语义是“等一段时间拿不到就放弃”但放弃之后是重试、是报错、还是走降级逻辑AQS 管不着。又比如非公平锁的插队行为在低竞争场景下是优点在高竞争场景下可能让队尾线程等得很久这种策略取舍也不能指望框架替你做。写锁相关代码时建议先画一张“临界区路径图”哪些操作必须互斥、哪些可以在锁外做、等待者能不能接受超时放弃然后再决定用什么锁和什么参数。7.4 自定义一个最小 AQS 同步器验证套路最后分享一个我常用的验证方法自己动手写一个基于 AQS 的最简互斥锁。只需要实现三个钩子方法其他排队、唤醒、中断传播能力全部复用框架public class SimpleLock { private final Sync sync new Sync(); public void lock() { sync.acquire(1); } public void unlock() { sync.release(1); } private static final class Sync extends AbstractQueuedSynchronizer { Override protected boolean tryAcquire(int arg) { if (compareAndSetState(0, 1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } Override protected boolean tryRelease(int arg) { if (getState() 0) { throw new IllegalMonitorStateException(); } setExclusiveOwnerThread(null); setState(0); return true; } Override protected boolean isHeldExclusively() { return getState() 1; } } }写完这段代码你会发现SimpleLock自动获得了阻塞排队、可中断、可定时获取这些能力甚至还能配合 Condition 使用。亲手跑一遍之后再看 ReentrantLock它不过是把tryAcquire里的单次 CAS 换成了“考虑重入计数”的逻辑而已。AQS 框架的抽象能力就是这样一套模板方法设计撑起来的。最后说一点个人体会。读 AQS 源码时不要试图按顺序死记每个方法先抓住四件事一个 volatile 的 state 字段、一条带状态的双向等待队列、一组基于 CAS 的原子修改、一把基于 LockSupport 的挂起与唤醒工具。然后自己画一遍获取锁和释放锁的状态流转把所有“为什么先设置状态再 park”“为什么从 tail 往回找下一个节点”“为什么 state 归零才算释放”这些细节捋顺。把这两条链路吃透之后ReentrantLock 的底层就算真正搞明白了再看 Semaphore、CountDownLatch 和读写锁的源码你会发现自己已经站在完全不同的高度。