ReentrantLock
类结构
经典的组合模式+依赖倒置
内部定义了一个抽象类Sync继承自AQS,然后提供了公平锁和非公平锁两个实现
加锁流程
是的,ReentrantLock 底层完全依赖 park 和 unpark 来实现线程的阻塞和唤醒。
具体来说,ReentrantLock 内部使用了 AQS(AbstractQueuedSynchronizer),而 AQS 在需要让线程挂起(阻塞)时,调用的正是 LockSupport.park();在需要唤醒线程时,调用的正是 LockSupport.unpark()。
下面为你详细解析这两个机制。
1. 什么是 park 和 unpark?
这两个方法位于 java.util.concurrent.locks.LockSupport 工具类中。它们是替代 Thread.suspend() 和 Object.wait() 的底层原语。
核心机制:"许可"(Permit)
你可以把 park 和 unpark 理解为颁发通行证。
- 每个线程都有一个关联的“许可”(Permit)。
- 这个许可只有两个状态:0(无许可)和 1(有许可)。它是不能累加的(给10次unpark,许可也只有1)。
方法行为
unpark(Thread thread):- 给指定线程发一张“通行证”(将许可置为 1)。
- 如果该线程已经被
park阻塞了,它会被立刻唤醒。 - 如果该线程还在运行,那么它下次调用
park时不会阻塞,而是直接消耗掉这张通行证并通过。
park():- 检查当前线程有没有“通行证”。
- 如果有(1):消耗掉通行证(置为 0),方法直接返回,线程不阻塞。
- 如果没有(0):当前线程阻塞(挂起),让出 CPU,直到有人给它发通行证。
2. 它们与 Object.wait/notify 的区别(重点)
这是面试常考点,park/unpark 解决了 wait/notify 的两大痛点:
| 特性 | Object.wait() / notify() | LockSupport.park() / unpark() |
|---|---|---|
| 前提条件 | 必须在 synchronized 块内(必须先持有锁) | 不需要持有任何锁 |
| 唤醒目标 | notify() 随机唤醒一个,notifyAll() 唤醒所有 | unpark(t) 可以精准唤醒指定线程 |
| 顺序要求 | 严格有序。如果先 notify 再 wait,唤醒信号会丢失,导致线程永久等待。 | 顺序无关。可以先 unpark 给线程发通行证,线程稍后调用 park 时会直接通过,不会卡死。 |
3. ReentrantLock 如何使用它们?
ReentrantLock 的核心逻辑在 AQS 中。我们可以梳理一下线程抢锁失败的过程:
场景:线程 A 持有锁,线程 B 来抢锁
- 抢锁失败: 线程 B 尝试通过 CAS 修改 state 失败(发现锁被 A 占了)。
- 进入队列: AQS 将线程 B 包装成一个
Node,放入同步队列(双向链表)的尾部。 - 阻塞自己 (Park): 线程 B 进入一个死循环(
acquireQueued),判断自己是否轮到抢锁。如果没轮到,它会执行LockSupport.park(this)。- 此时,线程 B 在操作系统层面被挂起,不再占用 CPU。
场景:线程 A 释放锁
- 修改状态: 线程 A 将 state 减为 0。
- 寻找继承者: AQS 从同步队列的头部找到下一个合法的节点(通常是 head.next),也就是线程 B 对应的 Node。
- 唤醒继承者 (Unpark): AQS 调用
LockSupport.unpark(s.thread),其中s.thread就是线程 B。 - 线程 B 复活: 线程 B 的
park()方法返回,它继续在死循环里执行,发现锁空闲了,通过 CAS 拿到锁。
4. 源码验证
为了让你看得更清楚,这是 AQS (AbstractQueuedSynchronizer) 中的关键源码片段:
// AQS 阻塞线程的方法
private final boolean parkAndCheckInterrupt() {
// 这里就是核心!调用 LockSupport 挂起当前线程
LockSupport.park(this);
return Thread.interrupted();
}
// AQS 唤醒线程的方法
private void unparkSuccessor(Node node) {
// ... 前置逻辑 ...
Node s = node.next;
if (s != null) {
Thread t = s.thread;
if (t != null)
// 这里就是核心!调用 LockSupport 唤醒特定线程
LockSupport.unpark(t);
}
}总结
- ReentrantLock 底层就是
park和unpark。 - park = "没有通行证我就睡,除非有人给我送证"。
- unpark = "给你送张通行证(不管你在不在睡)"。
- 这种机制让 AQS 能够精准控制让哪个线程等待,唤醒哪个线程,而不需要担心“先唤醒后等待”导致的死锁问题。
释放锁流程
- setexclusiveThread=null
- setState=0
- 获取阻塞队列的头节点,判断头节点是否为null,头节点的waitstatus属性是否为-1
- 如果是,那么会唤醒(unpark)头节点的下一个节点
重入特性
就是判断state的值是多少,然后判断是否是自己持有的线程
每一次释放state减一,直到state等于0就真正的释放掉
不可打断原理(没有获得锁之前不响应打断,只记录,获得锁之后进行响应)
这是一个非常核心的设计细节。之所以在 acquireQueued 方法里维护 interrupted 局部变量,是因为 AQS 的 acquire 方法(包括 lock())采用的是 “不响应中断” 的策略。
简单来说:“你在排队抢锁的时候被人打断了(Thread.interrupt()),你不能直接甩手不干了(抛异常退出),你得忍着,先把锁抢到手再说。等你抢到了锁,再告诉外面‘刚才有人打断过我’。”
下面详细解释 interrupted 变量的作用和流程:
1. interrupted 变量的作用
它是一个记账本。
- 它的初始值是
false(假设没被中断)。 - 它的作用是记录:“在我这次漫长的、可能经历多次睡眠(Park)的抢锁过程中,有没有哪怕一次被人置位了中断标记?”
2. 为什么要修改它?
看这行代码:
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) // <--- 关键点
interrupted = true; // <--- 记账parkAndCheckInterrupt() 做了什么?
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // 1. 线程挂起,睡觉
return Thread.interrupted(); // 2. 醒来后,检查并清除中断标记!
}当线程在 park 状态下被 interrupt() 唤醒时:
- 线程醒来。
Thread.interrupted()返回true(说明被中断过)。- 副作用:
Thread.interrupted()会清除线程的中断标记(置为 false)。
为什么这里要清除?
因为如果不清除标记,下一次循环如果再次失败需要 park 时,LockSupport.park() 会失效(如果线程中断标记为 true,park 会立即返回,线程就无法阻塞,导致 CPU 100% 空转自旋)。 为了能让线程再次安心睡觉,必须把中断标记清掉。
为什么清除后又要记录到 interrupted 变量里?
因为不能吞掉中断信号! 虽然为了继续排队睡觉,我暂时把标记清了,但我得拿小本本(interrupted 变量)记下来:“刚才发生过中断”。 只要发生过一次(interrupted = true),这个 true 就会一直保留到循环结束。
3. 返回值的用途
当线程终于抢到锁(tryAcquire 成功)时:
return interrupted;方法返回 true,意味着:“报告长官,任务完成了(抢到锁了),但在执行任务过程中,我被人打断过。”
4. 谁在消费这个返回值?
回到调用链的上层:AbstractQueuedSynchronizer.acquire(int arg)
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) // <-- 这里拿到了返回值
selfInterrupt(); // <-- 如果返回 true,自己再打断自己一次
}selfInterrupt() 做的事情:
static void selfInterrupt() {
Thread.currentThread().interrupt(); // 补发中断
}总结流程
- 排队睡觉:线程在
acquireQueued里挂起。 - 被打断:外部调用
t.interrupt(),线程醒来。 - 擦除标记:
parkAndCheckInterrupt()发现被中断,为了下次能继续睡觉,擦除了中断标记,但返回true。 - 记账:
acquireQueued把interrupted设为true,表示“出过事”。 - 继续循环:线程继续尝试抢锁,可能又睡了几觉。
- 抢到锁:终于抢到了,方法返回
interrupted = true。 - 补发:最外层的
acquire方法看到返回了true,知道刚才吞了一次中断,于是调用selfInterrupt()重新把中断标记打上。
结论:在 acquireQueued 里修改 interrupted,是为了“暂存中断状态”。因为为了维持排队机制(能反复 Park),必须清除实际的中断标记;为了不丢失用户的中断请求,必须用局部变量存着,等抢到锁以后再补回去。
“再补回去”的意思是:恢复现场。
打个比方来解释这个过程:
场景:你在排队买限量版球鞋(抢锁)。
- 排队睡觉:队伍很长,你拿个小板凳坐着睡着了(
Park)。 - 被打断:这时候你妈给你打了个电话(
Interrupt),喊你回家吃饭。 - 醒来处理:你醒了,接了电话。
- 心里博弈:你想,“球鞋还没买到,现在回家就前功尽弃了。不行,我得继续排队。”
- 擦除标记:于是你挂了电话,假装没听到(
Thread.interrupted()清除标记),继续坐在板凳上排队。 - 记账:但是你在手心里写了个字:“妈找过我”(
interrupted = true)。
- 继续排队:你又睡了几觉,醒了几次,终于排到了窗口,买到了球鞋(
acquireQueued返回true)。 - 补回去:拿到球鞋走出队伍(
acquire方法结束),你看看手心里的字“妈找过我”。- 行动:于是你拿出手机,给你妈回拨了一个电话(
selfInterrupt),恢复了“妈妈找我”这个状态。 - 结果:这时候,你的代码逻辑(比如外层的业务代码)检测到你有未接来电(中断标记为 true),就可以决定是现在回家吃饭,还是继续去买裤子。
- 行动:于是你拿出手机,给你妈回拨了一个电话(
技术层面的解释
“补回去” = 重新执行 Thread.currentThread().interrupt()
在
acquireQueued的死循环里,为了保证线程能再次调用LockSupport.park()进入阻塞状态,我们被迫执行了Thread.interrupted(),这个操作把原本为true的中断标记给清零(变成false)了。- 注:如果标记是 true,LockSupport.park() 是无法阻塞线程的,它会立马返回。
这意味着,线程把外部的中断信号给“吞”了。如果不做处理,外面的业务代码根本不知道刚才发生过中断。
所以,等锁抢到了(跳出死循环了),AQS 有义务把刚才为了排队而“临时吞掉”的中断信号,原封不动地还给线程。
通过调用
selfInterrupt(),再次将线程的中断标记设为true。这样,当代码回到业务层(比如调用lock()之后的代码),业务逻辑可以通过Thread.currentThread().isInterrupted()检测到这一事实,并作出响应。
可打断原理
相当于在acquireQueue方法(实际上是doAcquireInterruptibly()方法)中,接受到了打断就直接抛出异常,马上处理,不会再进入for循环
公平锁的原理
非公平原理
在竞争锁时,只判断state属性是否为0,不判断阻塞队列是否为空
非公平原理
在竞争锁时会判断阻塞队列是否为空,如果不为空,就进入阻塞队列(其实还有几次自旋过程)
条件变量的实现原理
await()方法的调用流程
首先需要先创建一个Condition队列,然后在线程内部调用condition.await()方法
进入队列
await()方法会把当前线程封装为Node节点对象,waitstatus属性设置为-2,添加到这个ConditionObject双向链表中,对当前线程进行park()阻塞操作
释放锁
会把这个Node节点的线程所持有的锁全部释放,全部主要就是针对锁重入的情况
在release()方法中还会唤醒阻塞队列中的下一个节点
条件变量唤醒(signal()函数)
首先检查当前线程是否拿到了锁,只有拿到了锁才能进行操作
然后获取等待队列中的队首元素
把队首元素从等待队列中断开,然后把下一个元素变成队首元素,然后加入阻塞队列
加入阻塞队列过程会用cas尝试把Node节点的waitstatus字段修改为0,如果失败,可能的情况是节点的状态被设置成取消了,这样就会尝试唤醒下一个节点
如果成功了,就会加入阻塞队列,获取前驱节点,并把前驱节点的waitstatus状态改为0
为什么直接加入阻塞队列而不是去竞争锁
这是一个非常深入且触及 Java 并发核心机制(AQS)的问题。
简单来说,signal() 的核心目的是“转移资格”而不是“立即执行”。
ConditionObject 的唤醒方法(signal)把节点加入等待队列(Sync Queue,即 AQS 的同步队列)而不尝试直接获取锁,主要有以下 3 个核心原因:
1. 锁目前被“呼叫者”占用(最直接的原因)
调用 condition.signal() 的线程(我们称为 线程A)在调用该方法时,必须持有锁(否则会抛出 IllegalMonitorStateException)。
- 既然 线程A 手里还拿着锁,被唤醒的 线程B 就算立刻尝试去获取锁,也百分之百会失败。
- 如果让线程B此时去抢锁,它只会得到失败的结果,然后还得乖乖去排队。所以,直接把它放到 AQS 的同步队列里排队是最有效率的做法。
2. AQS 的架构设计:只有同步队列里的节点才有资格抢锁
在 AQS(AbstractQueuedSynchronizer)的架构中,有两个队列:
- Condition Queue(条件队列):装着调用了
await()的线程,它们在等待某个信号,完全不参与锁的竞争。 - Sync Queue(同步队列):装着等待获取锁的线程。
await() 方法执行结束的前提是 重新获取到锁。 当 signal() 发生时,意味着这个线程的“等待条件”已经满足了,它现在的目标变成了“获取锁并返回”。 因此,它必须从 Condition Queue 转移到 Sync Queue,因为只有站在 Sync Queue 里,才有资格在锁被释放时去参与竞争。
3. signal 通常并不立即“唤醒”线程(减少上下文切换)
这是一个常见的误区:signal() 往往并不立即执行 LockSupport.unpark() 唤醒线程。
它的实际操作流程(transferForSignal)通常是:
- 修改节点状态。
- 将节点从 Condition 队列尾部移除。
- 将节点插入到 AQS 同步队列的尾部(入队)。
- 只有当前驱节点状态异常(如已取消)时,才会 unpark 唤醒线程去修正状态。
正常的流程是: 被 signal 的线程依然保持挂起(Sleeping)状态,安静地呆在 AQS 队列里。直到 线程A 调用了 unlock() 释放锁,AQS 才会根据队列规则真正唤醒(unpark)后续的线程(可能就是刚才被 signal 的线程B)起来拿锁。
总结: 这样做是为了避免惊群效应和无谓的锁竞争。既然锁还在别人手里,最好的策略就是先去“排队区”(Sync Queue)占个位置,等持有锁的人真正释放了,再按顺序唤醒执行。