☰
ReentrantLock与AQS源码深度解析:加锁解锁全流程与设计精髓
2026/10/13 20:20:33 网站建设 项目流程

最近我把ReentrantLock和AQS的源码又从头到尾过了一遍,顺手把读源码过程中的关键路径、容易混淆的设计细节和调试方法整理成了一份学习资料。这份资料不是那种把源码逐行翻译的“注释版”,而是先把整体架构讲明白,再沿着lock() -> acquire -> addWaiter -> acquireQueued和unlock() -> release -> unparkSuccessor两条主线拆开,配合断点和日志观察线程状态变化,把抽象队列同步器的运行机制真正看透。

如果你已经写过synchronized,也基本知道volatile和CAS是什么,但看着 AQS 源码总觉得有一层窗户纸捅不破;或者面试被问到“AQS 和 ReentrantLock 的关系”时只能背结论、讲不清过程,那么这份分析思路会很适合你。它不是零基础教学,而是帮你把已经零散了解的知识挂到一条完整的执行链路上,读完以后你再看到相关面试题,脑子里会直接浮现出源码流程。


1. 这份源码资料到底在讲什么

1.1 会写 synchronized,为什么还要啃显式锁

很多开发者觉得日常业务里用synchronized就够了,没必要研究ReentrantLock。但一旦你接触到并发量较高的场景,或者涉及锁超时、可中断、多个条件队列这些需求,synchronized就会显得力不从心。ReentrantLock作为显式锁的代表,底层完全建立在 AQS 之上,搞懂它等于搞懂了一整类并发工具的实现套路。

再看java.util.concurrent包里的核心组件,Semaphore、CountDownLatch、ReentrantReadWriteLock、ThreadPoolExecutor里的Worker锁,全部基于 AQS。所以这份源码资料表面上是在讲ReentrantLock,实际上是在给 JUC 并发工具铺路。学会了 AQS 的模板方法模式,后面看其他同步器基本就是“换汤不换药”。

1.2 源码资料的目录是怎么组织的

我在整理资料时没有按照类文件的顺序去列,而是按一条“线程从抢锁到释放锁”的时间线来组织:

  • 第一部分讲 AQS 的静态骨架:state变量 + CLH 队列变体 +Node状态位。
  • 第二部分走加锁路线:从ReentrantLock.lock()到AQS.acquire(),再到tryAcquire、addWaiter、acquireQueued。
  • 第三部分走解锁路线:从unlock()到release(),看tryRelease、unparkSuccessor,以及 head 节点的推进。
  • 第四部分针对公平锁和非公平锁的差异、可重入计数、中断响应这些高频考点做源码级对照。
  • 最后附上我自己的调试方法和踩坑记录。

这样组织的好处是,读源码时你能始终带着“当前线程正在哪一步”的问题意识,而不是陷入方法调用栈里出不来。


2. 先把 AQS 的设计骨架刻进脑子

2.1 一个 int 变量加一个队列,撑起整个显式锁体系

AQS 的核心只有两样东西。第一个是private volatile int state,第二个是一个 FIFO 双向队列。

state在不同同步器里的含义完全不同。在ReentrantLock里它表示“当前线程获取锁的次数”,初始值是 0,第一次加锁变为 1,同一线程重入一次再加 1,解锁时依次递减直到归零;在Semaphore里它表示剩余许可证数量;在CountDownLatch里它表示未完成的计数。AQS 本身不管语义,只提供对state的安全读写方法:getState()、setState()、compareAndSetState()。

队列则是 CLH 锁队列的一个变体。原始 CLH 锁是自旋等待前驱节点释放,AQS 把自旋换成了阻塞唤醒,所以它其实是一个“变体 CLH 队列”。这个队列由Node对象组成,每个节点代表一个等待锁的线程,节点内部通过prev和next指针连接成双向链表,head指向队列头部的空节点,tail指向队尾节点。

用一句话概括这个设计:多个线程抢同一个锁时,只会有一个线程成功修改state,其余线程进入队列排队,前一个线程释放锁后唤醒后一个线程。这个方案兼顾了公平性和性能,不需要内核配合就能完成大部分同步逻辑。

2.2 Node 节点里的状态位到底有多少种

AQS 的Node类里有几个常量,很多人看源码时被它们绕晕:

static final int CANCELLED = 1; static final int SIGNAL = -1; static final int CONDITION = -2; static final int PROPAGATE = -3;

waitStatus初始是 0,表示当前节点正在排队,啥特殊状态都没有。CANCELLED表示线程等待超时或被中断后放弃排队,这个节点在后续操作中会被清理。SIGNAL是这个设计里最关键的状态,它表示“当前节点的后继节点正在(或将要)被 park 阻塞,当前节点释放锁或取消时,需要 unpark 后继节点”。CONDITION专属于条件队列,PROPAGATE在共享锁模式下使用,ReentrantLock是独占锁,用不到。

从实际运行的角度理解,waitStatus不是用来记录“当前节点自己的状态”的,它更多是给前一个节点看的。比如节点 B 的waitStatus是 SIGNAL,意思是 B 在等它的前驱 A 唤醒它,A 在释放锁的时候看到后继节点是 SIGNAL,就知道“哦,我需要叫醒 B”。这个设计让节点的状态检查和修改尽量发生在队头附近,减少无谓的自旋。

2.3 模板方法模式:AQS 留给子类的钩子

AQS 的作者把同步器的通用流程都写死在父类里,把具体语义留给子类去实现。以独占锁获取为例,父类acquire的流程是这样的:

public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt(); }

这里tryAcquire在 AQS 里直接抛出UnsupportedOperationException,由子类覆写。ReentrantLock内部有FairSync和NonfairSync两个子类,分别实现公平和非公平的获取逻辑。

所以“AQS 和 ReentrantLock 的关系”这个面试常考问题的准确答案是:AQS 提供了排队、阻塞、唤醒、中断处理这些通用机制,ReentrantLock 定义了锁的语义,包括是否公平、如何重入、如何释放。两者通过模板方法模式协作。


3. 加锁路径:从 lock() 一路读到 acquireQueued

3.1 非公平锁的 lock() 为什么没直接进 AQS

先看ReentrantLock的默认构造函数,它创建的是NonfairSync。非公平锁的lock()方法有一个非常显眼的细节:

final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); }

它先做了一次 CAS,试图直接把state从 0 改成 1。如果成功,当前线程立即获得锁,整个过程根本没有排队,也没有调用 AQS 的acquire。

这个“先试一次 CAS”就是非公平锁不公平的根源之一。刚释放锁的线程或者刚到达的新线程,都有机会直接抢到锁,而不是去队列尾部等待。注意这里的 CAS 操作是线程安全的,但它的目的不是“必须先尝试”,而是用最低的成本覆盖“锁刚释放、队列还空着”的常见场景。

如果 CAS 失败,说明锁被别人持有,或者锁虽然没持有但存在竞争,此时才进入acquire(1)。这个设计带来的性能提升非常明显:绝大多数锁竞争并不激烈,一次 CAS 就能成功,避免了后续复杂的入队操作。

而公平锁的lock()就完全没有这个“插队”机会,它直接调用acquire(1),然后在tryAcquire里检查队列中是否有比当前线程更早的等待者,如果有就乖乖排队。

3.2 tryAcquire 在公平和非公平实现里的区别

非公平锁的tryAcquire内部调用了非公平的nonfairTryAcquire,核心逻辑如下:

final boolean nonfairTryAcquire(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; }

这里面有两个关键点。

第一,c == 0时代表锁是空闲的,但nonfairTryAcquire不像lock()一开始那样直接 CAS,而是又做了一次 CAS,如果失败就返回 false,进入排队流程。这是多了分支还是性能浪费?不是,lock()阶段的那次 CAS 是“抢跑”,失败后进入acquire再调用tryAcquire,这时候锁可能已经被前一个线程释放了,所以必须再检查一次、再抢一次。这里多一次 CAS 完全值得,因为一旦成功就避免入队。

第二,current == getExclusiveOwnerThread()这条分支就是“可重入”的源码实现。持有锁的线程再次加锁,不再走 CAS,而是直接把state加 1。这里没有 CAS,是因为只有当前持锁线程能走到这个分支,不存在并发修改state的风险。这就是很多人疑惑的“可重入为什么不用 CAS”的答案——持有者独占修改权限。

FairSync的tryAcquire和非公平版的区别只有一处,就是在c == 0的情况下多了一个判断:

if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; }

hasQueuedPredecessors()会检查队列里是否有等待时间更长的线程。如果是空队列、或者当前线程就是队头节点,才允许抢锁。所以公平锁并不是绝对平均地按到达顺序服务每一个线程,它只保证队列里的老线程优先,新来的线程必须排到队尾。

3.3 addWaiter 入队操作的实现细节

当tryAcquire返回 false,线程就要入队了。addWaiter的源码看似简单,但有一个容易被忽略的点:

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

它先尝试把新节点直接挂到队列尾部,如果队列还是空的,或者 CAS 设置tail失败,才进入enq(node)。

enq的逻辑是这样:

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

在队列为空时,它先创建一个新的空节点,用 CAS 把它设为head,同时让tail也指向它。这就是为什么队列里第一个实际等待的节点前面总会有一个“哑节点”的原因。

注意enq里是一个死循环。第一次循环可能发现队列为空,创建 head 后继续循环;第二次循环把 node 挂到尾。为什么不用 if 而是 for(;;)?因为compareAndSetTail可能失败,失败后必须重新读取最新 tail 再试。CAS 旋重试模式在这里体现得淋漓尽致。

addWaiter结束后,当前线程节点在队列中处于tail位置,接下来进入acquireQueued。

3.4 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; failed = false; return interrupted; } if (shouldParkAfterFailedAcquire(p, node) && parkAndCheckInterrupt()) interrupted = true; } } finally { if (failed) cancelAcquire(node); } }

我第一次读这段代码时最不理解的是:为什么前驱是 head 才去tryAcquire?

答案隐藏在排队规则里。head 是哑节点,它不表示任何真实线程。当一个等待线程释放锁时,会唤醒自己的后继者。所以队列中“第一个真实等待线程”的前驱一定是 head,只有它才有资格尝试抢占锁。其他节点即使尝试也没有意义,因为队列顺序决定了机会要按从前往后传递。

shouldParkAfterFailedAcquire做的事情是:根据前驱节点的waitStatus决定当前线程是否应该被阻塞。如果前驱是 SIGNAL,说明前驱会负责唤醒当前节点,所以返回 true,可以 park;如果前驱是CANCELLED,则往前跳过所有取消的节点;否则把前驱的waitStatus从 0 改成 SIGNAL,然后再循环一次,给当前线程一次重新尝试的机会。

只有前驱状态合适,当前线程才通过LockSupport.park挂起。线程被唤醒后从parkAndCheckInterrupt里返回,重新进入下一次循环,尝试获取锁。这就是“唤醒后重新竞争”的过程。

acquireQueued的返回值是boolean interrupted,表示线程在排队过程中是否被中断过。注意这里的中断不会立刻抛出异常,而是记录下来,等acquire方法结束后再调用selfInterrupt()补上中断标记。AQS 对中断的处理方式在这里体现得很明确:不可中断模式下,中断不会终止排队流程。


4. 解锁路径:release() 是如何唤醒下一个等待者的

4.1 tryRelease 里为什么只用加法

ReentrantLock的解锁入口是unlock(),内部调用sync.release(1),AQS 的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:

protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }

这里有几个值得细想的地方。

c等于getState() - 1,这对应加锁时的getState() + 1。可重入计数累加了多少次,释放就需要多少次。如果当前线程不是锁的持有者,tryRelease会直接抛出IllegalMonitorStateException,这个保护就是“锁必须由持有者释放”的源码依据。

如果c没归零,说明是重入了多次,只释放了一层,仍然返回 false。此时release方法不会唤醒队列中的任何线程,因为锁还在当前线程手里。只有c == 0时,free为 true,setExclusiveOwnerThread(null)把持锁线程清空,返回 true 后才会去唤醒队列中的下一个等待者。

4.2 unparkSuccessor 找节点的细节和坑

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 节点,它想唤醒的是 head 的后继节点。但为什么s == null || s.waitStatus > 0时要从 tail 往前找?

因为节点可能被取消排队,next指针也可能因为并发入队操作还没设置完整。node.next虽然逻辑上应该是最近的等待者,但它在取消节点被清理时可能已经断链。反过来,prev指针在入队时是先设置的,所以从队列尾部往前找一定能把 head 后面第一个存活节点找出来。这就是“唤醒前驱不能只依赖 next,必要时从 tail 反向遍历”的原因。

唤醒之后,被唤醒线程回到acquireQueued的循环里,再次执行tryAcquire。它能否抢到锁,取决于是否公平以及队列头部竞争情况。

4.3 为什么 head 节点总是一个“哑节点”

很多读者能理解 FIFO 队列,但始终困惑:为什么等待线程入队后在acquireQueued成功获取锁后,不能直接把自己变成 head,而是要把自己的前驱置空?

看setHead的实现:

private void setHead(Node node) { head = node; node.thread = null; node.prev = null; }

获得锁的节点把自己变成新的 head,同时把自己的thread置空、prev置空。这样一来,新的 head 又是一个不包含线程引用的空节点,只起到哨兵作用。为什么要把thread置空?因为 head 代表“正在持有锁的线程”,但它已经不需要在队列里等待了,持有锁的线程信息放在exclusiveOwnerThread里更合理。而且如果不释放thread引用,已经持有锁的线程会一直被队列引用,阻碍 GC。

head 永远是空节点,也保证了队列里真正的等待者总是从 head.next 开始。有了这个不变量,release唤醒head.next就可以直接执行,不需要额外判断“头节点里有没有线程”。


5. 源码阅读实操路线与断点技巧

5.1 两个最值得下断点的入口

我自己读这份源码时,第一个断点放在ReentrantLock.lock(),第二个放在ReentrantLock.unlock()。这两个断点简单直接,能帮你抓住整个流程的起点。

然后加两个观察断点,一个下在AQS.acquireQueued的循环内部,另一个下在AQS.release里的unparkSuccessor调用处。

打开调试工具后,用两个线程同时竞争同一把锁。先让线程 A 拿到锁并进入长时间睡眠,让线程 B 执行lock(),此时 B 会走进acquire,经过addWaiter、acquireQueued后 park。接着等 A 睡眠结束调用unlock(),观察 B 是如何被唤醒来抢锁的。这样整个“加锁-排队-阻塞-唤醒-抢锁”的闭环就完整了。

5.2 单线程读法,把琐碎逻辑先过一遍

不建议一开始就用多线程调,因为线程切换时断点乱跳会让人崩溃。先用单线程场景把所有分支方法过一遍:

  • 调用lock(),观察lock()里compareAndSetState成功后直接返回。
  • 再次调用lock(),观察tryAcquire的可重入分支,看state如何从 1 变 2。
  • 调用unlock()两次,看tryRelease中c从 2 到 1 再到 0 的过程,第一次unlock不会触发唤醒,第二次才会。

单线程流程确认没问题后,再切到多线程场景,重点观察队列和阻塞唤醒。

5.3 线程竞争场景怎么读更高效

准备一个简单的测试代码:两个线程争同一把锁,利用调试工具的“Frame”或“Threads”面板查看线程状态。当线程 B 被 park 时,它的调用栈会停留在LockSupport.park上,这时候可以切到 main 线程,看命中队列中 B 节点的前后指针,确认它已经接入双向链表。

在release的unparkSuccessor处打一个断点,观察此时head.next是不是线程 B 的节点,以及 B 的waitStatus是不是 SIGNAL。这是理解整个唤醒机制最好的现场。

5.4 条件队列和超时锁要放在进阶阶段

如果是第一次读 AQS,不建议急着看Condition部分。条件队列引入了Node.CONDITION状态和signal、await的完整转移过程,复杂度比独占锁的获取/释放要高一个量级。我建议先把lock/unlock主线走完,再去看lockInterruptibly()、tryLock(timeout),这些都是在主流程基础上加了中断处理和超时取消。

最后再回头读ConditionObject,你会发现它的await和signal本质上是“把节点从条件队列转移到同步队列”的过程,和主流程有很多相通之处。


6. 常见误区与面试高频问题的源码级答案

6.1 可重入计数为什么要用 int 而不用 boolean

有些人觉得锁就是“有”和“没有”两种状态,用 boolean 就够了。但可重入性要求记录重入次数,所以state必须是数字。ReentrantLock中state的值等于当前持有线程的重入次数,这个设计同时满足了“是否持有”和“持有几次”两个需求。

同一线程再次获取锁时不需要 CAS,直接setState(nextc),因为此时持锁线程就是当前线程,没有并发竞争。但lock()第一次抢锁时不同线程并发修改state,所以必须用 CAS。这个差异不是性能优化小技巧,而是由并发模型决定的必然结果。

6.2 公平锁与非公平锁性能差异的真相

非公平锁性能更高的原因不是它减少了排队,而是它在lock()入口直接做了 CAS,以及排队中的线程被唤醒后还会和“新来线程”竞争一次,减少了线程上下文切换。但代价是可能出现“线程饥饿”或排队延迟拉长。

公平锁的性能低在它严格执行“先到先得”,每个新线程必须先检查队列,即使锁此刻空闲,只要队列里有等待者,它就不能抢。这个检查本身成本不高,真正高的成本在于“被迫把大量操作让给阻塞唤醒”,线程序列化程度高。实际业务中,如果持锁时间极短,非公平锁通常明显更快;如果持锁时间较长且对公平性有硬性要求,才考虑公平锁。

面试时如果能说出“非公平锁的插队发生在两个阶段:lock() 入口的一次 CAS,以及被唤醒线程和新人竞争时的第二次 tryAcquire”,基本就能证明你是真读过源码。

6.3 lockInterruptibly 和 lock 的中断处理有什么不同

默认的lock()不会响应中断:线程在acquireQueued里被 park 时如果收到中断,只会记录interrupted = true,等拿到锁后才补设中断标记。这样做的好处是,线程就算被中断也不会丢失排队位置。

lockInterruptibly()走的是acquireInterruptibly,如果线程在park过程中收到中断,会抛出InterruptedException,节点会进入cancelAcquire被移除出队列。这两种模式的核心差异不是“是否能用中断唤醒”,而是“唤醒后是否继续等待锁”。

6.4 释放锁忘记在 finally 里的后果

tryRelease里有一道保护:当前线程不是持锁线程时抛出IllegalMonitorStateException。这意味着其他线程无法替当前线程释放锁,如果你把unlock()写在了不经finally的路径上,一旦业务代码抛异常,锁永远不会被释放,后续所有等待线程会无限 park。

这个问题在源码层面看得很清楚:release只有在tryRelease返回 true 时才去唤醒下一个等待者,而state不为 0 时tryRelease永远返回 false。所以锁释放不匹配的最终表现不是报错,而是死等。这也是为什么锁必须在finally里释放,没有例外。


7. 关于读源码的几点个人体会

读 AQS 这类并发源码,最忌讳的是按类结构从头开始逐行看。你很容易在Node的字段和waitStatus常量里迷失,一个月后还是一团浆糊。我的经验是,一定要沿着线程的执行流走,把一个线程从“抢锁”到“拿锁”再到“释放锁”的整条路径走通,然后再去关心那些边角状态。

另外,一定要多利用调试工具,不要只靠眼睛在代码里“跑线程”。我见过很多人读源码时把 JUC 包背得滚瓜烂熟,但一问他“head 节点的 waitStatus 什么时候从 0 变成 SIGNAL”,他就答不上来。这个问题的答案,只要你在shouldParkAfterFailedAcquire里下个断点,看一次运行过程就能刻进脑子。

还有一个容易被忽略的小技巧:在addWaiter和enq里做笔记时,务必分清“线程 A 的节点的prev指向谁”和“节点的next指向谁”。CAS 设置tail成功后,pred.next = node并非线程安全所必需,它只是为了方便从 head 正向遍历。理解这个顺序,你就不会对unparkSuccessor里反向查找的代码感到奇怪。

最后想说的是,AQS 整套设计并不复杂,它的复杂度在于状态多、并发交错多。你只要抓住“state 表示锁的状态,队列表示等待线程的顺序,节点状态位表示邻居之间的协作关系”这三点,再配合源码阅读路线反复走几遍,最终一定能形成自己的理解框架。这份资料如果能帮你在源码里少绕一些弯路,就算达到目的了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询