【多线程】Lock 与 Condition
Lock 与 Condition:比 synchronized 多给了你什么?
文章目录
- [【多线程】Lock 与 Condition](#【多线程】Lock 与 Condition)
-
- 引言
- 一、场景故事:它在解决什么问题?
- 二、核心机制:从代码看原理
-
- [2.1 ReentrantLock 标准骨架与三大增强](#2.1 ReentrantLock 标准骨架与三大增强)
- [2.2 多 Condition:精准唤醒的交替打印](#2.2 多 Condition:精准唤醒的交替打印)
- [2.3 生产者-消费者:手写有界缓冲区](#2.3 生产者-消费者:手写有界缓冲区)
- 三、理解偏差:这些坑别踩
-
- [偏差 1:判断条件用 if 就够了](#偏差 1:判断条件用 if 就够了)
- [偏差 2:不持有锁也能调用 await / signal](#偏差 2:不持有锁也能调用 await / signal)
- [偏差 3:unlock 不放 finally 也行,反正没抛异常](#偏差 3:unlock 不放 finally 也行,反正没抛异常)
- [偏差 4:公平锁更公平,所以应该都用公平锁](#偏差 4:公平锁更公平,所以应该都用公平锁)
- [易混淆点:Lock + Condition vs synchronized + wait/notify](#易混淆点:Lock + Condition vs synchronized + wait/notify)
- [四、记忆卡片:核心 QA 对](#四、记忆卡片:核心 QA 对)
- 五、知识网络:相似、互斥与学习路径
-
- [🔗 相似知识(同类不同派):Object.wait / notify](#🔗 相似知识(同类不同派):Object.wait / notify)
- [⚔️ 互斥/替代知识(通常二选一):synchronized vs ReentrantLock](#⚔️ 互斥/替代知识(通常二选一):synchronized vs ReentrantLock)
- [📚 前置与后续(学习路径)](#📚 前置与后续(学习路径))
- 六、配套代码运行指南
- 结语

引言
上一篇讲到,synchronized 是互斥的默认选择,但它有四个先天限制:
- 拿不到锁只能死等,不能超时放弃;
- 等锁的过程不可中断,死锁了只能杀进程;
- 全局只有一个等待队列 ,
notifyAll()会把不相干的线程全部吵醒; - 无法选择公平策略,总有线程被"饿死"。
ReentrantLock + Condition 就是 JDK 对这四个限制的逐一回应。本文通过三个递进的 Demo 讲透:标准用法与三大增强 → 多 Condition 精准唤醒 → 手写生产者-消费者 。第三个 Demo 写出来的缓冲区,就是下一篇 BlockingQueue 的内部原理。
一、场景故事:它在解决什么问题?
📖 故事一:医院的分诊叫号
某小诊所只有一位坐诊医生,候诊大厅里挤着看各种病的病人。轮到下一位时,前台直接用大厅广播喊:"下一位!"------结果所有人都涌向诊室门口重新排队抢,只有一个人能进去,其余人骂骂咧咧回到座位,白跑一趟,大厅嘈杂又低效。
后来诊所改造:按科室分成三个诊室,各自门口有等候区 。挂骨科的去骨科门口坐着,挂眼科的去眼科门口坐着。医生看完一个骨科病人,只喊"下一位骨科"------只有骨科门口的病人会被叫到,眼科病人安心坐着刷手机,一次都不用白跑。
💡 对应关系:
| 故事中 | 技术中 |
|---|---|
| 唯一的医生(诊室) | ReentrantLock(同一时刻只放一个线程进入) |
| 大厅广播"下一位" | notifyAll():唤醒整个等待集合 |
| 各科室门口的等候区 | 一个个 Condition(独立等待队列) |
| 只叫"下一位骨科" | condition.signal():精准唤醒指定队列 |
| 病人坐下等叫号 | condition.await():释放锁并进入等待队列 |
📖 故事二:面包店的烤盘
面包店后厨有一个 5 格的烤盘 。烤面包的师傅发现烤盘满了 ,只能停下来歇着,等有人买走一个腾出空位;来买面包的顾客发现烤盘空了,也只能等着,等师傅烤好一炉。空位和面包,就是师傅和顾客之间无声的"暗号"------谁也不催谁,条件不满足就各自等着,条件一满足就自动继续。
💡 对应关系:
| 故事中 | 技术中 |
|---|---|
| 5 格烤盘 | 容量为 5 的有界缓冲区 |
| 烤盘满 → 师傅歇着 | 生产者在 notFull 条件上 await() |
| 烤盘空 → 顾客等着 | 消费者在 notEmpty 条件上 await() |
| 买走一个 → 叫醒师傅 | 消费后 notFull.signal() |
基于这两个直觉,我们来看正式机制。
二、核心机制:从代码看原理
2.1 ReentrantLock 标准骨架与三大增强
java
// Demo01_Basic.java
static final ReentrantLock lock = new ReentrantLock();
static int count = 0;
// 标准骨架(必须背下来):lock 一定配 try/finally
Runnable task = () -> {
for (int i = 0; i < 10000; i++) {
lock.lock(); // 注意:lock() 放在 try 外面
try {
count++; // 业务代码(临界区)
} finally {
lock.unlock(); // 放 finally:业务抛异常,锁也必须释放
}
}
};
与 synchronized 相比,ReentrantLock 的三大增强在 Demo01 中逐一演示:
java
// 增强 1:tryLock ------ 拿不到锁立刻返回 false,去干别的,不傻等
if (lock.tryLock()) {
try { /* 拿到了,处理 */ } finally { lock.unlock(); }
} else {
System.out.println("没拿到锁,先去做别的事了(没有阻塞)");
}
// 增强 2:lockInterruptibly ------ 等锁期间可以被 interrupt 打断
try {
lock.lockInterruptibly(); // synchronized 等锁时永远不可打断,这里可以
try { /* ... */ } finally { lock.unlock(); }
} catch (InterruptedException e) {
System.out.println("等锁时被打断,放弃获取锁"); // 死锁的解药之一
}
// 增强 3:公平锁 ------ new ReentrantLock(true),先来先得
ReentrantLock fairLock = new ReentrantLock(true); // 默认 false = 非公平(吞吐更高)
💡 代码要点:
unlock()必须放 finally:synchronized 由 JVM 保证异常时自动释放,而 Lock 是普通 API,忘写 unlock 就意味着锁永远不会释放------这是从 synchronized 迁移到 Lock 时最常见的致命错误。lock.lock()写在try外面 :如果写在 try 内且lock()本身失败,finally 里的unlock()会释放一把从未获得的锁,抛出IllegalMonitorStateException。- "Reentrant(可重入)"与 synchronized 一样:同一线程可重复
lock(),unlock()次数必须与之配对。 - 公平锁保证先来先得、无线程饿死,但需要维护队列顺序,吞吐量低于默认的非公平锁。绝大多数场景用默认非公平即可。
2.2 多 Condition:精准唤醒的交替打印
synchronized 的所有等待线程挤在一个 等待集合里,notifyAll() 只能全员唤醒再重新抢锁;而一个 ReentrantLock 可以 newCondition() 出多个 Condition,每个 Condition 就是一条独立的等待队列------这就是故事一里的"分科室叫号"。
Demo02 用它实现了 3 个线程交替打印 A B C A B C ...(各 5 轮):
java
// Demo02_ConditionAlternate.java
static final ReentrantLock lock = new ReentrantLock();
static final Condition condA = lock.newCondition(); // 线程A 的专属等候区
static final Condition condB = lock.newCondition(); // 线程B 的
static final Condition condC = lock.newCondition(); // 线程C 的
static int state = 1; // 1=该A,2=该B,3=该C
static void print(String content, int myState, Condition self, int nextState, Condition next) {
for (int i = 0; i < 5; i++) {
lock.lock();
try {
// ① 不是轮到自己 → 在自己的队列里睡觉(释放锁)
while (state != myState) {
self.await();
}
// ② 轮到自己:干活,然后切换状态
System.out.print(content + " ");
state = nextState;
// ③ 精准叫醒"下一个",而不是全员广播
next.signal();
} finally {
lock.unlock();
}
}
}
// 线程A:print("A", 1, condA, 2, condB) ------ 打完 A 叫醒 B
// 线程B:print("B", 2, condB, 3, condC) ------ 打完 B 叫醒 C
// 线程C:print("C", 3, condC, 1, condA) ------ 打完 C 叫回 A,形成闭环
整个协调过程是一张状态环:
┌────────────────────────────────────────────┐
│ │
▼ │
[state=1] 打印A ── state=2 ──▶ 打印B ── state=3 ──▶ 打印C ── state=1
condA 队列 condB 队列 condC 队列
(谁不满足条件,谁就在自己的队列里 await 睡觉)
💡 代码要点:
- 每个线程"睡在自己的队列、只被自己的接棒人叫醒",等待关系一目了然------用 wait/notifyAll 实现同样的逻辑,需要每个线程醒来后反复判断、反复抢锁,代码难写且低效。
await()做了两件事:释放锁 (否则别人永远进不了临界区,状态永远变不了)+ 进入自己的等待队列 ;被 signal 后还要重新竞争锁 ,拿到锁才从await()返回。while而不是if:被唤醒后条件可能又被别人改了,必须重新检查(详见误区一)。
2.3 生产者-消费者:手写有界缓冲区
这是 Condition 最重要的应用模型。Demo03 用"一把锁 + 两个 Condition + 环形数组"实现了经典的有界缓冲区------两个 Condition 把"队列空"和"队列满"两种等待分开管理:
java
// Demo03_ProducerConsumer.java
static final int CAPACITY = 5;
static final ReentrantLock lock = new ReentrantLock();
static final Condition notFull = lock.newCondition(); // 生产者的等候区:"没满"才轮到我
static final Condition notEmpty = lock.newCondition(); // 消费者的等候区:"不空"才轮到我
static final String[] buffer = new String[CAPACITY];
static int putIdx, takeIdx, size;
static void produce(String item) throws InterruptedException {
lock.lock();
try {
while (size == CAPACITY) { // 烤盘满了 → 去生产者等候区睡觉
notFull.await();
}
buffer[putIdx] = item;
putIdx = (putIdx + 1) % CAPACITY; // 环形下标:走到头绕回 0
size++;
notEmpty.signal(); // 库存变了 → 叫醒一个等取货的消费者
} finally {
lock.unlock();
}
}
static String consume() throws InterruptedException {
lock.lock();
try {
while (size == 0) { // 烤盘空了 → 去消费者等候区睡觉
notEmpty.await();
}
String item = buffer[takeIdx];
takeIdx = (takeIdx + 1) % CAPACITY;
size--;
notFull.signal(); // 空位变了 → 叫醒一个等空位的生产者
return item;
} finally {
lock.unlock();
}
}
两边的等待与唤醒互不打扰:
生产者 共享缓冲区 消费者
│ [ ][ ][ ][ ][ ] 空了 │
│── produce() ──▶ 放入商品 ── notEmpty.signal() ──▶ 叫醒消费者 │
│ [✔][✔][ ][ ][ ] │
│ [✔][✔][✔][✔][✔] 满了 │
│ notFull.await() ◀── 在生产者队列睡觉 ◀──┐ │
│ └── 消费者取走并 signal │
💡 代码要点:
- 一把锁保护缓冲区的全部状态(buffer、下标、size),保证"检查条件 + 修改状态 + 唤醒对方"是一个原子过程。
- 两个 Condition 的分工是按条件分组,而不是按角色站队:生产者等的条件是"有空位",消费者等的条件是"有货",各睡各的队列。
- 放入成功 signal 消费者、取走成功 signal 生产者------每一次唤醒都"叫对了人"。这正是 Java 官方
ArrayBlockingQueue的内部实现(下一篇直接用它)。
三、理解偏差:这些坑别踩
偏差 1:判断条件用 if 就够了
❌ 常见误解 :if (size == CAPACITY) notFull.await();------检查一次,不满足就等,满足就干活,逻辑上没毛病。
✅ 正确理解 :必须用 while。原因有二:其一 ,await() 返回后到重新拿到锁之间存在时间窗,条件可能已被其他线程改回去(生产者 A 和 B 同时被唤醒,只有一个能放下货,另一个醒来时又满了);其二 ,存在虚假唤醒(spurious wakeup)------API 允许线程无理由地被唤醒,while 保证醒来先复查条件,两种情况一起兜底。
🧠 为什么容易错:单线程思维里"检查过一次的条件在几微秒内不会变",而并发环境下"检查"和"行动"之间的每一纳秒都可能被插入其他线程的操作。
🧪 自测方法 :把 Demo03 的两处 while 改成 if 再跑,多来几轮大概率出现库存越界(ArrayIndexOutOfBoundsException)或 size 变成负数。
偏差 2:不持有锁也能调用 await / signal
❌ 常见误解 :Condition 是个独立对象,随时可以调用 condition.await()。
✅ 正确理解 :await()/signal() 必须在持有对应 Lock 的前提下调用(即 lock() 与 unlock() 之间),否则抛 IllegalMonitorStateException。原因很本质:await 的第一步是"释放锁",你手里根本没有锁,释放什么?
🧠 为什么容易错 :wait()/notify() 也有同样的约束(必须在 synchronized 内),但报错发生在运行期而非编译期,很多人是踩了异常才记住。
🧪 自测方法 :记住固定结构------await/signal 永远出现在 lock.lock(); try { ... } finally { lock.unlock(); } 内部。看到锁外调用直接判错。
偏差 3:unlock 不放 finally 也行,反正没抛异常
❌ 常见误解 :临界区就几行代码,不会抛异常,直接顺序写 lock(); 业务; unlock(); 更简洁。
✅ 正确理解 :业务代码抛出任何 RuntimeException,unlock() 将被执行不到,锁永远不会释放,之后所有线程在这把锁上永久阻塞------一个异常演变成整个系统的停摆。
🧠 为什么容易错:"现在不会抛异常"是对当前代码的判断,而代码会被修改、被复用。synchronized 用户从来没有这个心智负担(JVM 自动释放),切到 Lock 时容易把这个责任弄丢。
🧪 自测方法 :review 时看到 lock() 与 try 之间、或 finally 缺失 unlock(),一律按 bug 处理。
偏差 4:公平锁更公平,所以应该都用公平锁
❌ 常见误解 :非公平锁会"插队",不公平就是不好,生产环境应该 new ReentrantLock(true)。
✅ 正确理解 :非公平是刻意的设计------刚释放/刚到达的线程大概率 CPU 缓存还热着、线程也无需挂起唤醒,直接抢锁省去一次"挂起-唤醒"的上下文切换,吞吐量显著更高。公平锁只在确实需要"杜绝线程饿死"的场景(如对延迟公平性敏感的任务调度)才有价值,代价是整体吞吐下降。
🧠 为什么容易错:把生活中的"公平是美德"直接平移到技术选型,忽略了两种模式各自服务的目标(吞吐 vs 公平延迟)。
🧪 自测方法:问自己------这个场景里"某个线程偶尔多等几毫秒"有实际危害吗?没有,就用非公平。
易混淆点:Lock + Condition vs synchronized + wait/notify
| 维度 | synchronized + wait/notify | Lock + Condition |
|---|---|---|
| 等待队列 | 一个对象只有一个等待集合 | 一个 Lock 可派生多个 Condition 队列 |
| 唤醒粒度 | notifyAll() 全员唤醒、重新抢锁 |
signal() 只唤醒指定队列,精准唤醒 |
| 唤醒方 | notify() 随机唤醒一个(可能叫错人) |
明确唤醒某个条件的等待者 |
| 等锁可中断 | ❌ | ✅ lockInterruptibly() |
| 等锁超时 | ❌ | ✅ tryLock(timeout) |
| 公平性 | 不可选 | 可选 |
| 释放锁 | 自动 | 必须手动(finally) |
| 一句话区分 | 够用但只有"一室一厅" | 复杂协作的"多室多厅" |
四、记忆卡片:核心 QA 对
| # | 问题 | 答案 |
|---|---|---|
| 1 | 定义:Condition 是什么? | 与 Lock 配套的等待/唤醒机制 :一个 Lock 可 newCondition() 多个 Condition,每个 Condition 是一条独立的等待队列。 |
| 2 | 机制 :await() 和 signal() 分别做了什么? |
await():释放锁 + 当前线程进入对应 Condition 队列;signal():把该队列中的线程移回锁的竞争队列,等它重新拿到锁后才从 await() 返回。 |
| 3 | 场景:什么时候必须用多个 Condition? | 线程需要按不同条件分组等待:生产者-消费者(notFull/notEmpty)、多线程交替执行、读写分离等待等。单一条件用 wait/notify 即可。 |
| 4 | 权衡:使用 Lock 的风险是什么? | 锁要手动释放(忘 finally = 永久锁死);不持锁调用 await/signal 抛异常;公平锁吞吐更低------换来的是可中断、可超时、多队列的灵活性。 |
| 5 | 区分 :signal() vs notifyAll()? |
notifyAll 只能唤醒同一个等待集合的全部线程(惊群);signal 精准唤醒指定 Condition 队列,无关线程毫无感知。 |
| 6 | 边界 :为什么等待必须用 while 而不是 if? |
唤醒后条件可能又被其他线程改掉,且存在虚假唤醒;while 保证醒来后重新检查,是等待-唤醒范式的标准写法。 |
| 7 | 增强:ReentrantLock 相比 synchronized 的三大能力? | tryLock()(不傻等/限时等)、lockInterruptibly()(等锁可中断,死锁解药)、公平锁可选(先来先得)。 |
五、知识网络:相似、互斥与学习路径
🔗 相似知识(同类不同派):Object.wait / notify
| 对比维度 | Condition(await/signal) | Object(wait/notify) |
|---|---|---|
| 核心思想 | 把"等待"抽象为可多实例的对象 | 把"等待"挂在对象监视器上,一对象一套 |
| 队列数量 | 一个 Lock 多个 Condition | 一把监视器锁只有一个等待集合 |
| 唤醒控制 | 精准到队列 | 只能全员或随机一个 |
| 依赖的锁 | ReentrantLock | synchronized |
| 一句话区分 | wait/notify 是 Condition 的"单队列特例" | Condition 是 wait/notify 的"多队列升级" |
⚔️ 互斥/替代知识(通常二选一):synchronized vs ReentrantLock
| 对比维度 | synchronized | ReentrantLock |
|---|---|---|
| 核心思想 | JVM 语法级互斥,自动管理生命周期 | API 级互斥,手动管理、能力更全 |
| 最佳场景 | 简单互斥、临界区短、无复杂协作 | 可中断/可超时/多条件/公平性需求 |
| 关键差异 | 自动释放锁,写不出"忘解锁"的 bug | 灵活,但把释放责任交给了程序员 |
| 选择依据 | 默认选它;只有用到 Lock 独有能力时才换 | 明确需要三大增强之一时选它 |
📚 前置与后续(学习路径)
- 前置知识 :
- synchronized 与竞态条件(系列第 1 篇):理解"互斥"和"锁对象"是本文的地基
- 线程中断(
interrupt()):lockInterruptibly()的前提是理解中断只是"打个招呼",由线程自己决定如何响应
- 后续知识 :
- BlockingQueue(系列第 3 篇):本文 Demo03 的官方封装版,业务代码只剩 put/take
- AQS(AbstractQueuedSynchronizer):ReentrantLock 和 Condition 的底层框架,"同步队列 + 条件队列"双队列结构的源码级实现
- Semaphore / CountDownLatch:基于同一套 AQS 的其他协作工具
- 最佳搭档 :
- 环形数组 :
putIdx = (putIdx + 1) % CAPACITY的环形缓冲区与 Lock+Condition 组合,就是ArrayBlockingQueue的完整蓝本 - 线程池 :
ThreadPoolExecutor的 worker 线程正是在条件队列上等待任务
- 环形数组 :
六、配套代码运行指南
a02LockCondition/
├── Demo01_Basic.java 标准骨架 + tryLock + lockInterruptibly
├── Demo02_ConditionAlternate.java 多 Condition 精准唤醒(ABC 交替打印)
└── Demo03_ProducerConsumer.java 手写有界缓冲区(生产者-消费者)
每个类都含 main 方法,直接运行即可(JDK 17)。建议重点观察:
- Demo01:tryLocker 没拿到锁时没有阻塞 ,waiter 等锁时被
interrupt()干净地退出; - Demo02:输出恒为
A B C A B C ...,把next.signal()换成lock.newCondition().signal()(叫错人)就能看到程序卡死,体会"精准"的含义; - Demo03:消费者较慢(300ms),观察库存到 5 后生产者阻塞、等待消费者腾位的完整过程。
结语
Lock 与 Condition 的价值不是"比 synchronized 更好的锁",而是把锁从语法关键字升级成了可编程的对象 :等待可以放弃(tryLock)、可以被打断(lockInterruptibly)、可以分组排队(多 Condition)。其中"一把锁 + 多个条件队列"的组合,是理解 Java 几乎所有高级同步工具的钥匙------下一篇的 BlockingQueue,就是把本文手写的生产者-消费者缓冲区,封装成了开箱即用的标准组件。