在构建高并发、高性能的 Java 应用程序时,多线程编程是我们必须掌握的核心技能。然而,让多个线程协同工作而不是相互冲突,往往比简单地启动线程要复杂得多。你是否遇到过这样的情况:一个线程需要等待某个条件成立后才能继续执行,或者多个线程需要按照特定的顺序访问共享资源?
为了解决这些协同问题,Java 提供了一套优雅的线程间通信机制。在这篇文章中,我们将深入探讨这个机制中的核心方法——wait()。我们将通过丰富的代码示例和原理解析,带你全面掌握 wait() 方法的用法、注意事项以及最佳实践。
什么是 wait() 方法?
简单来说,wait() 是 Java 中用于实现线程间通信的一种机制。它定义在 java.lang.Object 类中,这意味着 Java 中的每一个对象都拥有这个方法。
当一个线程在同步代码块或同步方法中调用对象的 wait() 方法时,该线程会释放该对象的锁,并进入等待状态,直到其他线程调用了同一个对象的 notify() 或 notifyAll() 方法来唤醒它。
方法的签名与异常
在我们开始写代码之前,让我们先从技术上了解它的定义。wait() 方法有几个重载版本,最基础的形式如下:
语法:
public final void wait() throws InterruptedException
在调用这个方法时,我们需要处理两个关键的异常:
- InterruptedException(中断异常):如果当前线程在等待通知之前或期间被任何其他线程中断,就会抛出此异常。这是 Java 处理线程中断的标准方式。
- IllegalMonitorStateException(非法监视器状态异常):如果调用 wait() 方法的线程当前不拥有该对象的监视器(即没有持有该对象的锁),就会抛出此异常。这是初学者最容易遇到的错误之一。
wait()、notify() 与 notifyAll() 的协作原理
理解 wait() 的工作原理,离不开它的两个“搭档”:notify() 和 notifyAll()。让我们通过一个形象的比喻来理解它们是如何工作的。
想象一个公共卫生间(同步资源/锁)。
- synchronized (锁):卫生间的门锁。同一时间只能有一个人使用(线程持有锁)。
- wait():当前正在使用卫生间的人发现没纸了(条件不满足)。他必须走出卫生间(释放锁),去到门口的等待区排队(等待池),让别人进来帮忙放纸。
- notify():另一个人进来放完纸后,告诉排队的那个人:“嘿,有纸了,你可以进来了”。这会唤醒等待区中的一个线程。
- notifyAll():这个人大喊一声:“卫生间有纸了!”,唤醒等待区中所有在等待的线程。然后所有被唤醒的线程会去争抢卫生间的那把锁(竞争执行权)。
核心工作流程:
- 获取锁:线程必须先进入 synchronized 块。
- 释放锁并等待:调用 wait() 后,线程立即释放对象锁,并进入休眠。
- 被唤醒:其他线程调用 notify/notifyAll。
- 重新获取锁:被唤醒的线程必须重新抢到锁,才能从 wait() 方法处继续往下执行。
> 注意: 调用 notify() 或 notifyAll() 并不会导致当前线程释放锁。只有在同步代码块执行完毕,当前线程才会释放锁。
实战示例 1:经典的生产者-消费者模型
让我们通过一个经典的“生产者-消费者”场景来演示 wait() 的威力。在这个场景中,我们有一个共享的缓冲区。
- 生产者:如果缓冲区满了,生产者必须 wait(),等待消费者腾出空间。
- 消费者:如果缓冲区空了,消费者必须 wait(),等待生产者放入数据。
class SharedBuffer {
private int contents;
private boolean available = false;
// 消费者获取数据
public synchronized int get() {
// 如果数据不可用(缓冲区为空),线程进入等待状态
while (available == false) {
try {
// 释放锁,等待生产者唤醒
System.out.println("消费者:缓冲区为空,等待生产...");
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println("消费者:获取数据 -> " + contents);
available = false;
// 通知生产者可以继续生产了
notifyAll();
return contents;
}
// 生产者放入数据
public synchronized void put(int value) {
// 如果数据可用(缓冲区已满),线程进入等待状态
while (available == true) {
try {
// 释放锁,等待消费者取走数据
System.out.println("生产者:缓冲区已满,等待消费...");
wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
contents = value;
available = true;
System.out.println("生产者:放入数据 -> " + value);
// 通知消费者可以取数据了
notifyAll();
}
}
public class ProducerConsumerDemo {
public static void main(String[] args) {
SharedBuffer buffer = new SharedBuffer();
// 生产者线程
new Thread(() -> {
for (int i = 1; i {
for (int i = 1; i <= 3; i++) {
buffer.get();
try { Thread.sleep(1000); } catch (Exception e) {}
}
}).start();
}
}
实战示例 2:模拟射击游戏中的弹药补给
让我们看一个更具互动性的例子,类似于你在游戏开发中可能遇到的场景。我们创建一个 GunFight 类,其中包含子弹数量。
- fire() 方法:发射子弹。当子弹归零时,线程调用 wait() 进入等待,等待“补给”。
- reload() 方法:补充弹药。补充完毕后调用 notify() 唤醒正在等待的战斗线程。
class GunFight {
private int bullets = 20; // 初始20发子弹
// 发射子弹的方法
synchronized public void fire(int bulletsToFire) {
System.out.println("开始发射 " + bulletsToFire + " 发子弹...");
for (int i = 1; i gf.fire(40)).start();
// 主线程稍作停顿,模拟补给兵稍后到达
try { Thread.sleep(1000); } catch (Exception e) {}
// 线程2:补给兵,给枪支装弹
new Thread(() -> gf.reload()).start();
}
}
输出分析:
你会看到战斗线程发射完20发子弹后停止,等待补给线程运行并调用 notify(),然后战斗线程恢复并发射剩余的子弹。这就是线程间通信的魔力。
深入探讨:为什么 wait() 必须在 synchronized 块中?
你可能会好奇,为什么 Java 强制要求 wait() 方法必须在 synchronized 上下文中调用?
- 底层的监视器模型:Java 的对象锁是基于“监视器”概念的。只有拥有监视器(持有锁)的线程才有资格进入等待室。如果没有锁就直接等待,系统将无法知道你在等待哪个资源,也无法保证线程安全。
- 竞争条件:如果可以在同步块外部调用 wait(),就会发生“竞态条件”。想象一下:你检查了条件发现没资源(此时还没锁),正准备 wait(),另一个线程插队把资源放了进来并 notify()。等你开始 wait() 时,你可能就会错过这个通知,导致永久睡眠。
常见陷阱:wait() vs sleep()
这是面试中最常见的问题,也是开发中容易混淆的地方。
- 来源:sleep() 是 Thread 类的静态方法;wait() 是 Object 类的实例方法。
- 锁的释放(最关键区别):
* sleep():线程休眠,但不释放锁。如果是在同步块中 sleep,其他线程无法进入该对象的同步代码块。
* wait():线程休眠,并释放锁。这给了其他线程进入同步块并执行 notify() 的机会。
- 唤醒条件:sleep() 必须等待时间结束;wait() 可以被 notify() 唤醒,也可以通过 wait(timeout) 设置超时自动唤醒。
- 使用场景:简单的暂停用 sleep();涉及多线程协调共享资源时,必须用 wait()。
实战示例 3:wait(long timeout) —— 带超时的等待
在实际业务中,我们往往不希望线程无限期地等待下去,否则可能会导致系统死锁或假死。Object 类还提供了一个带超时参数的 wait() 方法:wait(long timeout)。
如果超过指定时间,还没有被 notify(),线程也会自动醒来并尝试获取锁。
class TimedWaiExample {
synchronized public void waitForResponse() throws InterruptedException {
System.out.println("等待响应(最多3秒)...");
// 等待 3000 毫秒。如果 3 秒内没人 notify,它会自动醒来
// 注意:返回后还需要重新获取锁才能继续执行
wait(3000);
System.out.println("等待结束,继续执行主业务逻辑。可能是被唤醒,也可能是超时了。");
}
synchronized public void triggerResponse() {
System.out.println("收到请求!");
notify();
}
}
这种模式在微服务调用、数据库连接池等待等场景中非常常见。
常见错误与最佳实践
在编写多线程代码时,你可能会遇到一些棘手的问题。让我们来看看如何避免它们。
1. 使用 while 循环检查条件,而不是 if
请看下面的对比:
- 不推荐:
synchronized(this) {
if (conditionNotMet) {
wait();
}
}
synchronized(this) {
while (conditionNotMet) {
wait();
}
}
为什么?这被称为“虚假唤醒”。在极少数情况下(由于操作系统的底层实现机制),线程可能会在没有收到 notify() 的情况下被唤醒。如果你使用 if,线程醒来后会直接往下执行,此时条件可能并不满足,导致程序出错。使用 while 循环可以确保线程醒来后会再次检查条件。
2. 优先使用 notifyAll(),谨慎使用 notify()
notify() 只会随机唤醒一个等待的线程。如果有多个线程因为不同的原因等待在同一个对象上,notify() 可能会唤醒一个错误的线程,导致死锁。notifyAll() 虽然开销稍大(唤醒所有人竞争锁),但它更安全,能确保所有相关线程都有机会检查条件。
3. 确保异常处理
wait() 会抛出 InterruptedException。在捕获到这个异常时,通常意味着线程被外部取消。你应该清理现场并优雅地退出线程。
性能优化与实战技巧
在大型系统中,过度的线程阻塞(wait)会导致上下文频繁切换,影响性能。
- 缩小锁的范围:尽量减少 synchronized 代码块的范围。只在需要检查条件或修改共享数据的地方加锁,不要把耗时计算放在锁里面。
- 并发工具类:除了基础的 wait/notify,Java 还提供了更高级的工具,如 CountDownLatch, CyclicBarrier, Semaphore 等。如果你的场景很复杂(比如多线程需要等到所有任务都完成),使用这些类通常比自己写 wait/notify 更安全、更高效。
总结
在这篇文章中,我们像拆解钟表一样,详细分析了 Java 中的 wait() 方法。从基本的语法签名,到线程间通信的底层逻辑,再到生产者-消费者、弹药补给等实战案例,我们看到了 wait() 是如何通过“释放锁-进入等待-被唤醒-重新获取锁”的流程来实现多线程协作的。
关键回顾:
- wait() 必须在 synchronized 代码块中调用,且会释放锁。
- 永远使用
while(condition) { wait(); }来处理等待逻辑,防止虚假唤醒。 - 理解 wait() 与 sleep() 的核心区别在于是否释放锁。
掌握了这些,你就已经具备了编写复杂并发 Java 应用的基础。当你下次需要处理多线程同步问题时,不妨试着在脑海中画出那个“卫生间”的模型,你会发现设计线程通信流程变得更加清晰了。希望这篇文章能帮助你在多线程编程的道路上更进一步!