Java 中的线程信号通信(Thread Signaling)

更新于 2025-12-28

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 块、释放锁之后才行。

上述原理如下图所示(图中将监视器对象称为“信号对象”):

Thread Signaling Diagram

多个线程可以在同一个监视器对象上调用 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() 被同时唤醒,但只有一个线程应继续执行时,该机制依然有效。

具体过程如下:

  1. 只有一个线程能获得监视器对象的锁,从而退出 wait() 并清除 wasSignalled 标志;
  2. 其他被唤醒的线程随后依次获得锁,但在 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 实例(如最初示例所示),而不是共享全局对象或字符串常量。