前一篇我们已经把 LockReentrantLock 的核心思路拆开了。

你已经知道:

  • synchronized 是内置锁,简单直接
  • ReentrantLock 是显式锁,更灵活、更可控
  • lock() / unlock() 要成对出现,最好永远写在 try/finally
  • tryLock()lockInterruptibly()、公平锁这些能力,都是 synchronized 很难直接表达的

但讲到这里,很多人会立刻接着问下一个问题:

既然前面已经学过 wait() / notify(),现在又有了 Lock,那为什么 Java 还要再提供一个 Condition

而且你第一次看到它时,通常会更困惑:

  • Condition 不就是另一套等待/唤醒机制吗?
  • 它和 Object.wait() / notify() 到底有什么本质区别?
  • 为什么文档总说 Condition 可以提供“多个等待队列”?
  • 多个等待队列到底解决了什么实际问题?
  • 什么时候该继续用 wait()/notify(),什么时候更适合用 Condition

这些问题背后,本质上是在问:

线程通信这件事,如果从“内置监视器模型”升级到“显式锁模型”,到底多出来了什么工程能力?

这篇文章我想把这件事彻底讲清楚。

我会重点讲 6 个问题:

  1. Condition 是什么,它和 wait()/notify() 的关系是什么
  2. 为什么 Condition 必须和 Lock 一起使用
  3. await()signal()signalAll() 分别在做什么
  4. “多个等待队列”到底比 notifyAll() 强在哪里
  5. 生产者—消费者模型用 Condition 重写后,为什么更清晰
  6. 初学者最容易踩哪些坑

你把这一篇吃透,后面再去学:

  • 阻塞队列
  • 线程池中的任务协调
  • 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)ConditionLock 体系里的条件等待机制

  • await() 对应 wait()
  • signal() 对应 notify()
  • signalAll() 对应 notifyAll()

2)它最大的优势不是“也能等”,而是“能拆多个等待队列”

这让不同类型的线程等待可以分开管理,而不是都挤在同一个监视器等待集合里。

3)条件等待的基本套路没有变

永远是:

  • 先拿锁
  • 检查条件
  • 不满足就等待
  • 被唤醒后重新检查条件
  • 满足了再继续执行业务逻辑

4)whileif 更适合条件等待

这是并发里必须养成的习惯。

5)简单场景可以先用 wait()/notify(),复杂协作更适合 Condition

尤其当你有多个等待条件时,Condition 的可读性和可维护性会明显更强。

下一篇我会继续接着 Java 并发这条线往下讲:

ReadWriteLock 到底在解决什么问题?为什么有些场景下“读多写少”不该再用一把普通互斥锁硬顶?

到那时你会发现,Java 并发工具并不是越学越杂,而是在围绕一个核心问题不断细化:

不同线程之间,到底应该怎样既安全,又尽量高效地协作。