ReentrantLock 公平锁/非公平锁的实现,以及 signal 如何通知线程
ReentrantLock 公平锁/非公平锁的实现,以及 signal 如何通知线程
面试指北 · 去哪儿
题意
这道题表面上是两个问题:ReentrantLock 的公平锁和非公平锁怎么实现,以及 Condition.signal() 怎么通知一个线程。实际考察的是对 AQS 的理解:独占锁的获取与释放、state 的含义、同步队列与条件队列的区别、公平性策略,以及 await/signal 的线程协作流程。
回答时不能只背“公平锁排队、非公平锁插队”,还要说清楚“在哪里排队”“signal 把线程移动到了哪个队列”“被通知后是不是立刻执行”。
结论
ReentrantLock基于 AQS 实现,内部通过Sync、FairSync、NonfairSync区分公平与非公平模式,默认是非公平锁。- 公平锁在
tryAcquire中会调用hasQueuedPredecessors(),如果同步队列里存在比当前线程更早等待的线程,当前线程就不能抢锁,只能入队排队。 - 非公平锁在
lock()时先直接 CAS 抢一次锁,tryAcquire里也不检查队列,所以新来的线程可能插队;吞吐量通常更高,但可能造成线程饥饿。 Condition依赖 AQS 的条件队列。await()会释放锁并把当前线程加入条件队列;signal()会把条件队列中的一个节点转移到同步队列,让它在后续竞争锁。signal()不是“直接让线程继续执行”,它不会释放锁。被通知的线程只是从条件队列转到同步队列,仍然要等锁释放后重新竞争到锁,才能从await()返回。
要点展开
1. ReentrantLock 与 AQS 的基本关系
ReentrantLock 是可重入的互斥锁。可重入的意思是:同一个线程已经持有锁时,再次调用 lock() 仍然可以成功,AQS 的 state 会递增;每 unlock() 一次递减,减到 0 才真正释放锁。
创建锁时可以指定模式:
ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
ReentrantLock nonFairLock = new ReentrantLock(false); // 非公平锁
ReentrantLock defaultLock = new ReentrantLock(); // 默认非公平锁
AQS 内部维护一个 state 和一个同步队列。state == 0 表示锁空闲,大于 0 表示被某个线程持有,数值代表重入次数。获取锁失败的线程会被包装成 Node 放入同步队列,并通过 LockSupport.park() 挂起,等待前驱节点释放锁后唤醒。
公平锁和非公平锁的差异,核心就在“尝试获取锁时要不要看同步队列”。
2. 公平锁的实现
公平锁强调先来后到。线程调用 lock() 后,会走 AQS 的 acquire(1),进而调用 FairSync.tryAcquire()。简化后的核心代码如下:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 只有同步队列中没有前驱等待线程时,才允许 CAS 抢锁
if (!hasQueuedPredecessors() && 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;
}
这里最关键的是 hasQueuedPredecessors()。它判断的是:同步队列中是否存在比当前线程更早等待的线程。如果存在,当前线程就不能“截胡”,tryAcquire 返回 false,随后 AQS 会把它加入同步队列排队。
公平锁的优点是顺序清晰,不容易饥饿;缺点是严格排队会带来更多线程切换和唤醒开销,性能通常略低于非公平锁。
3. 非公平锁的实现
非公平锁允许新来的线程先尝试抢锁,即使同步队列里已经有线程在等。NonfairSync.lock() 的典型逻辑是:
final void lock() {
// 先直接抢一次,不管队列里有没有人等
if (compareAndSetState(0, 1)) {
setExclusiveOwnerThread(Thread.currentThread());
} else {
acquire(1);
}
}
进入 tryAcquire 后,非公平锁也不会调用 hasQueuedPredecessors() 检查队列。简化后的核心逻辑是:
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
// 非公平锁直接 CAS 抢锁,不检查队列
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;
}
非公平锁的核心特点是:锁释放的一瞬间,新来的线程可能比同步队列里被唤醒的线程更快抢到锁。这样减少了“唤醒线程后还要等它被调度”的空档,吞吐量通常更好。但它不是完全无序,更不是永远不排队;只是允许在锁空闲的窗口期插队。代价是极端情况下某些线程可能长时间抢不到锁。
可以用一张表对比:
| 对比项 | 公平锁 | 非公平锁 |
|---|---|---|
| 是否检查同步队列 | tryAcquire 中检查 hasQueuedPredecessors() |
不检查队列 |
| 是否允许插队 | 不允许 | 允许 |
| 吞吐量 | 通常略低 | 通常更高 |
| 饥饿风险 | 较低 | 相对更高 |
| 默认模式 | 否 | 是 |
4. Condition 与 signal 的通知流程
ReentrantLock 通过 newCondition() 创建 Condition,用来替代 synchronized 配合 wait/notify 的线程通信。一个锁可以创建多个 Condition,每个 Condition 维护自己的条件队列,因此可以实现更精准的唤醒。
Condition 的关键是两套队列:
- 同步队列:等待获取锁的线程队列,AQS 的双向链表。
- 条件队列:调用
await()后等待条件的线程队列,ConditionObject内部的单向链表。
await 做了什么
线程调用 condition.await() 时,大致流程是:
- 检查当前线程是否持有锁,没有持有就抛
IllegalMonitorStateException。 - 把当前线程包装成 Node,加入当前
Condition的条件队列,节点状态为CONDITION。 - 完全释放锁,包括重入次数。也就是说,如果线程重入了 3 次,这里会一次性释放到
state == 0,并唤醒同步队列中的后继节点。 - 通过
LockSupport.park()挂起当前线程。 - 当其他线程调用
signal()把它转移到同步队列后,它需要重新竞争锁;竞争成功后,从await()返回,并恢复之前的重入计数。
signal 做了什么
signal() 的核心逻辑是:检查当前线程是否持有锁,然后把条件队列中的第一个有效节点转移到同步队列。简化源码如下:
public final void signal() {
if (!isHeldExclusively()) {
throw new IllegalMonitorStateException();
}
Node first = firstWaiter;
if (first != null) {
doSignal(first);
}
}
private void doSignal(Node first) {
do {
if ((firstWaiter = first.nextWaiter) == null) {
lastWaiter = null;
}
first.nextWaiter = null;
} while (!transferForSignal(first) &&
(first = firstWaiter) != null);
}
final boolean transferForSignal(Node node) {
// 把节点从 CONDITION 状态改为普通状态
if (!compareAndSetWaitStatus(node, Node.CONDITION, 0)) {
return false;
}
// 加入同步队列,返回前驱节点
Node p = enq(node);
int ws = p.waitStatus;
// 如果前驱已取消,或者设置 SIGNAL 失败,就直接唤醒该线程
if (ws > 0 || !compareAndSetWaitStatus(p, ws, Node.SIGNAL)) {
LockSupport.unpark(node.thread);
}
return true;
}
这段代码可以拆成几个关键点:
signal()必须在持有锁时调用,否则抛IllegalMonitorStateException。doSignal()从firstWaiter开始,把节点从条件队列摘下。transferForSignal()把节点的等待状态从CONDITION改为 0,然后通过enq()加入同步队列。- 如果前驱节点状态正常,就把前驱标记为
SIGNAL,表示前驱释放锁时负责唤醒它;如果前驱已经取消或设置失败,就直接unpark。 - 被通知的线程并没有立刻拿到锁,它只是进入了同步队列。等
signal()的调用方执行unlock()后,它才有机会被唤醒并重新竞争锁。
这也是面试里最容易答错的地方:signal() 不是“让线程马上运行”,而是“把线程从条件队列转移到同步队列,给它一个重新竞争锁的资格”。
下面保留一个使用示例:
ReentrantLock lock = new ReentrantLock();
Condition condition = lock.newCondition();
new Thread(() -> {
lock.lock();
try {
System.out.println("Thread 1: Waiting...");
condition.await(); // 进入条件队列并释放锁
System.out.println("Thread 1: Woken up!");
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}).start();
new Thread(() -> {
lock.lock();
try {
System.out.println("Thread 2: Signaling...");
condition.signal(); // 唤醒条件队列中的一个线程
} finally {
lock.unlock();
}
}).start();
这个例子只用于演示 API。真实业务里,等待条件通常要放在 while 循环中判断,避免虚假唤醒或信号丢失;同时要确保等待线程已经进入 await(),否则先 signal() 后 await() 可能导致等待线程错过通知。
易错点与对比
-
signal()不会释放锁。
调用signal()后通常要在finally中unlock()。如果只signal()不释放锁,被转移到同步队列的线程仍然拿不到锁。 -
signal()不是直接唤醒执行。
它只是把节点从条件队列转移到同步队列。被通知线程还要重新竞争锁,竞争成功后await()才返回。 -
await()和signal()都必须在锁内调用。
否则会抛IllegalMonitorStateException。这一点和Object.wait()/notify()必须持有 monitor 类似。 -
条件队列不是同步队列。
条件队列存放的是调用await()等待条件的线程;同步队列存放的是等待获取锁的线程。signal()的本质是完成一次“条件队列到同步队列”的转移。 -
公平锁不是绝对按操作系统调度顺序执行。
它保证的是:在锁的获取逻辑上,不允许新线程越过同步队列中的前驱线程。线程真正谁先被 CPU 调度,仍受操作系统影响。 -
非公平锁不是完全不排队。
它只是允许在锁空闲时插队;获取失败后仍然会进入同步队列。非公平锁吞吐高,但极端情况下可能让某些线程等待较久。 -
hasQueuedPredecessors()不是简单判断队列是否为空。
它判断的是同步队列中是否存在比当前线程更早等待的前驱节点。公平锁正是靠这个判断来避免插队。 -
Condition比wait/notify更灵活。
一个锁可以创建多个Condition,实现分组等待和精准唤醒;Object.wait/notify只有一个等待集,notify()唤醒哪个线程并不由业务指定。 -
等待条件要用
while而不是if。
await()可能被虚假唤醒,也可能出现通知先于等待的情况。标准写法是:
java lock.lock(); try { while (!conditionSatisfied) { condition.await(); } // 条件满足后的业务逻辑 } finally { lock.unlock(); }
小结
面试回答可以按这个顺序组织:
先一句话定性:ReentrantLock 基于 AQS,公平锁和非公平锁的差异集中在 tryAcquire 是否检查同步队列;默认是非公平锁。然后展开公平锁的 hasQueuedPredecessors() 和非公平锁的“先 CAS 抢一次、不检查队列”。最后讲 Condition:await() 释放锁并进入条件队列,signal() 在持锁状态下把条件队列节点转移到同步队列,被通知线程仍然要重新竞争锁才能继续执行。
把这几个点讲清楚,基本就能覆盖“公平/非公平锁实现”和“signal 怎么通知线程”这两个核心问题。