很多开发者第一次接触wait和notify时,都会记住一条规则:调用wait必须先写在synchronized代码块里。但背下这条规则和理解它背后的原因,是两回事。

真正理解这个问题的关键,是意识到wait方法内部做的第一件事不是阻塞自己,而是释放锁。既然它的逻辑里包含释放锁,那调用方当然得先持有这把锁。接下来从源码和设计两个层面,讨论一下。

synchronized到底在保护什么

Java里每个对象头上都关联着一个叫做monitor的东西,可以把它理解为对象自带的一把隐式锁。synchronized关键字的作用,就是让当前线程去竞争这把锁的所有权。竞争成功了,当前线程成为这把锁的owner,才能进入临界区执行代码。其他线程如果也来竞争,会被挡在外面,进入阻塞或自旋状态。

也就是说,synchronized保护的从来不是对象本身的数据,而是对数据的操作顺序。它保证在同一时刻,只有一个线程能执行某段代码。这是理解wait的前提。

wait方法到底做了什么

wait不是简单地把线程挂起。它的完整逻辑大致分为四步:

  1. 检查当前线程是否持有对象的monitor,如果没有,直接抛IllegalMonitorStateException
  2. 把当前线程挂到该对象的一个等待队列里,这个队列在HotSpot源码里叫做WaitSet
  3. 释放当前线程持有的monitor
  4. 把线程挂起,直到被notify、中断或超时

注意第三步。wait在让线程睡觉之前,会主动把锁交出去。如果不交,其他线程进不了临界区,也就永远没机会执行notify来叫醒它。那将是一个死局。

既然wait的逻辑里包含释放锁,那调用方自然必须先持有锁。不持有就释放,从语义上就说不通。

下图展示了wait的完整生命周期:在这里插入图片描述

源码层面的硬性检查

在JDK 17的HotSpot源码里,Object.wait方法最终会走到ObjectMonitor::wait。这个方法的第一行就是CHECK_OWNER():

void ObjectMonitor::wait(jlong millis, bool interruptible, TRAPS) {
    CHECK_OWNER();  // 不持有锁就抛IllegalMonitorStateException
    // ... 后续逻辑
}

CHECK_OWNER内部会检查当前线程是否是这把monitor的owner:

bool ObjectMonitor::check_owner(TRAPS) {
    void* cur = owner_raw();
    if (cur == current) {
        return true;
    }
    THROW_MSG_(vmSymbols::java_lang_IllegalMonitorStateException(),
               "current thread is not owner", false);
}

如果当前线程没有持有这把锁,JVM会直接抛出IllegalMonitorStateException。这不是建议,是硬编码的强制检查。所以即使从纯粹的操作层面讲,不写在synchronized里,代码也跑不过这一关。

设计层面的根本原因:防止竞态条件

源码告诉我们JVM强制加了这道检查,但更重要的问题是:为什么JVM的设计者要这么设计?

wait的存在意义,是让一个线程暂停执行,直到某个条件成立。比如一个线程从队列里取数据,发现队列空了,于是调用wait,等别的线程往队列里放了数据再唤醒它。

这里有一个关键细节:线程在决定要不要wait之前,必须先检查条件是否成立。而这个检查和wait操作本身,必须是原子的。也就是说,从检查条件到挂起自己,中间不能被其他线程打断。

想象一下这个场景:

// 错误示范,没有synchronized保护
if (queue.isEmpty()) {
    queue.wait();  // 假设这里没有抛异常
}

线程A执行完if判断,确认队列是空的。就在它准备调用wait的那一瞬间,线程B往队列里塞了一条数据,并且执行了notify。紧接着线程A才执行wait,把自己挂起。但此时已经没有人在notify它了,线程A将永远睡下去。

这就是经典的lost wakeup问题。根本原因是条件检查和wait操作之间出现了一个时间窗口,别的线程趁这个空档修改了条件并发了通知。
在这里插入图片描述

synchronized的作用就是关闭这个时间窗口。线程A先拿到锁,检查条件,发现为空,调用wait。由于wait在释放锁之前不会把线程真正挂起,线程B就算想修改条件,也得先等线程A把锁释放出来。等线程A释放锁并进入WaitSet之后,线程B才能拿到锁、修改条件、执行notify。此时线程A已经安全地等在WaitSet里了,不会错过通知。

所以wait必须配合synchronized,不是为了语法上的好看,而是为了保障程序的正确性。

被唤醒之后,线程还要重新抢锁

当线程被notify唤醒后,它并不能立刻从wait方法返回继续执行。它还得重新去竞争那把monitor锁。竞争成功了,wait才返回;竞争失败,它继续等在锁的入口队列里。

这一点在源码里也很清楚。ObjectMonitor::wait被唤醒后,会调用enter或ReenterI重新获取锁:

// 被唤醒后的重入阶段
if (v == ObjectWaiter::TS_RUN) {
    enter(current);
} else {
    ReenterI(current, &node);
}

这意味着从notify到线程真正恢复执行,中间还有一个锁竞争的过程。很多初学者以为notify之后被唤醒的线程立刻就能跑起来,这是一个常见的误解。

wait和notify的正确使用模板

综合以上分析,使用wait和notify的标准写法应该是下面这样:

synchronized (lock) {
    while (条件不满足) {
        lock.wait();
    }
    // 执行业务逻辑
}

用while而不是if,是为了防范虚假唤醒。wait的文档里明确写了,线程可能在没被notify的情况下自行醒来。醒来后必须重新检查条件,确认真的满足了才能继续往下走。

对应的通知方:

synchronized (lock) {
    // 修改条件
    lock.notifyAll();
}

用notifyAll而不用notify,是为了避免在多个线程等待不同条件时,把错误的线程唤醒。除非你能确定只有一个线程在等待,而且唤醒谁都一样,否则优先用notifyAll。

小结

wait必须先拿到锁,不是因为Java语法多此一举,而是因为wait的内部逻辑本身就包含释放锁。从源码看,HotSpot在ObjectMonitor::wait的第一行就加了owner检查,不持有锁直接抛异常。从设计上看,条件检查和wait操作必须原子化,否则会出现lost wakeup。从完整生命周期看,wait不仅是挂起,还包括释放锁、进WaitSet、被唤醒、重新抢锁这一系列动作。理解了这层,就不会再把wait和synchronized的关系当成需要死记硬背的规则了。

参考的内容

Object.wait官方文档

Java Language Specification: Threads and Locks

最近在知乎出了

  • 「应付6000万会员的秒杀系统专栏」
  • 「几亿用户,百万并发的C端商品系统实战」
  • 「技术团队DDD领域驱动设计三年落地实战」

专栏,感兴趣的可以订阅一下。至于知识星球的,可以搜:

  • 老码头的技术浮生录

它是一个能实际帮你解决难题的星球。有问题的,找知心的Sam哥,支持无限次语音一对一解决你遇到的难题。「另外后续我新写的所有对外的付费专栏,在星球内都是免费的,且可以拿到所有源代码。」

当前星球里免费看的专栏是:

  • 「几亿用户,百万并发的C端商品系统实战」
  • 「技术团队DDD领域驱动设计三年落地实战」

知识星球内后续将推出20+个付费专栏,覆盖电商全链路:

选购线 用户会员营销线 中后台
购物车服务 营销系统 订单系统
商品服务 用户系统 支付系统
菜单服务 结算服务

从前台选购到中后台结算,星球成员全部免费,后续新增也不额外收费。

我的知乎账号:

  • SamDeepThinking
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐