1. 多线程协作的基本困境
在Java多线程编程中,wait()和notify()这对方法扮演着线程间通信的关键角色。想象这样一个场景:餐厅后厨里,厨师(生产者线程)做好菜品后需要通知服务员(消费者线程)上菜,而服务员在没有菜品时需要等待。这种生产者-消费者模型正是wait/notify机制的典型应用场景。
但为什么Java强制要求这两个方法必须在同步块(synchronized block)中调用?这个设计看似增加了编码复杂度,实则解决了多线程环境下的三个核心问题:
-
竞态条件预防:当多个线程同时修改共享状态时,非同步操作会导致数据不一致。比如两个消费者线程同时检测到队列为空,都进入等待状态,而生产者只发送一次通知,就会导致永久等待。
-
虚假唤醒防御:即使没有收到notify,线程也可能从wait状态醒来(称为spurious wakeup)。同步块确保了线程在检查条件和进入等待状态时持有锁,防止条件判断与等待操作之间的竞态窗口。
-
happens-before保证:同步块建立了清晰的内存可见性规则,确保notify线程对共享变量的修改对被唤醒的wait线程可见。
关键理解:同步块不是限制wait/notify的枷锁,而是保证它们正确工作的安全护栏。就像交通信号灯看似限制了车辆通行,实则是为了避免碰撞事故。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 竞态条件的具象化分析
让我们通过代码示例具体化没有同步保护的灾难场景。假设我们实现一个简单的任务队列:
java复制// 危险示例:未同步的wait/notify
class UnsafeTaskQueue {
private Queue<String> queue = new LinkedList<>();
public void produce(String task) {
queue.add(task);
notify(); // 可能丢失通知
}
public String consume() throws InterruptedException {
while (queue.isEmpty()) {
wait(); // 可能错过通知
}
return queue.remove();
}
}
这段代码存在两个致命缺陷:
-
通知丢失(Lost Wakeup Problem):
- 线程A检查queue.isEmpty()为true,准备进入wait()
- 线程B此时插入任务并调用notify()
- 线程A才执行wait(),导致永久阻塞
-
状态不一致:
- 多个消费者线程可能同时通过isEmpty()检查
- 导致多个线程尝试消费同一个任务或空队列异常
通过synchronized修复后的正确版本:
java复制class SafeTaskQueue {
private final Object lock = new Object();
private Queue<String> queue = new LinkedList<>();
public void produce(String task) {
synchronized (lock) {
queue.add(task);
lock.notify();
}
}
public String consume() throws InterruptedException {
synchronized (lock) {
while (queue.isEmpty()) {
lock.wait();
}
return queue.remove();
}
}
}
3. Java内存模型(JMM)的视角
从Java内存模型看,synchronized块创建了关键的happens-before关系:
-
锁获取与释放语义:
- 线程释放锁(退出同步块)前的所有操作happens-before于后续线程获取该锁(进入同步块)
- 这保证了notify线程对共享变量的修改对wait线程可见
-
wait()的特殊语义:
- 调用wait()会原子性地释放锁并暂停线程
- 被唤醒后必须重新获取锁才能继续执行
- 这确保了wait线程恢复执行时,必然持有最新状态的锁
对比其他同步方案:
| 机制 | 内存可见性保证 | 线程唤醒精度 | 适用场景 |
|---|---|---|---|
| synchronized | 完全保证 | 精确 | 互斥访问、简单协作 |
| volatile | 部分保证 | 无 | 状态标志、简单原子操作 |
| Lock/Condition | 完全保证 | 精确 | 复杂条件、超时控制 |
4. 实际开发中的陷阱与最佳实践
即使理解了原理,实际使用wait/notify时仍容易踩坑:
- 经典的三重检查模式:
java复制synchronized (lock) {
while (!condition) { // 必须用while而非if
lock.wait();
}
// 处理业务逻辑
}
- 使用while循环防止虚假唤醒后错误处理
- 每次唤醒后重新检查条件状态
-
notify vs notifyAll的选择:
- notify()随机唤醒一个等待线程,适用于:
- 所有等待线程是可互换的(处理相同任务)
- 确保每次通知只需一个线程响应
- notifyAll()唤醒所有等待线程,适用于:
- 不同线程可能等待不同条件
- 条件状态变化可能满足多个等待线程
- notify()随机唤醒一个等待线程,适用于:
-
性能优化技巧:
- 为不同条件使用单独的锁对象,减少不必要的唤醒
- 考虑使用java.util.concurrent包中的高级工具类(如BlockingQueue)
- 同步块范围应尽可能小,但必须包含条件检查和wait/notify调用
-
常见反模式:
- 在非同步方法中调用wait/notify(导致IllegalMonitorStateException)
- 持有锁时执行耗时操作(导致线程阻塞)
- 忽略中断异常(应恢复中断状态)
5. 从设计哲学看Java线程协作
Java的这种设计体现了其多线程模型的核心原则:
- 安全优先:宁愿抛出异常(IllegalMonitorStateException)也不允许不安全的操作
- 显式控制:开发者必须明确标识哪些代码需要线程安全保证
- 底层机制:提供基础构建块(wait/notify),高级抽象(如并发集合)在其上构建
对比其他语言的实现:
| 语言 | 线程通信机制 | 同步要求 | 特点 |
|---|---|---|---|
| Java | wait/notify | 强制同步 | 安全但繁琐 |
| C# | Monitor.Wait/Pulse | 强制同步 | 类似Java |
| Python | threading.Condition | 显式锁关联 | with语句简化锁管理 |
| Go | channel | 无锁要求 | CSP模型,更高级抽象 |
在现代Java开发中,虽然直接使用wait/notify的场景减少(被JUC工具类取代),但理解这些底层机制仍然重要,特别是在:
- 面试中展示对并发原理的深刻理解
- 调试复杂的线程交互问题
- 实现自定义的同步组件时
我曾在一个高并发的订单处理系统中,就遇到过因为错误使用notify()导致线程饥饿的问题。最终通过以下步骤解决:
- 用jstack抓取线程转储,发现多个消费者线程在WAITING状态
- 分析代码发现生产者只调用notify()
- 改为notifyAll()后问题缓解
- 最终优化为多个条件队列,针对不同类型通知使用不同锁
这个经历让我深刻体会到:多线程编程就像编排交响乐,每个乐器(线程)既要各司其职,又要精确配合。而wait/notify加同步块,就是Java提供的指挥棒。
