AQS 与 ReentrantLock学习笔记(2/2):ReentrantLock、Condition 与 synchronized 对比
上一篇读完了 AQS 内核:state、CLH 变体队列、LockSupport 的许可机制。但 AQS 是个框架,日常你手上拿的是 ReentrantLock 这个壳——它的获取方式有四种,各自的行为差别很大,tryLock() 甚至有个跟公平性冲突的细节。
真正体现 ReentrantLock 比 synchronized 灵活的地方是 Condition:一把锁可以挂多组等待队列,这是 Object.wait/notify 给不了的。最后把两者做一次彻底对比,顺带说清锁升级在 JDK 15 之后发生的事情。
版本说明:源码引用分两套,JDK 8 那套是面试主线;JDK 14 起 AQS 被整体重写,Condition 的实现也跟着变了,jstack 栈里能直接看出来。实测输出跑在本机 OpenJDK 26.0.1。
手把手实操:await 到底释不释放锁
先把最容易记错的一点焊死。await() 会释放锁——不是"看起来释放了",是真把 state 减到 0、让别的线程能拿到。跑一段验证:
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* 一、await() 会释放锁:把锁交给子线程,等它进 await 之后,主线程(此刻不持锁)
* 再 lock() 能立刻拿到,说明锁确实被 await 放开了。
* 二、await 期间线程停在 LockSupport.park,留 6 秒窗口供 jstack 抓取。
*/
public class ConditionAwaitDemo {
static final ReentrantLock LOCK = new ReentrantLock();
static final Condition COND = LOCK.newCondition();
static void log(String s) { System.out.println(s); System.out.flush(); }
static volatile boolean inAwait = false;
public static void main(String[] args) throws Exception {
LOCK.lock();
log("1. 主线程先持有锁");
Thread t = new Thread(() -> {
LOCK.lock(); // 主线程释放后才能拿到
try {
log("2. 子线程拿到锁,准备 await");
inAwait = true;
long t0 = System.currentTimeMillis();
COND.await(); // 进 await:先释放锁,再挂起
log("6. 子线程被 signal 唤醒并重新拿到锁,await 期间等待 "
+ (System.currentTimeMillis() - t0) + " ms");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
LOCK.unlock();
}
}, "await-demo-thread");
t.start();
Thread.sleep(300);
LOCK.unlock(); // 让子线程拿到锁
while (!inAwait) Thread.onSpinWait(); // 等它真的进了 await
Thread.sleep(300);
long t0 = System.currentTimeMillis();
LOCK.lock(); // 主线程此刻不持锁;若 await 没释放锁,这里会一直等
long cost = System.currentTimeMillis() - t0;
log("3. 子线程正在 await,主线程重新 lock() 耗时 " + cost + " ms -> 锁确实被 await 释放了");
log("4. jstack 窗口打开,子线程此刻停在 ConditionObject.await(留 6 秒)");
Thread.sleep(6000);
log("5. 主线程 signal()(必须持锁才能调用)");
COND.signal();
LOCK.unlock();
t.join();
log("7. 结束");
}
}
实测输出:
1. 主线程先持有锁
2. 子线程拿到锁,准备 await
3. 子线程正在 await,主线程重新 lock() 耗时 0 ms -> 锁确实被 await 释放了
4. jstack 窗口打开,子线程此刻停在 ConditionObject.await(留 6 秒)
5. 主线程 signal()(必须持锁才能调用)
6. 子线程被 signal 唤醒并重新拿到锁,await 期间等待 6305 ms
7. 结束
第 3 行是关键。主线程在子线程已经进 await 之后调 LOCK.lock(),耗时 0 ms——如果 await 没释放锁,这里会一直等到第 5 步 signal() 之后。所以 await() 的语义完整说是三步:释放锁 → 挂起等待 → 被唤醒后重新抢锁。
注意第三步,这个"重新抢锁"经常被漏掉,它直接决定了后面 if 还是 while 的写错后果。
第 6 行 6305 ms 也顺带说明:signal() 之后子线程不是立刻继续,而是要走完"从条件队列搬回同步队列 → 重新竞争锁 → 抢到"这条路。
ReentrantLock 的四种获取方式
synchronized 只有一种获取方式:抢不到就一直等,不响应中断、不能超时。ReentrantLock 有四种,是它最主要的实用性优势。
| 方法 | 抢不到时的行为 | 响应中断 |
|---|---|---|
lock() | 一直等(进 AQS 队列 park) | 否 |
lockInterruptibly() | 一直等,但可被中断抛异常 | 是 |
tryLock() | 立即返回 false,不等待 | 否 |
tryLock(timeout, unit) | 等指定时长,超时返回 false | 是 |
lockInterruptibly() 的价值在"可取消"。任务被提交到线程池,用户点了取消,如果任务卡在 synchronized 上,唯一的下场是把线程一起废掉;用 lockInterruptibly() 就能优雅退出:
try {
if (!lock.tryLock(3, TimeUnit.SECONDS)) {
// 3 秒都没抢到:记日志、降级、直接返回,绝不无限等
return Result.timeout();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复中断标记,别吞掉
return Result.cancelled();
}
try {
// 临界区
} finally {
lock.unlock();
}
这里有个很容易踩的坑:tryLock(timeout) 和 lockInterruptibly() 在超时或中断时返回/抛出,此时根本没拿到锁,所以后面的 unlock() 绝对不能执行。写 finally { lock.unlock(); } 把整个 try 包进去,会抛 IllegalMonitorStateException。规范写法就是上面这样,把 lock() 单独拿出来判。
还有一个公平性冲突的细节,查了 JDK 源码才注意到:tryLock() 定义在抽象的 Sync 上并且是 final 的,公平锁和非公平锁都不覆写它:
// ReentrantLock$Sync(非公平锁和公平锁共用这一个实现)
final boolean tryLock() {
Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, 1)) { // 直接 CAS 抢,没检查队列
setExclusiveOwnerThread(current);
return true;
}
} else if (getExclusiveOwnerThread() == current) {
if (++c < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(c);
return true;
}
return false;
}
对比一下公平锁的 initialTryLock()——它是会检查 hasQueuedThreads() 的。但 tryLock() 绕过了这个检查,直接 CAS 插队。 所以即使你声明了 new ReentrantLock(true),调 tryLock() 依然会让队伍前面的线程被插队。
这个设计是不得已:tryLock() 的语义是"立即返回,绝不阻塞",如果它还要先排队,语义就不成立了。理解这一点,就不会在"公平锁为什么没按顺序"的问题上纠结。
查询类 API 顺手记一下:isFair()、isLocked()、isHeldByCurrentThread()、getHoldCount()(重入了几次)、hasQueuedThreads()、getQueueLength()(队列里排了多少个)。synchronized 一个都没有——这是"灵活"这个词最实在的落点。
Condition:一把锁配多组等待队列
这是 ReentrantLock 相比 synchronized 真正的结构性优势。
Object 上的 wait/notify/notifyAll 只有一个等待集。一个生产者-消费者里,"队列满了要等"和"队列空了要等"两类线程混在同一个等待集里,生产者 notify() 唤醒的可能是另一个生产者——白白唤醒,还可能造成死锁。所以用 synchronized 写这段,只能一律 notifyAll(),唤醒所有线程让它们自己抢,效率很低。
Condition 直接解决这个问题:一把 ReentrantLock 可以 newCondition() 出任意多个等待队列,每类线程等自己的那个:
ReentrantLock lock = new ReentrantLock();
Condition notFull = lock.newCondition(); // 生产者等这个
Condition notEmpty = lock.newCondition(); // 消费者等这个
这也是本文后面那个生产者-消费者示例能同时写"满等、空等"两支逻辑的原因。
await 和 signal 在内部干了什么
用上一篇的说法对一下,就知道为什么说"条件队列"和"同步队列"是两条队列:
await():当前线程必须先持有锁。它把当前线程包装成一个节点,挂到 Condition 的条件队列(firstWaiter/lastWaiter单向链)上,然后完全释放锁(state减到 0),再park挂起。signal():也必须先持有锁。它把条件队列里队首那个节点摘下来,转移到 AQS 的同步队列尾部——注意只是"搬回去排队",没有唤醒线程去抢锁,被搬走的线程还要重新竞争。signalAll():把条件队列里所有节点都搬到同步队列。
所以 signal() 唤醒的是队首那一个,且唤醒不等于拿到锁。这个"两步走"是理解后面所有问题的钥匙。
下面这张图把两条队列和一次搬家画在一起,顺带对比 await / signal / signalAll 三个动作各自做了什么:
盯住图里那条绿色虚线——它是"搬家",不是"交还锁"。线程 A 从条件队列挪到同步队列尾部之后,身份变成了一个普通的等锁线程,跟线程 X、线程 Y 排在一起重新竞争。这正是下面 if / while 实验要验证的机制。
jstack 里怎么认出它在等 Condition
上面那个程序的第 4 步窗口期内抓 jstack,子线程的栈是这样的(地址和线程号做了简化):
"await-demo-thread" #27 prio=5 os_prio=0 ... waiting on condition
java.lang.Thread.State: WAITING (parking)
at jdk.internal.misc.Unsafe.park(Native Method)
- parking to wait for <0x...b28> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:369)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(AbstractQueuedSynchronizer.java:520)
at java.util.concurrent.ForkJoinPool.unmanagedBlock(ForkJoinPool.java:4367)
at java.util.concurrent.ForkJoinPool.managedBlock(ForkJoinPool.java:4313)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:1752)
at ConditionAwaitDemo.lambda$main$0(ConditionAwaitDemo.java:27)
关键看 parking to wait for 后面跟的对象。上一篇等锁时这是一把 ReentrantLock$NonfairSync;这里是 AbstractQueuedSynchronizer$ConditionObject。线程状态都是 WAITING (parking),光看状态分不出来,只能靠这个对象区分"在等锁"还是"在等条件"。
另外注意 ConditionNode.block 这一层。JDK 14 起把 ConditionNode 做成了 ForkJoinPool.ManagedBlocker 的实现,所以 await 挂起时走的是 managedBlock → unmanagedBlock → ConditionNode.block → LockSupport.park。JDK 8 里这条链短得多,直接就是 ConditionObject.await 调 LockSupport.park(this)。这个"绕了一圈 ForkJoinPool"的栈,是 JDK 14+ 的典型指纹。
手把手实操:if 和 while 的真实差距
await() 会释放锁、唤醒后要重新抢锁——这两件事合起来意味着:线程被唤醒的那一刻,它等待的条件未必还成立。
signal() 只是"把节点搬回同步队列",搬回去之后这个线程要重新抢锁。在它抢到锁之前,完全可能有别的线程先抢到锁、把条件又改回去了。所以醒来之后必须重新检查条件,这就决定了等待条件是写在 while 里还是 if 里。
写个实验把差距量化。容量故意设成 1,3 个消费者,生产 20000 条,signalAll() 唤醒所有等待者放大破绽:
import java.util.ArrayDeque;
import java.util.Deque;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
/**
* 同一个生产者-消费者实现,只把等待条件的 if 换成 while,结果完全不同。
* 用法:java ConditionIfBreakDemo if|while
*/
public class ConditionIfBreakDemo {
static final int CAP = 1; // 容量故意设成 1,放大竞争
static final int ITEMS = 20000;
static final ReentrantLock LOCK = new ReentrantLock();
static final Condition NOT_FULL = LOCK.newCondition();
static final Condition NOT_EMPTY = LOCK.newCondition();
static final Deque<String> QUEUE = new ArrayDeque<>();
static final AtomicInteger CONSUMED = new AtomicInteger();
static final AtomicInteger GOT_NULL = new AtomicInteger();
static volatile boolean running = true;
public static void main(String[] args) throws Exception {
boolean useWhile = args.length > 0 && args[0].equals("while");
Runnable consumer = () -> {
while (true) {
LOCK.lock();
try {
// 只有"队列已空且收到关停"才退出,保证两个版本都会把队列排空
if (QUEUE.isEmpty()) {
if (useWhile) {
while (QUEUE.isEmpty()) {
if (!running) return;
NOT_EMPTY.await(); // 正确:醒来回到 while 再检查
}
} else {
if (!running) return;
NOT_EMPTY.await(); // 错误:醒来直接往下走
}
}
String msg = QUEUE.pollFirst();
if (msg == null) {
GOT_NULL.incrementAndGet(); // 被空取了
} else {
CONSUMED.incrementAndGet();
}
NOT_FULL.signal();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} finally {
LOCK.unlock();
}
}
};
Thread c1 = new Thread(consumer, "consumer-1");
Thread c2 = new Thread(consumer, "consumer-2");
Thread c3 = new Thread(consumer, "consumer-3");
c1.start(); c2.start(); c3.start();
for (int i = 0; i < ITEMS; i++) {
LOCK.lock();
try {
while (QUEUE.size() == CAP) NOT_FULL.await();
QUEUE.addLast("msg-" + i);
NOT_EMPTY.signalAll(); // 唤醒所有等待者,放大 if 的破绽
} finally {
LOCK.unlock();
}
}
running = false;
LOCK.lock();
try { NOT_EMPTY.signalAll(); } finally { LOCK.unlock(); }
c1.join(5000); c2.join(5000); c3.join(5000);
System.out.println("等待条件用 " + (useWhile ? "while" : "if ")
+ " -> 生产 " + ITEMS + " 条,成功消费 " + CONSUMED.get()
+ " 条,取到 null(被空取) " + GOT_NULL.get() + " 次"
+ ",线程是否都退出 " + (!c1.isAlive() && !c2.isAlive() && !c3.isAlive()));
}
}
两个版本的退出条件写成对称的(都是"队列空且收到关停才退出"),保证它们都会把队列排空,唯一的差别就是 if 和 while。跑起来:
javac ConditionIfBreakDemo.java
java ConditionIfBreakDemo if
java ConditionIfBreakDemo while
实测(if 版连跑 3 轮,while 版 2 轮):
等待条件用 if -> 生产 20000 条,成功消费 20000 条,取到 null(被空取) 39931 次,线程是否都退出 true
等待条件用 if -> 生产 20000 条,成功消费 20000 条,取到 null(被空取) 39941 次,线程是否都退出 true
等待条件用 if -> 生产 20000 条,成功消费 20000 条,取到 null(被空取) 39921 次,线程是否都退出 true
等待条件用 while -> 生产 20000 条,成功消费 20000 条,取到 null(被空取) 0 次,线程是否都退出 true
if 版空取了将近 4 万次,while 版一次都没有。
为什么是 4 万这个量级?容量是 1,3 个消费者总有两个在等。生产者每放一条就 signalAll(),两个等着的消费者被同时搬回同步队列,然后依次抢锁:第一个抢到的把唯一那条数据取走,第二个抢到锁时队列已经空了,但它的 if 判断早在 await 之前就做过了,醒来不会再检查,于是直接 pollFirst() 拿到 null——白醒一次,还可能把 null 当成数据往下传。
这里要澄清一个常见混淆:这不是"虚假唤醒(spurious wakeup)"。虚假唤醒指的是没有任何 signal、park 自己返回,那是操作系统层面的行为。上面的空取是实实在在的 signalAll 唤醒了它,只是唤醒时条件已经不成立——这是正常唤醒,问题出在代码没重新检查条件上。
所以规则很硬:await() 永远放在 while 里,不要放 if 里。顺带一提,即使没有别的线程来抢,await() 在 JDK 里也允许自己返回(规范明确写了调用方要放在循环里),所以这条规则没有例外。
ReentrantLock 与 synchronized 的全面对比
| 维度 | synchronized | ReentrantLock |
|---|---|---|
| 实现层 | JVM 内置(monitor,字节码 monitorenter/monitorexit) | Java 层(AQS),ReentrantLock$Sync |
| 加解锁配对 | 编译器保证,自动释放 | 手动,unlock() 必须放 finally |
| 抢不到时 | 一直等 | 四选一:等 / 可中断等 / 立即返回 / 超时返回 |
| 响应中断 | 不支持 | lockInterruptibly()、tryLock(timeout) |
| 超时 | 不支持 | tryLock(timeout, unit) |
| 公平性 | 只有非公平 | 可选公平(构造参数 true) |
| 等待队列 | 一个等待集 | 一把锁配 多个 Condition |
| 状态查询 | 无 | isLocked / isHeldByCurrentThread / getQueueLength 等 |
| 是否可被优化 | 编译期锁消除、锁粗化、逃逸分析 | 无法,是普通对象方法调用 |
| 出错表现 | 卡在 monitor 上 BLOCKED | WAITING (parking),且忘写 unlock 会永久泄漏 |
选型建议直接给结论:能用 synchronized 就用 synchronized(代码短、不会忘记释放、JVM 还能做优化);需要"超时/可中断/多等待条件/公平性"这四样里的任何一样,才用 ReentrantLock。反过来说,只因为"听说 ReentrantLock 性能好"就换过去,是没有依据的——JDK 6 之后 synchronized 的优化已经很充分,空跑场景下两者难分高下,真正的差距在功能而不在裸性能。
锁升级:JDK 15 之后这件事变了
背过八股的话,你现在脑子里大概是这条链:
无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁
放在今天,这条链的中间一环基本可以删掉了。
偏向锁解决的是"同一个线程反复进入同一个临界区"的场景:第一个拿到锁的线程,JVM 直接把它的线程 ID 记在对象头 Mark Word 里,下次它再来,比对一下 ID 就进去了,连 CAS 都不用。看起来很美,代价是撤销偏向的成本很高——一旦有第二个线程来抢,要等一个安全点(safepoint)才能撤销,而这个等待会拖慢整个 JVM。JDK 15 的 JEP 374 直接把它废弃并默认禁用。
到本机这个 JDK 26 上,连开关都不存在了:
$ java -XX:+UseBiasedLocking -version
Unrecognized VM option 'UseBiasedLocking'
Error: Could not create the Java Virtual Machine.
所以现在正确的说法是:synchronized 的锁状态是 无锁 → 轻量级锁(CAS 自旋)→ 重量级锁(monitor) 三级,偏向锁是 JDK 15 之前的历史。
轻量级到重量级的升级路径还是值得记:轻量级锁靠 CAS 在 Mark Word 里换指针,如果 CAS 自旋失败到一定次数(或等待线程数变多),就升级成重量级锁,把竞争线程挂到 monitor 的 _EntryList/_WaitSet 上,走操作系统的互斥量。面试问"锁升级"时按三级答,再补一句"偏向锁在 JDK 15 被 JEP 374 废弃、在更新版本里连 VM 参数都移除了"——这一句就能把"看的还是老书"的印象翻过来。
面试速答
Q:await() 会释放锁吗?被唤醒之后呢?
A:会。完整语义是"释放锁 → 挂起 → 被唤醒后重新抢锁"。释放锁是立即生效的(实测中主线程在子线程 await 期间重新 lock() 耗时 0 ms)。但唤醒后必须重新竞争锁,且竞争到的时刻条件未必仍成立,所以等待条件要写在 while 里。
Q:Condition 和 Object.wait/notify 的区别?
A:一个 ReentrantLock 可以创建多个 Condition,就等于有多个独立的等待队列,"队列满"和"队列空"分开等,signal() 能精准唤醒对应那一类线程。Object 只有一个等待集,只能一律 notifyAll(),效率低还容易出问题。另外 Condition.await() 内部是 AQS 的条件队列,signal() 把节点从条件队列转移到同步队列,不是直接唤醒。
Q:signal() 和 signalAll() 怎么选?
A:signal() 只把条件队列队首那个节点搬到同步队列。如果等待的条件对所有等待线程都一样(比如"队列非空"),且每次只可能有一个线程被满足,用 signal() 就够。判断不准就用 signalAll()——它只是多几次无效唤醒,比"该醒的没醒"导致死锁安全得多。
Q:公平锁下手动调用 tryLock() 会插队吗?
A:会。tryLock() 定义在 ReentrantLock$Sync 上且是 final,公平锁和非公平锁都不覆写,内部直接 compareAndSetState(0, 1),没有检查队列里有没有前驱。因为 tryLock() 的契约是"立即返回不阻塞",加排队就违背契约了。要按顺序就得用 lock()。
Q:unlock() 写在 finally 里,有什么陷阱?
A:如果 tryLock(timeout) 返回 false 或 lockInterruptibly() 抛异常,说明根本没拿到锁,此时执行 unlock() 会抛 IllegalMonitorStateException。正确做法是把获取锁和临界区分成两段 try:获取失败就 return,只对成功获取的那段用 finally { unlock(); }。
Q:synchronized 的锁升级过程?
A:现在只有三级:无锁 → 轻量级锁(Mark Word 里 CAS 换指针 + 自旋)→ 重量级锁(自旋失败,挂到 monitor 上走操作系统互斥量)。偏向锁在 JDK 15 被 JEP 374 废弃并默认禁用,实测在本机 JDK 26 上 -XX:+UseBiasedLocking 直接报 Unrecognized VM option,参数已经彻底移除。
Q:怎么选 synchronized 还是 ReentrantLock?
A:默认 synchronized。只有需要"超时获取、可中断获取、一把锁多组等待条件、公平锁"这四样之一时,才用 ReentrantLock。它不是性能更好,而是功能更多。
核心收获与下一步
ReentrantLock 的价值集中在两处:获取方式有四种(等 / 可中断等 / 立即返回 / 超时返回),一把锁能挂多组 Condition。这两样 synchronized 都给不了。
Condition 的机制记成两条队列的搬家:await() 把节点挂到条件队列并释放锁,signal() 把节点从条件队列搬到同步队列,然后由 AQS 那套逻辑重新竞争锁。因为中间隔着"重新竞争"这一步,await() 必须写在 while 里。
最后一条版本相关的:synchronized 的锁升级现在是无锁 → 轻量级 → 重量级三级,偏向锁在 JDK 15 之后已经出局,别再背成四级。
下一步:把 ConditionIfBreakDemo 里的 while 改成 if,加个计数器跑一遍,亲眼看着空取次数从 0 变成几万次——这比背"要写 while"有效得多。然后把 getQueueLength() 打进日志,观察生产快消费慢的时候同步队列是怎么涨起来的。
本文关键词:ReentrantLock、Condition、await、signal、锁升级、synchronized