1. 为什么wait()和notify()必须放在synchronized代码块中?
这个问题困扰过很多刚接触Java多线程开发的程序员。我第一次在实际项目中遇到IllegalMonitorStateException异常时也是一头雾水——明明代码逻辑看起来没问题,为什么一运行就报错?后来通过深入研究才发现,这背后蕴含着Java线程同步机制的精妙设计。
1.1 监视器(Monitor)的基本原理
每个Java对象都有一个内置的监视器(Monitor),这是实现线程同步的基础。可以把监视器想象成一个房间的门锁:
synchronized关键字就像是获取这个门锁的钥匙wait()相当于暂时离开房间并交出钥匙notify()像是敲门告诉里面的人可以出来了
关键点在于:你必须有钥匙(synchronized)才能进行这些操作。如果没有获取锁就直接调用wait()或notify(),就像没有钥匙却想进出房间,自然会抛出IllegalMonitorStateException。
1.2 丢失唤醒(Lost Wakeup)问题
这是最核心的原因。让我们通过一个典型的生产者-消费者场景来说明:
// 错误示例 if (!hasData) { // 步骤1:检查条件 // 这里可能发生线程切换! lock.wait(); // 步骤2:等待 }假设消费者线程执行完步骤1但还未执行步骤2时,CPU切换到生产者线程:
- 生产者设置hasData=true并调用notify()
- 然后消费者线程继续执行wait()
结果就是生产者发出的通知被"丢失"了,消费者将永远等待下去。这种问题在实际多线程环境中非常危险且难以调试。
关键点:synchronized保证了"条件检查"和"等待"这两个操作成为一个原子操作,不会被其他线程打断。
1.3 虚假唤醒(Spurious Wakeup)的应对
即使使用synchronized,线程也可能在没有收到明确通知的情况下被唤醒,这就是所谓的"虚假唤醒"。因此我们总是使用while循环而不是if语句来检查条件:
// 正确做法 while (!condition) { lock.wait(); }synchronized在这里的作用是确保在检查条件和调用wait()期间,共享状态不会被其他线程修改。
2. wait()和notify()的底层机制
2.1 wait()的三步操作
很多人以为wait()只是让线程暂停,实际上它完成了三个关键操作:
- 释放当前持有的锁
- 将线程加入该对象的等待队列
- 线程进入WAITING状态
这与sleep()有本质区别:sleep()不会释放任何锁资源。
2.2 notify()的工作机制
notify()并不会立即唤醒线程,而是:
- 从等待队列中移出一个线程
- 将该线程放入入口队列(Entry Set)
- 这个线程需要重新竞争锁才能继续执行
notifyAll()则是唤醒所有等待线程,它们会一起竞争锁。
2.3 监视器的状态转换
理解对象监视器的状态转换对掌握多线程编程至关重要:
获取锁(synchronized) ↓ 运行状态 ↓ wait() → 等待队列 ↑ notify() → 入口队列 ↓ 重新竞争锁这个状态转换图解释了为什么必须在持有锁时才能调用这些方法——它们直接操作监视器的内部队列。
3. 实际应用中的最佳实践
3.1 标准模板代码
经过多年实践,我总结出使用wait/notify的安全模板:
// 等待方 synchronized(lock) { while (!condition) { lock.wait(); } // 处理业务 } // 通知方 synchronized(lock) { // 改变条件 condition = true; lock.notifyAll(); }3.2 为什么更推荐notifyAll()
虽然notify()更高效,但在实际项目中我更推荐使用notifyAll(),原因包括:
- 避免复杂的线程调度问题
- 防止某些线程被"遗忘"在等待队列中
- 代码更健壮,特别是在条件可能被多个因素改变时
3.3 性能优化技巧
在高并发场景下,可以采取以下优化措施:
- 尽量减少同步块内的代码量
- 使用超时版本的wait(long timeout)
- 考虑使用更高级的并发工具如Condition
4. 常见问题与调试技巧
4.1 典型错误案例
我在代码审查中经常看到这些错误:
- 在非同步方法中调用wait/notify
- 使用if而不是while检查条件
- 忘记在改变条件后调用notify
- 对不同的条件使用同一个锁对象
4.2 调试多线程问题的技巧
调试多线程问题确实很有挑战性,我常用的方法包括:
- 给线程设置有意义的名称
- 使用Thread.dumpStack()定位问题
- 在关键点添加详细的日志
- 使用jstack工具分析线程状态
4.3 替代方案的选择
对于现代Java开发,我们有了更多选择:
- java.util.concurrent包中的高级工具
- Lock和Condition接口
- 并发集合类
- CompletableFuture等异步编程工具
但在理解这些高级工具之前,掌握基础的wait/notify机制仍然非常重要。
5. 从JVM角度看实现原理
5.1 对象头中的Mark Word
每个Java对象头中都包含Mark Word,其中存储了锁状态信息:
- 偏向锁标志
- 轻量级锁指针
- 重量级锁指针(指向Monitor对象)
wait/notify操作的就是这个重量级锁关联的Monitor。
5.2 MonitorObject的结构
HotSpot JVM中MonitorObject包含:
- _owner:指向持有锁的线程
- _EntryList:等待获取锁的线程队列
- _WaitSet:调用了wait()的线程队列
这解释了为什么必须先获取锁才能操作等待队列。
5.3 本地方法实现
最终wait/notify是通过JNI调用实现的:
// ObjectMonitor.cpp void ObjectMonitor::wait(jlong millis, bool interruptible, TRAPS) { // 将线程加入_WaitSet // 释放锁 // 等待唤醒 }理解这些底层实现有助于我们写出更健壮的多线程代码。
6. 历史演进与设计哲学
6.1 早期Java的线程模型
Java从1.0版本就引入了synchronized和wait/notify机制,这种设计:
- 借鉴了Hoare的监视器概念
- 与当时的硬件条件相适应
- 提供了基础的线程通信能力
6.2 为什么这样设计
这种看似严格的限制实际上体现了Java的安全哲学:
- 强制开发者考虑线程安全问题
- 避免隐晦的并发bug
- 提供确定性的行为
6.3 现代并发工具的关系
后来的并发工具如ReentrantLock并没有取代这种机制,而是:
- 提供了更灵活的锁操作
- 支持多个条件变量
- 增加了可中断、可定时等功能
但wait/notify作为基础机制仍然重要。
7. 实际项目经验分享
7.1 电商库存管理案例
我曾在一个电商系统中使用wait/notify实现库存预警:
class Inventory { private int stock; private final Object lock = new Object(); public void consume() { synchronized(lock) { while (stock <= 0) { lock.wait(); } stock--; } } public void replenish(int amount) { synchronized(lock) { stock += amount; lock.notifyAll(); } } }这个实现简单但可靠,处理了高峰期的大量并发请求。
7.2 性能调优经验
在高并发场景下,我们需要注意:
- 同步块的范围要尽可能小
- 考虑使用读写锁分离场景
- 监控等待线程的数量
- 设置合理的超时时间
7.3 遇到的坑与解决方案
记忆最深的一个bug是:
- 问题:系统偶尔会挂起
- 原因:某个异常路径跳过了notify调用
- 解决:在finally块中确保调用notify
这个教训让我养成了在finally中处理锁的好习惯。
8. 扩展知识与相关概念
8.1 与Condition的对比
Java 5引入的Condition接口提供了更灵活的功能:
- 一个锁可以关联多个Condition
- 支持公平/非公平选择
- 提供更丰富的等待方法
但在简单场景下,内置的wait/notify仍然是最轻量的选择。
8.2 与其他语言的比较
不同语言的线程同步机制各有特点:
- C++: std::condition_variable
- Python: threading.Condition
- Go: channel-based同步
Java的设计在安全性和性能之间取得了良好平衡。
8.3 响应式编程的影响
随着响应式编程的兴起,传统的线程同步模式正在被观察者模式等替代,但理解这些基础仍然重要。