前一篇我们已经把 Lock 和 ReentrantLock 的核心思路拆开了。
你已经知道:
synchronized是内置锁,简单直接ReentrantLock是显式锁,更灵活、更可控lock()/unlock()要成对出现,最好永远写在try/finally里tryLock()、lockInterruptibly()、公平锁这些能力,都是synchronized很难直接表达的
但讲到这里,很多人会立刻接着问下一个问题:
既然前面已经学过 wait() / notify(),现在又有了 Lock,那为什么 Java 还要再提供一个 Condition?
而且你第一次看到它时,通常会更困惑:
Condition不就是另一套等待/唤醒机制吗?- 它和
Object.wait()/notify()到底有什么本质区别? - 为什么文档总说
Condition可以提供“多个等待队列”? - 多个等待队列到底解决了什么实际问题?
- 什么时候该继续用
wait()/notify(),什么时候更适合用Condition?
这些问题背后,本质上是在问:
线程通信这件事,如果从“内置监视器模型”升级到“显式锁模型”,到底多出来了什么工程能力?
这篇文章我想把这件事彻底讲清楚。
我会重点讲 6 个问题:
Condition是什么,它和wait()/notify()的关系是什么- 为什么
Condition必须和Lock一起使用 await()、signal()、signalAll()分别在做什么- “多个等待队列”到底比
notifyAll()强在哪里 - 生产者—消费者模型用
Condition重写后,为什么更清晰 - 初学者最容易踩哪些坑
你把这一篇吃透,后面再去学:
- 阻塞队列
- 线程池中的任务协调
ReadWriteLock- 更复杂的并发容器和同步器
会顺很多。
一、先说结论:Condition 不是重复造轮子,而是把“条件等待”从内置锁里拆成了更可控的显式机制
很多人第一次看到 Condition,会觉得 Java 好像又重复造了一遍 wait()/notify()。
但它真正解决的,不是“能不能等待”这个问题,而是:
当并发协作变复杂时,wait()/notify() 这套基于单一监视器等待队列的模型,表达能力开始不够用了。
你可以先这样理解两者关系:
wait()/notify()/notifyAll():是synchronized体系里的条件等待机制await()/signal()/signalAll():是Lock体系里的条件等待机制
它们在目标上很像:
- 条件不满足时先等待
- 条件满足时再唤醒
但 Condition 更强调两件事:
- 等待逻辑显式化
- 等待队列可拆分
这两点一出来,代码的可读性和可控性就会明显提升。
二、为什么有了 wait()/notify() 还不够?真正的问题出在“只有一条等待队列”
这个点是理解 Condition 价值的关键。
前面学 wait()/notify() 时,我们已经知道:
- 每个对象都可以作为锁对象
- 调用
wait()的线程会进入这个对象关联的等待队列 notify()会随机唤醒其中一个线程notifyAll()会唤醒全部线程
表面看起来没问题。
但一旦场景稍微复杂一点,问题就出来了。
1)单监视器模型下,不同类型的等待通常会混在同一队列里
最经典的例子就是生产者—消费者。
假设缓冲区有界:
- 缓冲区满了,生产者要等
- 缓冲区空了,消费者要等
这时你会发现:
“等非满”和“等非空”其实是两种完全不同的条件。
但如果你只用 synchronized + wait()/notifyAll(),这两类线程往往会挂在同一把锁对象的等待队列里。
于是就会出现这些问题:
- 唤醒时粒度太粗
- 明明只该唤醒消费者,却把生产者也一起叫醒
- 很多线程被唤醒后发现条件还是不满足,又得继续睡回去
- 代码逻辑越来越绕
2)notify() 往往不敢乱用,最后只能退回 notifyAll()
因为同一等待队列里混着不同角色的线程,你很难保证:
- 这次随便唤醒一个,一定是对的那个
所以很多代码最后就只能保守地写成 notifyAll()。
这当然是安全的,但代价是:
- 唤醒过多线程
- 增加无效竞争
- 降低并发效率
Condition 的价值,正是在这里开始体现。
它允许你把不同条件拆成不同等待队列。
也就是说:
不是所有“等着的人”都必须挤在一个房间里。
三、Condition 到底是什么?可以先把它理解成“绑定在 Lock 上的条件队列”
先看一个最小结构。
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class Demo {
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition();
}
这里最关键的一句是:
Condition condition = lock.newCondition();
它说明:
Condition 不是独立存在的,它必须从某把 Lock 上创建出来。
为什么?
因为条件等待本质上仍然是在围绕同一份共享数据做协调。
只是现在:
- 互斥访问由
Lock管 - 条件等待由
Condition管
你可以把它粗略理解成:
Lock负责“谁能进门”Condition负责“进来之后,如果条件不满足,去哪个等候区排队”
这比单一监视器模型清楚得多。
四、await()、signal()、signalAll() 分别在做什么?
这三个方法和 wait()/notify()/notifyAll() 在角色上基本对应,但语义更贴近显式锁体系。
1)await():等待条件,并且释放当前锁
最核心的一点和 wait() 一样:
线程等待时,不会一直霸占锁。
也就是说,线程调用 await() 后会:
- 进入该
Condition的等待队列 - 释放当前持有的锁
- 暂停执行
- 等待别人
signal()或signalAll() - 被唤醒后重新竞争锁
- 重新拿到锁之后,才从
await()后继续执行
这个流程很重要,因为很多人会误以为“被唤醒就立刻继续跑”。
其实不是。
被唤醒只是从条件队列回到锁竞争阶段。
它还得重新拿到锁,才能真正继续。
2)signal():唤醒该 Condition 等待队列中的一个线程
注意几个关键词:
- 是这个
Condition上等待的线程 - 不是所有线程
- 也不是别的
Condition上的线程
这意味着你可以精确唤醒某一类等待者。
3)signalAll():唤醒该 Condition 等待队列中的全部线程
这和 notifyAll() 类似,但作用域更清楚:
只唤醒这个条件队列里的全部线程,而不是同一把锁下所有可能等待的人。
这就是它比传统监视器模型更精细的地方。
五、为什么 Condition 必须和 Lock 一起使用?
这是一个很容易死记但不真正理解的点。
本质原因很简单:
条件等待不是孤立发生的,它一定依附于共享状态,而共享状态又必须受到同一套互斥规则保护。
比如:
- 缓冲区是否为空
- 当前库存是否为 0
- 某个任务是否完成
- 某个标志位是否已更新
这些条件都不是空中飘着的概念,而是共享数据的一部分。
所以如果等待条件和访问共享状态不在同一把锁保护下,线程之间的观察就会不一致,程序也会变得不可靠。
因此标准写法一定是:
lock.lock();
try {
while (!conditionSatisfied) {
condition.await();
}
// 执行业务逻辑
} finally {
lock.unlock();
}
你会发现,它和前面 wait() 的最佳实践本质上很像:
- 先获得互斥访问权
- 检查条件
- 不满足就等待
- 被唤醒后重新检查条件
- 最后执行真正逻辑
并发里很多看起来复杂的 API,底层思想其实一直没变。
六、为什么 await() 前面几乎总是 while,而不是 if?
这条规则和前面学 wait() 时一模一样,必须继续保留。
while (!conditionSatisfied) {
condition.await();
}
为什么不能偷懒写成 if?
因为线程被唤醒时,不代表条件一定已经稳定满足。
可能出现这些情况:
1)被唤醒后,锁还要重新竞争
你醒了,不代表你马上执行。
在你重新拿到锁之前,别的线程可能已经把条件改掉了。
2)可能有多个线程一起被唤醒
如果多个线程被唤醒,其中前面的线程可能先把资源拿走,后面的线程拿到锁时条件已经不成立了。
3)防御性写法更安全
并发里最稳的逻辑永远是:
醒来之后别急着相信世界,重新检查条件。
所以要把这个模式记死:
if是一次性判断while是条件守卫
只要是条件等待,优先用 while。
七、最经典的例子:用 Condition 重写生产者—消费者模型
先看代码。
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedBuffer {
private final Queue<Integer> queue = new LinkedList<>();
private final int capacity = 5;
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(int value) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await();
}
queue.offer(value);
System.out.println("生产:" + value);
notEmpty.signal();
} finally {
lock.unlock();
}
}
public int take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await();
}
int value = queue.poll();
System.out.println("消费:" + value);
notFull.signal();
return value;
} finally {
lock.unlock();
}
}
}
这段代码的亮点,就在于它把两种等待条件拆开了:
notFull:给生产者等notEmpty:给消费者等
这就是 Condition 最核心的工程价值。
为什么它比 wait()/notifyAll() 更清楚?
因为你现在可以非常明确地表达:
- 队列满了,生产者去
notFull这边等 - 队列空了,消费者去
notEmpty这边等 - 生产完一个元素,只需要叫醒“等非空”的消费者
- 消费完一个元素,只需要叫醒“等非满”的生产者
这比传统写法里所有线程混在一个等待集合里,再靠 notifyAll() 硬唤醒,要清晰得多。
而且性能上也更合理。
因为你减少了很多不必要的唤醒和无效竞争。
八、Condition 相比 wait()/notify(),到底强在哪?
这里可以直接做个总结。
1)等待队列可以拆分
这是最核心的优势。
一个 Lock 可以创建多个 Condition。
于是不同类型的等待者可以进入不同条件队列。
2)语义更清晰
你看到:
notEmpty.await()notFull.signal()
就几乎能直接看懂业务意图。
相比之下,单纯看到 wait() / notifyAll(),你往往还得再回头读上下文,猜线程在等什么。
3)和显式锁体系更一致
既然前面已经用了 ReentrantLock,那继续配套用 Condition,会比一半显式、一半内置语义更整齐。
4)更适合复杂并发协调
当系统中有:
- 多种角色线程
- 多个等待条件
- 更细粒度唤醒需求
Condition 的优势会越来越明显。
九、什么时候继续用 wait()/notify(),什么时候用 Condition?
这个问题初学者很常问。
我的建议其实很直接。
1)场景简单、教学入门、逻辑不复杂:可以先用 synchronized + wait()/notify()
比如:
- 理解线程通信基本原理
- 简单的一对一等待模型
- 并发入门练习题
这套组合的优点是:
- 概念集中
- 不用额外引入并发包 API
- 适合先把底层思想学明白
2)场景一旦进入工程化协作,尤其有多个条件时:优先考虑 Lock + Condition
比如:
- 有界缓冲区
- 多生产者多消费者
- 任务状态切换
- 更复杂的线程协作流程
因为这时你更在意的是:
- 可读性
- 可维护性
- 唤醒粒度
- 逻辑分层是否清晰
如果系统已经复杂起来了,还硬用一套 wait()/notifyAll() 顶着,代码大概率会越来越乱。
十、初学者最容易踩的坑
1)没持有锁就调用 await() / signal()
这和没持有监视器就调 wait() / notify() 一样,都是违规用法。
你必须先 lock.lock(),再操作对应的 Condition。
2)await() 醒来后不重新检查条件
这是最常见坑之一。
记住:
醒来不代表条件一定还成立。
永远优先:
while (!条件满足) {
condition.await();
}
3)忘记在 finally 里释放锁
显式锁最大风险之一就是:
- 你必须自己善后
所以模板最好形成肌肉记忆:
lock.lock();
try {
// 业务逻辑
} finally {
lock.unlock();
}
4)把 signal() 想成“对方立刻运行”
不是。
signal() 只是把等待线程从条件队列里移出来,让它去竞争锁。
它什么时候真正继续执行,还要看锁什么时候轮到它。
5)条件设计不清,多个线程乱用同一个 Condition
如果你明明有两类完全不同的等待逻辑,却还是都塞进一个 Condition,那就等于把自己又写回了单队列模式。
Condition 的优势就在于拆分等待条件,别自己把它抹平了。
十一、从设计角度看:Condition 的意义不是 API 多了,而是并发意图更明确了
很多并发工具学到最后,真正拉开水平差距的,不是“会不会背方法名”,而是:
你能不能把线程之间的协作关系表达清楚。
Condition 的设计价值,恰恰就在这里。
它让你把原来混在一起的逻辑拆开成:
- 哪把锁负责互斥
- 哪个条件负责等待非空
- 哪个条件负责等待非满
- 哪一步修改状态
- 哪一步唤醒正确角色
当你的代码能把这些关系写清楚时,后面维护、排障、扩展都会轻松很多。
所以别把 Condition 只看成“wait() 的替代品”。
更准确地说:
它是 Java 并发里,把“条件协调”从隐式约定变成显式结构的一步。
十二、最后总结
这篇文章你如果只带走几句话,我希望是下面这几条。
1)Condition 是 Lock 体系里的条件等待机制
await()对应wait()signal()对应notify()signalAll()对应notifyAll()
2)它最大的优势不是“也能等”,而是“能拆多个等待队列”
这让不同类型的线程等待可以分开管理,而不是都挤在同一个监视器等待集合里。
3)条件等待的基本套路没有变
永远是:
- 先拿锁
- 检查条件
- 不满足就等待
- 被唤醒后重新检查条件
- 满足了再继续执行业务逻辑
4)while 比 if 更适合条件等待
这是并发里必须养成的习惯。
5)简单场景可以先用 wait()/notify(),复杂协作更适合 Condition
尤其当你有多个等待条件时,Condition 的可读性和可维护性会明显更强。
下一篇我会继续接着 Java 并发这条线往下讲:
ReadWriteLock 到底在解决什么问题?为什么有些场景下“读多写少”不该再用一把普通互斥锁硬顶?
到那时你会发现,Java 并发工具并不是越学越杂,而是在围绕一个核心问题不断细化:
不同线程之间,到底应该怎样既安全,又尽量高效地协作。