Jakob Jenkov 2024-07-21
Java 提供了一组功能,允许线程之间相互发送信号,并等待这些信号。例如,线程 B 可能会等待来自线程 A 的信号,表示数据已准备好进行处理。
Java 中的线程通信机制通过 Object 类中的 wait()、notify() 和 notifyAll() 方法实现,而所有 Java 类都继承自 Object 类。
wait()、notify() 和 notifyAll()
Java 内置了一种等待机制,使线程在等待其他线程发出的信号时可以进入非活跃状态。java.lang.Object 类定义了三个方法:wait()、notify() 和 notifyAll(),用于实现这一机制。
当一个线程在某个对象上调用 wait() 时,它会变为非活跃状态,直到另一个线程在同一个对象上调用 notify() 或 notifyAll()。
注意:调用 wait()、notify() 或 notifyAll() 的线程必须首先获取该对象的锁。也就是说,这些方法必须在对该对象加锁的 synchronized 块中调用。
下面是一个示例类,可用于两个线程之间传递信号。两个线程需要访问同一个该类的实例:一个线程调用 doWait(),另一个调用 doNotify()。
public class MonitorObject {
}
public class MyWaitNotify {
MonitorObject myMonitorObject = new MonitorObject();
public void doWait() {
synchronized (myMonitorObject) {
try {
myMonitorObject.wait();
} catch (InterruptedException e) {
// 处理中断异常
}
}
}
public void doNotify() {
synchronized (myMonitorObject) {
myMonitorObject.notify();
}
}
}
当第一个线程调用 doWait() 时,它首先进入一个 synchronized 块,然后在内部监视器对象上调用 wait()。这个 synchronized 块正是以该监视器对象为锁对象的。调用 wait() 后,当前线程会释放该监视器对象的锁,并被阻塞,直到另一个线程在同一个监视器对象上调用 notify() 或 notifyAll()。
当第二个线程调用 doNotify() 时,它也进入一个以该监视器对象为锁的 synchronized 块,并在其中调用 notify()。这将唤醒一个正在该监视器对象上等待的线程(如果有多个,则只唤醒一个)。但需要注意的是,被唤醒的线程无法立即从 wait() 返回,必须等到调用 notify() 的线程退出 synchronized 块、释放锁之后才行。
上述原理如下图所示(图中将监视器对象称为“信号对象”):

多个线程可以在同一个监视器对象上调用 wait(),从而阻塞并等待 notify() 或 notifyAll() 调用:
notify()仅唤醒一个等待线程;notifyAll()则唤醒所有等待线程。
如果线程在未持有对象锁的情况下调用 wait()、notify() 或 notifyAll(),将抛出 IllegalMonitorStateException 异常。
丢失信号(Missed Signals)
notify() 和 notifyAll() 不会保存调用记录。如果在没有任何线程等待时调用了 notify(),那么该信号就会丢失。
这意味着,如果线程 A 在线程 B 调用 wait() 之前就调用了 notify(),那么线程 B 将永远等不到信号,可能陷入永久等待。
为避免信号丢失,应在信号类中存储信号状态。例如,在 MyWaitNotify 示例中,应将通知信号存储为成员变量:
public class MyWaitNotify2 {
MonitorObject myMonitorObject = new MonitorObject();
boolean wasSignalled = false;
public void doWait() {
synchronized (myMonitorObject) {
if (!wasSignalled) {
try {
myMonitorObject.wait();
} catch (InterruptedException e) {
// 处理中断
}
}
// 清除信号并继续执行
wasSignalled = false;
}
}
public void doNotify() {
synchronized (myMonitorObject) {
wasSignalled = true;
myMonitorObject.notify();
}
}
}
注意:
doNotify()在调用notify()前先设置wasSignalled = true;doWait()在调用wait()前检查wasSignalled,只有未收到信号时才等待。
虚假唤醒(Spurious Wakeups)
出于某些不可解释的原因,即使没有调用 notify() 或 notifyAll(),等待的线程也可能被唤醒,这称为虚假唤醒(spurious wakeup)。
如果在 MyWaitNotify2 中发生虚假唤醒,线程可能在未收到有效信号的情况下继续执行,从而导致严重问题。
为防止这种情况,应使用 while 循环而非 if 语句来检查信号状态。这种循环也称为自旋锁(spin lock):线程被唤醒后会再次检查条件,若条件不满足则重新等待。
改进后的代码如下:
public class MyWaitNotify3 {
MonitorObject myMonitorObject = new MonitorObject();
boolean wasSignalled = false;
public void doWait() {
synchronized (myMonitorObject) {
while (!wasSignalled) {
try {
myMonitorObject.wait();
} catch (InterruptedException e) {
// 处理中断
}
}
// 清除信号并继续执行
wasSignalled = false;
}
}
public void doNotify() {
synchronized (myMonitorObject) {
wasSignalled = true;
myMonitorObject.notify();
}
}
}
现在,即使发生虚假唤醒,由于 wasSignalled 仍为 false,线程会再次进入 wait()。
多个线程等待同一信号
while 循环的另一个好处是:当多个线程因 notifyAll() 被同时唤醒,但只有一个线程应继续执行时,该机制依然有效。
具体过程如下:
- 只有一个线程能获得监视器对象的锁,从而退出
wait()并清除wasSignalled标志; - 其他被唤醒的线程随后依次获得锁,但在
while循环中发现wasSignalled已被清空,于是重新进入等待状态。
这样确保了即使使用 notifyAll(),也只有一个线程真正响应信号。
不要在常量字符串或全局对象上调用 wait()
早期版本的本文曾使用空字符串 "" 作为监视器对象,如下所示:
public class MyWaitNotify {
String myMonitorObject = "";
boolean wasSignalled = false;
public void doWait() {
synchronized (myMonitorObject) {
while (!wasSignalled) {
try {
myMonitorObject.wait();
} catch (InterruptedException e) { /*...*/ }
}
wasSignalled = false;
}
}
public void doNotify() {
synchronized (myMonitorObject) {
wasSignalled = true;
myMonitorObject.notify();
}
}
}
这是错误的做法!
原因:JVM/编译器会将常量字符串(如 "")内部化为同一个对象实例。这意味着,即使你创建了两个不同的 MyWaitNotify 实例,它们的 myMonitorObject 实际上指向同一个字符串对象。
后果:
- 线程 A 和 B 在第一个实例上等待;
- 线程 C 和 D 在第二个实例上等待;
- 但所有线程实际上都在同一个字符串对象上等待。
此时,若在第二个实例上调用 doNotify()(即 notify()),可能会错误地唤醒线程 A 或 B。由于 wasSignalled 标志存储在各自的实例中,A/B 检查后发现本实例未收到信号,于是重新等待——但 C/D 却从未被唤醒!
这就导致了信号丢失,类似于前面提到的问题。
为什么不用 notifyAll()?
你可能会想:“那我总是用 notifyAll() 不就行了?”
但这是性能反模式:没有必要唤醒所有等待线程,尤其当只有一个线程能处理信号时。
正确做法
始终使用专属于当前通信结构的唯一对象作为监视器。例如,每个 MyWaitNotify 实例应拥有自己的 MonitorObject 实例(如最初示例所示),而不是共享全局对象或字符串常量。