当前位置: 首页 > news >正文

我们一起“扒一扒”ReentrantLock:看看锁背后那些精妙的设计

今天泡杯茶,深入聊聊咱们的老朋友——ReentrantLock。平时用 synchronized 关键字挺顺手,但一旦想玩点高级的,比如公平锁、尝试获取锁、可中断获取锁,那就得请出 ReentrantLock 了。咱们不光要会用,还得掀开它的盖子,看看里面的发动机(AQS)是怎么转的。

为了让咱们的探索更有代入感,我先写一个最简单的使用示例作为我们的“地图”,然后咱们就跟着代码的调用链路,一步步“钻”进源码里去探险。

我们的探索地图:示例代码

import java.util.concurrent.locks.ReentrantLock;public class ReentrantLockDemo {// 这就是我们今天要解剖的主角。默认是非公平锁。private final ReentrantLock lock = new ReentrantLock();public void doSomething() {// 第一站:获取锁lock.lock(); try {// 临界区代码,同一时间只有一个线程能执行System.out.println(Thread.currentThread().getName() + " got the lock.");// 为了演示重入,我们调用另一个也需要锁的方法doSomethingElse();} finally {// 最后一站:释放锁// 一定要放在finally里,保证即使出异常也能释放锁,避免死锁。lock.unlock(); }}public void doSomethingElse() {lock.lock(); // 同一个线程,再次获取锁 -> 重入try {System.out.println(Thread.currentThread().getName() + " got the lock again (reentrant).");} finally {lock.unlock(); // 释放重入的锁}}public static void main(String[] args) {final ReentrantLockDemo demo = new ReentrantLockDemo();// 创建几个线程来竞争锁for (int i = 0; i < 3; i++) {new Thread(() -> demo.doSomething(), "Thread-" + i).start();}}
}

好了,地图在手,天下我有。我们的探险路线非常清晰:lock.lock() -> 临界区 -> lock.unlock()。出发!


第一站:获取锁 - lock.lock()

当我们调用 lock.lock() 时,会发生什么呢?点进去看看:

// ReentrantLock.java
public void lock() {sync.lock(); // 嚯,它直接把活儿委托给了内部类`sync`
}

这个 sync 是何方神圣?它在 ReentrantLock 构造的时候就初始化了:

// ReentrantLock.java
private final Sync sync;public ReentrantLock() {sync = new NonfairSync(); // 默认是非公平锁
}

所以,sync.lock() 实际上调用的是 NonfairSync 类的 lock() 方法。咱们就看看非公平锁是怎么“抢”的。

非公平锁的“抢”锁行为 - NonfairSync.lock()

// ReentrantLock.NonfairSync
static final class NonfairSync extends Sync {final void lock() {// 【第一步:不管三七二十一,先直接尝试CAS修改状态,把state从0改成1】if (compareAndSetState(0, 1))// 如果抢成功了!立马把锁的主人设为自己,然后直接返回,成功获取锁。setExclusiveOwnerThread(Thread.currentThread());else// 如果第一步没抢到,那就调用AQS提供的标准acquire方法。acquire(1);}// ... 后续还有其他方法
}

源码注释:

  • compareAndSetState(0, 1): 这是AQS提供的一个CAS操作,它尝试将 state 字段(可以理解为锁的计数器)从0改为1。0代表锁空闲,大于0代表被持有。这是实现锁的基石。
  • setExclusiveOwnerThread(Thread.currentThread()): 这也是AQS父类中的方法,就是简单地记录下当前是哪个线程持有了这个独占锁。

思考一下:为什么叫“非公平”?就因为这一步!它完全不看后面有没有线程在排队等待,自己直接上来就抢。这就像你去排队买奶茶,突然有个人插队到最前面直接点单,这就是“非公平”。但如果他抢失败了(CAS返回false),他就得老实地去后面排队(调用 acquire(1))。

如果没抢到,就会调用 acquire(1)。这是AQS的核心方法,是一个模板方法,它定义了获取资源的总体流程,但其中一些关键步骤留给子类自己实现。

// AbstractQueuedSynchronizer.java
public final void acquire(int arg) {// 这是一个非常经典的条件判断流程:// 1. 首先再尝试一次获取(tryAcquire,由子类实现)// 2. 如果获取失败,则将当前线程包装成节点加入队列(addWaiter)// 3. 然后在队列中自旋或阻塞地等待(acquireQueued)if (!tryAcquire(arg) &&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))// 如果acquireQueued返回true,代表等待过程中线程被中断了,// 这里重新设置一下中断标志位(因为阻塞过程中中断状态被清除了)selfInterrupt();
}

这个方法就像是一个工作流引擎,我们一步步拆解。

关键点一:再次尝试获取 - tryAcquire(arg)

tryAcquire 在AQS里是抽象的,具体实现看子类,也就是我们的 NonfairSync

// ReentrantLock.NonfairSync
protected final boolean tryAcquire(int acquires) {// 直接调用了父类Sync实现的一个非公平获取方法return nonfairTryAcquire(acquires);
}// ReentrantLock.Sync
final boolean nonfairTryAcquire(int acquires) {final Thread current = Thread.currentThread();int c = getState(); // 获取当前锁状态if (c == 0) {// 【状态为0,锁又空闲了!机会来了,再次尝试CAS抢锁!】// 这就是非公平的第二次体现:即使可能在排队,新来的线程依然有机会抢if (compareAndSetState(0, acquires)) {setExclusiveOwnerThread(current);return true; // 成功!}}// 【关键点:如何实现可重入?】else if (current == getExclusiveOwnerThread()) {// 如果state不为0,但持有锁的线程就是当前线程自己int nextc = c + acquires; // 那就把state直接加上acquires(通常是1)if (nextc < 0) // 溢出检查,int最大值是2147483647,重入次数不能超过这个throw new Error("Maximum lock count exceeded");setState(nextc); // 设置新的state值。注意这里不需要CAS,因为锁本来就是自己占着的return true; // 获取成功,这就是重入!}// 如果锁被别的线程占着,或者自己CAS又没抢过别人,那就返回false,获取失败。return false;
}

可重入锁的实现奥秘就在这里! 它通过检查当前线程是否是锁的持有者来实现。如果是,就把 state 简单地 +1。释放的时候,也需要释放相应的次数(state -1),直到减为0才算真正释放。这就是为什么 lock()unlock() 必须要成对出现的原因。

如果 tryAcquire 返回 false,意味着获取又失败了。工作流引擎就会继续往下走:addWaiter(Node.EXCLUSIVE)

关键点二:线程入队 - addWaiter(Node mode)

是时候把当前线程放入等待队列了。Node.EXCLUSIVE 代表这是一个独占模式的节点。

// AbstractQueuedSynchronizer.java
private Node addWaiter(Node mode) {// 1. 以给定模式创建当前线程的新节点//    mode有两种:Node.EXCLUSIVE(独占)或Node.SHARED(共享)Node node = new Node(Thread.currentThread(), mode);// 快速尝试:直接CAS设置新的尾节点,如果成功就直接返回。Node pred = tail; // 获取当前尾节点if (pred != null) {node.prev = pred; // 新节点的前驱指向当前尾节点if (compareAndSetTail(pred, node)) { // CAS操作,把tail指针指向新节点pred.next = node; // 将原尾节点的后继指向新节点,完成双向链表连接return node;}}// 如果快速尝试失败(比如并发入队导致CAS失败),或者队列还没初始化(pred==null)// 就进入一个循环,不断尝试入队,直到成功enq(node);return node;
}// 循环入队,保证肯定能成功
private Node enq(final Node node) {for (;;) { // 自旋循环Node t = tail;if (t == null) { // 如果队列是空的,必须初始化// CAS地设置一个哑元节点(Dummy Node)作为头节点if (compareAndSetHead(new Node()))tail = head; // 头尾都指向这个新节点} else {// 和快速尝试里的逻辑一样,CAS地将新节点设为尾节点node.prev = t;if (compareAndSetTail(t, node)) {t.next = node;return t; // 返回旧的尾节点}}}
}

源码注释:

  • CLH队列:AQS的队列是一个虚拟的CLH队列的变种。它是一个FIFO的双向链表。
  • 初始化:队列懒惰初始化。第一个节点入队时,会先创建一个不包含任何线程的“哑元节点”或“哨兵节点”作为头节点。头节点可以认为就是当前持有锁的节点
  • 入队步骤:创建新节点 -> 将新节点的prev指向当前tail -> CAS将tail指向新节点 -> 将原tail的next指向新节点。注意:prev指针是稳定的,而next指针在并发情况下可能暂时不一致,这也是为什么唤醒时有时需要从后往前遍历的原因(我们后面会看到)。

现在,线程已经被成功包装成Node,放入等待队列的尾部了。接下来就是它在队列中的“修炼”了:acquireQueued

关键点三:队列中的等待与唤醒 - acquireQueued(final Node node, int arg)

这个方法让已经在队列中的节点,以自旋(循环)的方式不断尝试获取锁,如果失败且判断需要休息,就安心阻塞(park),直到被前驱节点唤醒。

// AbstractQueuedSynchronizer.java
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; // 帮助GC,断开旧头节点的链接failed = false;return interrupted; // 返回中断状态}// shouldParkAfterFailedAcquire: 检查获取失败后是否应该park阻塞// parkAndCheckInterrupt: 如果应该,那就park阻塞,并在被唤醒后检查中断状态if (shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt())interrupted = true; // 如果park过程中被中断,记录中断状态}} finally {if (failed)cancelAcquire(node); // 如果最终失败(比如异常),取消当前节点}
}// 检查并更新节点的状态,告诉它“你该休息了,等前驱节点叫你”
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {int ws = pred.waitStatus; // 获取前驱节点的等待状态if (ws == Node.SIGNAL) // Node.SIGNAL(-1): 表示“后继节点需要被唤醒”// 前驱节点状态正确,可以安心park了return true;if (ws > 0) { // ws>0 只有CANCELLED(1),表示前驱节点已取消// 那就一直往前找,找到一个有效(非取消)的节点,并排在它后面do {node.prev = pred = pred.prev;} while (pred.waitStatus > 0);pred.next = node;} else {// ws是0或PROPAGATE(-3),把前驱节点的状态CAS地设为SIGNAL// 告诉它“你释放锁的时候记得叫我啊!”compareAndSetWaitStatus(pred, ws, Node.SIGNAL);}return false; // 这次先不park,下次循环再来检查
}// 阻塞当前线程,并在被唤醒后返回线程的中断状态
private final boolean parkAndCheckInterrupt() {LockSupport.park(this); // 调用Unsafe的park方法,线程在此处阻塞return Thread.interrupted(); // 被唤醒后,清除并返回中断标志
}

源码注释:

  • 自旋获取:只要前驱是头节点,就有资格不断尝试 tryAcquire。这减少了不必要的阻塞唤醒开销。
  • SIGNAL状态:这是节点间的一种“约定”。一个节点的 waitStatusSIGNAL,意味着它释放锁时有责任唤醒它的后继节点。shouldParkAfterFailedAcquire 方法的核心工作就是确保自己的前驱节点是这个状态,这样自己才能放心地去阻塞。
  • 清理取消的节点:在寻找有效前驱时,会跳过那些已取消 (CANCELLED) 的节点,维护队列的健康。
  • park:这是最终让线程进入等待状态的地方,底层调用 Unsafe.park(),非常高效。
  • 新的头节点:当节点成功获取锁后,它会成为新的头节点。旧的头节点会被断开链接。头节点代表的永远是当前持有锁的节点(或刚刚释放锁的节点)。

走到这里,一个获取锁失败的线程,它的 lock() 调用之旅就暂时告一段落了——它要么成功获取了锁,要么已经在队列中安静地阻塞(park)了,等待着被唤醒的那一天。


最后一站:释放锁 - lock.unlock()

持有锁的线程执行完临界区代码后,必须在 finally 中调用 unlock() 来释放锁,以便唤醒后继等待的线程。让我们看看这又是如何发生的。

// ReentrantLock.java
public void unlock() {sync.release(1); // 同样是委托给sync,调用AQS的release模板方法
}// AbstractQueuedSynchronizer.java
public final boolean release(int arg) {// 1. 尝试释放锁(tryRelease,由子类实现)if (tryRelease(arg)) {Node h = head; // 获取当前头节点// 如果头节点不为空,并且waitStatus不为0(通常是SIGNAL,表示有后继需要唤醒)if (h != null && h.waitStatus != 0)unparkSuccessor(h); // 2. 唤醒后继节点return true;}return false;
}

又是一个模板方法!release 先调用 tryRelease 尝试释放,如果完全释放成功了(state==0),就去看看队列里有没有需要被唤醒的兄弟。

关键点四:释放状态 - tryRelease(int releases)

这个方法在 ReentrantLock.Sync 中实现。

// ReentrantLock.Sync
protected final boolean tryRelease(int releases) {// 计算释放后的stateint c = getState() - releases;// 非常重要的一点:如果当前线程不是锁的持有者,抛异常!if (Thread.currentThread() != getExclusiveOwnerThread())throw new IllegalMonitorStateException();boolean free = false;if (c == 0) {// 如果state减为0了,说明锁完全释放了,可以清空持有线程标记free = true;setExclusiveOwnerThread(null);}setState(c); // 更新state(即使不为0,也可能是重入释放了一次)return free; // 返回是否完全释放
}

源码注释:

  • 重入释放:可重入锁的释放必须次数匹配。每次 unlock 只减1,只有最后一次释放才会将 state 减到0,并将 exclusiveOwnerThread 设为 null
  • 状态检查:如果当前线程压根没持有锁,直接抛异常,防止乱释放。

如果 tryRelease 返回 true(锁已完全释放),就会去执行 unparkSuccessor(h)

关键点五:唤醒后继 - unparkSuccessor(Node node)

这是AQS队列唤醒的核心

// AbstractQueuedSynchronizer.java
private void unparkSuccessor(Node node) {// node在这里是头节点,即刚刚释放完锁的节点int ws = node.waitStatus;if (ws < 0) // 如果状态是SIGNAL等小于0的状态// CAS地将头节点状态置为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);
}

为什么从后往前找?
这是因为在并发入队和取消节点的过程中,next 指针可能暂时是不准确的(比如一个节点刚取消,它的 next 可能还没被前驱节点修正)。而 prev 指针在节点入队时就确定了,非常稳定。从稳定的 tail 开始,利用稳定的 prev 指针向前遍历,就绝对不会漏掉任何一个真正需要被唤醒的节点,保证了唤醒操作的可靠性。这是一种非常健壮的设计。

被唤醒的线程会从哪里继续执行呢?它会从之前 parkAndCheckInterrupt() 方法中 LockSupport.park(this) 的地方醒来,然后继续那层的 for(;;) 自旋循环。

它再次检查前驱是不是头节点(p == head),然后再次调用 tryAcquire 尝试获取锁。此时锁肯定是空闲的(因为刚被释放),所以这次获取几乎一定会成功。然后它就会执行 setHead(node),将自己设为新的头节点,并开始执行自己的临界区代码。

至此,锁的释放和交接仪式就圆满完成了!


总结与闲聊

好了,咱们这趟源码之旅算是结束了。让我们回顾一下 ReentrantLock 的设计精髓:

  1. 委托模式ReentrantLock 所有核心功能都委托给内部同步器 Sync(AQS的子类)实现。
  2. 状态控制:通过AQS的 state 字段和 exclusiveOwnerThread 实现了可重入的特性。
  3. 队列管理:AQS维护了一个CLH变体的FIFO双向队列,高效地管理着所有等待线程。
  4. 模板方法:AQS定义了 acquire/release 等模板方法,子类只需实现 tryAcquire/tryRelease 等来定义具体的同步规则(公平/非公平),这是整个设计的核心,也是AQS能成为那么多同步工具类基础的原因。
  5. 并发处理:源码中充满了CAS操作和精心设计的循环,以处理各种并发竞争下的边界条件,比如非公平抢锁、节点入队、状态更新、跳过取消节点、可靠的唤醒等。
  6. 性能与公平的权衡:默认的非公平锁虽然“插队”,但减少了线程切换的开销,吞吐量更高。公平锁(FairSync)的实现不同之处就在于 tryAcquire 中会先检查队列是否有等待者(hasQueuedPredecessors()),如果有,即使state=0,也会乖乖排队,保证了绝对的公平。

看源码就像和顶尖高手对话,一开始可能云里雾里,但一旦理解了其设计思路和模式,就会豁然开朗,对自己的编程思维是极大的提升。希望这篇“游记”能帮你更好地理解 ReentrantLockAQS

如果还有哪里不明白,欢迎在评论区讨论!

http://www.wxhsa.cn/company.asp?id=202

相关文章:

  • win10使用openssl生成证书
  • $\LaTeX{}$之minted使用 - Invinc
  • linux服务器 系统服务文件
  • Codeforces Round 1049 (Div. 2) 部分题解
  • Critical Thinking Academic Writing
  • 1.3 课前问题思考
  • 【知识管理工具分享】基于AI搭建个人法律知识库:我的PandaWiki实践心得
  • 你的中间件一团糟-是时候修复它了-️
  • 超越-env-一份成熟的应用程序配置指南
  • 告别框架臃肿-我如何在不牺牲性能的情况下重新发现简单之美
  • 像元大小(例如 1.4 m 1.4 m)具体的含义和用途
  • Codeforces Round 1049 (Div. 2) 一些 idea
  • 医学如果不追求深入的话,其实门槛没有特别高
  • Canvas 的性能卓越,用它解决一个棘手的问题!
  • CSS Box-Sizing 详解:解决移动端布局溢出问题的关键
  • Visual Studio Code 开发环境搭建(Rust)
  • Spring Boot 项目中,同一个版本的依赖,内容却不一样?一次因依赖污染导致 Redis 启动失败的排查
  • 微信机器人开发文档
  • 从0到1:餐饮微信点餐小程序源码解析(含扫码点餐+外卖系统+后台管理)
  • 推荐一款线程or进程间数据同步解决方案
  • part 2
  • Apache服务器自动化运维与安全加固脚本详解
  • 无障碍资源导航
  • The 2022 ICPC Asia Shenyang Regional Contest
  • 还在微信群追问任务进展?领歌看板让逾期工作无处可藏
  • 别再猜了-开始测量吧-一份实用的Web性能指南
  • 你的开发服务器在说谎-热重载与热重启的关键区别
  • 大屏开发
  • 检测域名证书有效期
  • PostgreSQL 内机器学习的关键智能算法研究