并发编程里,wait、notify、join这三兄弟是每个Java程序员都绕不过去的坎。面试的时候,十个候选人里至少有七八个能把“wait会释放锁,notify不会释放锁”这句话背出来,但真要现场写一段多线程协作的代码,或者解释一下join底层到底是怎么把主线程阻塞住的,能讲清楚的就没几个。
我最早接触这块也是在准备面试的时候,被各种“线程状态流转图”搞到头大。后来自己造轮子、写生产者消费者、调一个又一个死锁,才慢慢把wait/notify/join在JVM里的真实逻辑搞明白。这篇文章我打算直接从实际的代码场景切入,带你走一遍从“抢锁”到“等待唤醒”再到“重新抢锁”的完整链路,把为什么wait必须在synchronized里、notify到底唤醒了谁、join凭什么不用notify也能阻塞主线程这些烂熟于胸。
1. 从一道经典面试题说起:两个线程交替打印的底层博弈
先抛一个我当年面试被问到的题,也是网上流传很广的入门题:两个线程交替打印,数字线程打印1到26,字母线程打印A到Z,输出结果必须是1 A 2 B 3 C … 26 Z。
你可以先在心里想想自己会怎么写。很多人第一反应是:这有什么难的,用两个线程,加个synchronized就行。于是写出来可能是下面这样。
1.1 第一版实现:没有条件等待的雏形
public class AlternatePrintV1 { private static final Object LOCK = new Object(); private static int num = 1; private static char letter = 'A'; public static void main(String[] args) throws InterruptedException { Thread numberThread = new Thread(() -> { synchronized (LOCK) { for (int i = 0; i < 26; i++) { System.out.print(num++ + " "); LOCK.notify(); try { LOCK.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } LOCK.notify(); } }); Thread letterThread = new Thread(() -> { synchronized (LOCK) { for (int i = 0; i < 26; i++) { System.out.print(letter++ + " "); LOCK.notify(); try { LOCK.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } LOCK.notify(); } }); numberThread.start(); // 这里休眠一下,确保数字线程先抢到锁 Thread.sleep(10); letterThread.start(); } }这段代码如果你直接跑,大概率能输出1 A 2 B 3 C …,但你不要高兴太早,这个写法有很多隐患。先说第一层理解:数字线程先拿到LOCK锁,打印1,然后notify通知一下(此时字母线程可能还没启动,所以这个通知是空的),随后wait让出锁并把当前线程挂起。字母线程等锁释放后拿到LOCK,打印A,再notify唤醒数字线程,自己wait,如此交替。
这里最关键的动作就是wait()和notify()的配合:每次打印完都要先把对方叫醒,再让自己睡过去。从流程上看,它做到了“你方唱罢我登场”。
1.2 第一版代码的连个坑
我当年写第一版的时候,运行完也没有多想。但接着我做了个测试:把主线程里的Thread.sleep(10)去掉,让两个线程完全自由竞争,结果偶尔会出现A 1 B 2 C 3 …这种字母先开头的情况;多跑几次还可能出现1 A 2 B 3 C … 25 Y 26之后字母线程彻底卡死,再也没有输出Z。
卡死的原因很简单:数字线程最后一次循环打印完26后执行LOCK.notify(),因为此时字母线程可能已经打印完Z并wait,所以这个notify会把它唤醒;但数字线程随后退出synchronized块、释放锁。字母线程被唤醒后需要重新抢锁,抢到锁后执行循环体,可是它已经执行完26次循环了,所以出for循环后不会再执行wait()。这里的“卡死”其实是字母线程跑完就结束了,主线程没有等待它。而如果某个notify发生在对方还没有wait的时候,这个信号就白发了,可能造成一个线程已经跑完、另一个线程却还阻塞着等别人唤醒。
这个例子告诉我们,wait/notify协作的本质不是“互相喊话”,而是“基于共享状态的条件等待”。没有状态判断,没有在循环里等待,代码就永远是脆弱的。更标准的写法应该是用一个boolean numberTurn之类的状态变量来控制顺序,配合while (!condition) wait()来实现,这部分后面我会专门展开。
2. 先把地基打牢:monitor锁与线程状态流转模型
聊wait/notify之前,必须先搞清楚一个东西:它俩不是Object类上随便定义的两个普通方法,而是依赖“监视器锁(Monitor)”工作的JVM底层机制。很多人在这一层就模糊了,所以后面看什么都像是背结论。
2.1 对象监视器:_owner、_EntryList、_WaitSet怎么协作
HotSpot虚拟机里,每个Java对象都有一个对应的ObjectMonitor,你可以把它理解为“这个对象作为锁时的一张管理台账”。这张台账里最重要的四个东西:
_owner:记录当前持有锁的线程。_cxq/_EntryList:存放想要抢锁但还没抢到的线程队列。_WaitSet:存放调用了wait()之后被挂起的线程队列。_recursions:记录可重入次数。
当你写synchronized (LOCK) { ... }时,JVM做的就是让当前线程去竞争LOCK对象对应的ObjectMonitor,竞争成功就设置_owner为当前线程。如果在持锁期间再次遇到同一个锁的同步块,就把_recursions加1,这就是可重入的由来。
_WaitSet是wait/notify机制的核心。wait()做的事情就是把当前线程从_owner上摘下来,放到_WaitSet里面去;而notify()做的事情恰好相反,从_WaitSet里挑一个线程"转移"到锁竞争队列。这两个操作都要求_owner必须是当前线程,否则JVM根本不知道你在操作哪个锁的哪个队列,就直接抛IllegalMonitorStateException。
所以官方文档说的是“wait/notify必须在持有monitor的代码块里调用”,本质上就是:你只能操作“你当前拥有的那把锁”的_WaitSet,不能隔空去操作别人的。
2.2 线程六种状态与wait/notify的映射
Java线程在任意时刻都处在Thread.State枚举里定义的一个状态中,下面这张对照表要刻在脑子里:
| 线程状态 | 含义 | 典型触发方式 |
|---|---|---|
| NEW | 创建还没start | new Thread(...) |
| RUNNABLE | 正在执行或准备执行 | start() |
| BLOCKED | 等待monitor锁 | 进入synchronized但锁被占用 |
| WAITING | 无限期等待 | wait() 无超时、join()、LockSupport.park() |
| TIMED_WAITING | 有限期等待 | wait(1000)、sleep(1000)、join(1000) |
| TERMINATED | 执行完毕 | run()返回 |
看到没有,wait/notify直接影响的状态是 WAITING、TIMED_WAITING,而锁竞争对应的是 BLOCKED。这俩状态很多人容易搞混,我在面试的时候爱问一个问题:“线程调用了wait之后,是什么状态?”正确答案是WAITING;问“线程被notify唤醒之后,是什么状态?”很多人脱口而出RUNNABLE,但其实它是先进入BLOCKED,去排队抢锁,抢到才变RUNNABLE。
这个细节在后面第3节会有完整的状态流,先记住:WAITING是“我已经放弃锁,躺在等待室里”,BLOCKED是“我在门口排队,等锁腾出来”。
2.3 锁膨胀:为什么wait/notify只认重量级锁
聊到 synchronized,很多有经验的人会提到锁的升级过程:无锁、偏向锁、轻量级锁、重量级锁。偏向锁和轻量级锁是JVM为了优化无竞争场景发明的机制,它们不维护_WaitSet,只处理“少量线程来回抢锁”的情况。
一旦某个线程对某个对象调用wait(),JVM必须让这个对象进入重量级锁状态,因为只有重量级锁对应的ObjectMonitor才有条件队列去挂起和唤醒线程。这个操作叫“锁膨胀”,是JVM自动完成的。你可以简单理解为:wait/notify这招太“重”了,只能用重量级锁体系才能实现。这也是为什么wait/notify的性能瓶颈比LockSupport等工具更大。
这个知识点对面试有用,对排查线上问题同样有用。如果你看到 jstack 输出里某个线程是waiting on <0x...>,而这个对象明明只是个普通HashMap之类的东西,不要意外,它一定是被当成monitor用过。
3. wait/notify真正执行了什么:状态流转全链路拆解
这一节我打算把“从抢锁到等待唤醒”的完整过程逐步拆开。用一个最简单场景:两个线程A和B,用同一个LOCK对象做同步,A先拿到锁,然后调用wait,B再去抢锁,最后A被notify唤醒。
3.1 第一步:从抢锁到进入同步块
三个线程同时运行,线程A抢到了LOCK的monitor锁,_owner = A,A进入synchronized块执行。线程B和C没有抢到,它们被挂到_EntryList或对应的锁竞争队列中,状态从 RUNNABLE 变成 BLOCKED。
注意,A抢到锁后,B和C的“抢”并没有结束,它们只是被JVM暂时挂起,一旦A释放锁,它们就会重新参与竞争。这个阶段跟wait/notify还没有任何关系,但它是理解后面被notify线程行为的基础。
3.2 第二步:调用wait的那一刻发生了什么
A在同步块里执行LOCK.wait(),JVM会按顺序做下面几件事:
- 校验当前线程 A 是不是
_owner,不是就直接抛IllegalMonitorStateException。这一步是拦路虎,很多初学者第一次写就栽在这。 - 把 A 封装成一个节点,加入到 LOCK 对应的
_WaitSet中。注意此时用的锁对象是LOCK对象,跟_EntryList里的竞争队列是两套不同的队列。 - 释放 monitor 锁,此时
_owner = null,_recursions清零。如果你是在可重入锁里调用了两次wait,这里的释放是“一次性全部释放”,不是只减一层。 - A 线程状态变为 WAITING,不再参与CPU调度。也就是说,它不是在那里空转等锁释放,而是真的被停掉了。
这四步里最容易理解错的是第3步。很多人看介绍知道“wait会释放锁”,但不知道它释放的是“这把对象的monitor锁”。如果A在同一个synchronized块里还持有了其他对象的锁,wait()不会把那些锁一起释放。现实中我见过一个极端的坑:synchronized锁的是A对象,方法里又用synchronized (B)加了一把B锁,然后调用了A.wait(),结果A锁释放了,B锁还在,其他线程照样进不来。
3.3 第三步:B线程趁虚而入
A释放锁之后,B从_EntryList中被唤醒,成功抢到LOCK锁,_owner = B,B进入同步块执行。此时A还在_WaitSet里安静地躺着。
这时的状态格局是:
| 线程 | 状态 | 位置 |
|---|---|---|
| A | WAITING | LOCK对象的_WaitSet |
| B | RUNNABLE | 持有LOCK锁,正在执行 |
| C | BLOCKED | LOCK对象的_EntryList等待队列 |
B在同步块里可以正常做事,执行完后调用LOCK.notify()。这里有个非常关键的点:notify本身不释放锁。B执行完notify之后,仍然持有LOCK锁,继续往下执行同步块里的剩余代码,直到离开synchronized块,monitor锁才会释放。这就是网上那句话“wait释放锁,notify不释放锁”的准确含义。
3.4 第四步:被notify的线程经历了什么
notify的完整逻辑是从_WaitSet里挑一个线程(具体挑哪个,取决于JVM实现,不保证公平),把它移到锁竞争队列。被挑中的线程A从 WAITING 变为 BLOCKED,开始参与monitor锁的竞争。
关键点来了:A不是直接恢复运行的。A要等B彻底释放锁之后,再去参与和C等其他线程的锁竞争。如果竞争失败,A就继续在BLOCKED队列里等待;如果竞争成功,A才回到 RUNNABLE 状态,从当初wait()调用的地方继续往下执行。
所以完整的状态流转是:
A: RUNNABLE(抢到锁) -> WAITING(调用wait) -> BLOCKED(被notify后排队) -> RUNNABLE(重新抢到锁)如果调用的是wait(1000),走的是另一条路:A在_WaitSet里待满1000毫秒后,自动被转移回锁竞争队列,状态从 TIMED_WAITING 变为 BLOCKED。所以在线程池任务执行到某个wait超时点时,线程状态里看到的既有TIMED_WAITING也有后续的BLOCKED,完全正常。
3.5 happens-before:wait/notify之间的内存可见性
除了状态流转,wait/notify还牵扯到内存可见性。JVM规范中,同一个monitor锁下的wait/notify操作存在天然的happens-before关系:
- 线程A在调用
wait()之前的写入操作,对线程B在后续获取同一把锁并继续执行时是可见的。 - 线程B在调用
notify()之前的写入操作,对被唤醒的线程A在重新获取锁之后是可见的。
下面这个例子很直观:
class MessageHolder { private String message; private boolean hasMessage = false; public synchronized void put(String msg) { while (hasMessage) { try { wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } this.message = msg; hasMessage = true; notifyAll(); } public synchronized String take() throws InterruptedException { while (!hasMessage) { wait(); } String msg = this.message; hasMessage = false; notifyAll(); return msg; } }put线程写入message字段和hasMessage状态,take线程被唤醒后重新抢到锁,能看到put线程写入的最新值,靠的就是这层happens-before保证。所以不仅synchronized块本身有内存屏障语义,wait/notify的配对使用也会形成内存屏障。
4. join的底牌:没有notify的等待机制怎么运转
聊完wait/notify,我们再看join。很多人只知道b.join()会让主线程等子线程b执行完,但问到底层原理就只记得“类似wait”。其实join的源码确实就是wait机制,但它有个特别反直觉的设计——join源码里看不到任何notify调用,那它是靠什么唤醒等待线程的?
4.1 join到底在等什么:isAlive循环
先看Thread.join()的真实源码,下面这个是JDK 8里的实现:
public final synchronized void join(long millis) throws InterruptedException { long base = System.currentTimeMillis(); long now = 0; if (millis < 0) { throw new IllegalArgumentException("timeout value is negative"); } if (millis == 0) { while (isAlive()) { wait(0); } } else { while (isAlive()) { long delay = millis - now; if (delay <= 0) { break; } wait(delay); now = System.currentTimeMillis() - base; } } }注意看两个核心点:
join是synchronized方法,也就是说它锁的是this,这个this就是被join的线程对象本身。- 它在
while (isAlive())循环里不停调用wait(0),wait(0)表示不限时等待,直到有人来唤醒。
也就是说,主线程调用b.join()时,实际上是持有b线程对象monitor锁,然后判断b还活着就进入WAITING状态等待。这个“等待”和普通wait没有任何区别,区别只在于谁来唤醒它。
4.2 JVM线程退出时的自动notifyAll
join不手动调notify,因为JVM在线程退出时会自动做一次notifyAll。具体来说,HotSpot在执行线程退出流程时,会调用JavaThread::exit(),在退出过程中会对线程对象执行一个notifyAll(),把所有正在等待这个线程对象锁的线程全部唤醒。
这样想就通了:b.join()等待的是“b线程终止”这个条件,而JVM保证b线程终止的那一刻,一定会唤醒所有等在b这个对象监视器上的线程。唤醒之后,这些线程重新进入BLOCKED状态抢锁,抢到锁后从wait(0)返回,继续执行while (isAlive())检查。因为b已经终止,isAlive()返回false,循环退出,join返回。
这同时解释了另一个很隐蔽的问题:为什么join源码里要用while (isAlive())而不是if (isAlive())?因为如果主线程是被其他原因误唤醒的(虚假唤醒),或者有别的线程故意对b对象做了notify,b并没有死,那wait(0)返回后必须重新检查isAlive(),否则join会提前返回,后续逻辑就全乱了。这也印证了wait的标准用法:永远在循环里等待。
4.3 用wait/notify手写一个join
理解原理最好的方式是自己实现一遍。下面这个我手写的简易join,功能跟Thread.join()基本一致:
public class SimpleJoin { private final Thread target; private boolean alive = true; public SimpleJoin(Thread target) { this.target = target; } public synchronized void myJoin() throws InterruptedException { while (alive) { wait(); } } // 模拟目标线程执行完毕时,由JVM/逻辑代码调用 public synchronized void markTerminated() { this.alive = false; notifyAll(); } public static void main(String[] args) throws InterruptedException { Thread worker = new Thread(() -> { System.out.println("worker start"); try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("worker end"); }); SimpleJoin joinHelper = new SimpleJoin(worker); Thread wrapper = new Thread(() -> { try { worker.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } joinHelper.markTerminated(); }); wrapper.start(); System.out.println("main waiting"); joinHelper.myJoin(); System.out.println("main continue"); } }这个例子很粗糙,但核心思想是透出的:join的本质就是“持有目标线程对象的monitor,等待目标线程终止条件为真”,而“目标线程终止”这个条件由JVM保证去变更并唤醒等待线程。
4.4 join(timeout)为什么不会被中断卡死
再看join(2000)这种带超时的版本。它的逻辑是用wait(delay)等待最多delay毫秒,等被唤醒或超时后,重新计算已等待时间,再判断isAlive()。如果目标线程还在运行而总等待时间已经超过指定值,就退出循环,join返回。
所以join(2000)保证最多阻塞2秒,但不保证目标线程一定已终止。如果你想在任务超时后做后续处理,需要主动检查线程状态,或者用更现代的Future.get(timeout)机制。
另一个值得说的是InterruptedException。join方法声明了throws InterruptedException,因为内部调用了wait。如果调用join的线程在等待期间被其他线程interrupt,wait会抛InterruptedException,线程状态被清掉。所以调用join的时候,建议要么向外抛,要么捕获后重新设置中断标志:
try { worker.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 做中断相关的清理工作 }5. 实战中的坑与排查思路
wait/notify/join用得不熟,最容易出现一堆很难查的死锁、活锁问题。我把这些年实际踩过的、帮别人排查过的坑集中列一下,每一个几乎都在生产环境或真实面试中出现过。
5.1 锁对象不一致:wait的是A,notify的是B
这个坑特别容易出现在用多个锁对象的代码里。比如两个线程约定用LOCK对象做同步,但某天一个线程写LOCK.wait(),另一个线程不巧写了LOCK2.notify(),两个对象根本不是同一个monitor,唤醒信号永远传不到等待线程耳朵里。
排查手法:看到 jstack 输出里线程A停在waiting on <0x...>,就把这个对象地址记下来,再去其他线程的堆栈里找对应地址的monitor信息。如果双方地址不一致,基本就是锁对象选错了。
5.2 条件判断用if导致虚假唤醒
这是面试必考、实战必踩的经典问题。wait的标准写法必须是:
synchronized (LOCK) { while (!condition) { wait(); } // 条件满足,继续执行 }很多初学者写的是:
synchronized (LOCK) { if (!condition) { wait(); } // 直接执行 }问题在于,wait()的返回不一定是被notify唤醒的,它可能被虚假唤醒(spurious wakeup)打断。另外,就算是被普通notify唤醒,也不能保证唤醒你的时候条件一定满足——因为另一个线程可能在你被唤醒前就已经把条件改回false了。举个例子,生产者消费者里消费者被唤醒后,队列里的数据可能已经被其他消费者抢走了,如果用的是if,当前消费者拿到一个空数据或者出错。
JDK源码里,ArrayBlockingQueue的await/signal相关代码也都是放在while循环里的。
5.3 notify与notifyAll的取舍
一个经典场景:生产者和多个消费者。如果你在生产者里只调notify(),它只会唤醒_WaitSet里的一个线程。如果恰好唤醒的是一个消费者,没问题;如果唤醒的也是个生产者,而这个生产者发现自己生产的条件不满足(比如队列满),又会继续wait,此时队列里明明有数据,消费者却一个都没被唤醒,整个程序就卡住不动了。
项目里如果不太确定“这个monitor上等待的线程类别是否单一”,优先用notifyAll()比较安全,代价是可能造成“惊群效应”——一次唤醒所有线程,让它们重新竞争锁。在竞争不激烈的业务场景下,这个性能损失可忽略。
5.4 用jstack快速定位线程卡死
线程莫名其妙不跑了,第一件事不是瞎猜,而是用jstack <pid>把线程快照打出来。下面是一份典型输出的关键片段:
"pool-1-thread-3" #13 prio=5 os_prio=0 cpu=156.25ms elapsed=25.60s tid=0x00007f7cfd4c1000 nid=0x4cd3 in Object.wait() [0x00007f5c9c7fb000] java.lang.Thread.State: WAITING (on object monitor) at java.lang.Object.wait(Native Method) at java.lang.Object.wait(Object.java:502) at com.example.BlockingQueueExample.lambda$main$1(BlockingQueueExample.java:18) - waiting on <0x000000076ab5f000> (a java.lang.Object) at com.example.BlockingQueueExample$$Lambda$2/0x00000008400a0040.run(...)第一行看线程状态:WAITING (on object monitor)表示它正处于wait等待;如果是BLOCKED (on object monitor),说明它在排队抢锁。接着看waiting on <0x...>这个对象地址,再去其他线程的堆栈里搜同一个地址,往往就能找到谁持有这把锁却没有释放。
修复过几次之后你会发现,大多数死等问题都能归结为一个非常简单的结论:没有线程在正确的对象monitor上调用notify/notifyAll。要么通知方向不对,要么通知时机不对,要么压根没有通知。
5.5 别急着手写wait/notify:现代并发工具更省心
最后说点真心话。wait/notify是Java并发的最底层基础,你面试必须懂,源码阅读必须懂。但到了真实业务开发中,如果你还在大量手写wait/notify实现生产者消费者、任务编排,我建议停下来想一下是不是有更合适的工具。
CountDownLatch:等待N个事件完成,底层基于AQS,语义比手动wait/notify清晰得多。Semaphore:控制并发访问量。BlockingQueue:生产者消费者的标准答案,底层封装好了等待唤醒逻辑。CompletableFuture:异步任务编排,join的进阶替代,能处理超时、异常、回调。LockSupport.park/unpark:更灵活的线程挂起与唤醒,不要求持有锁,JDK内部大量使用。
我个人在实际项目里,只有碰到非常底层的框架代码或者需要精确控制monitor语义的场景,才会手写wait/notify。日常业务代码里,用并发工具类既安全又易读。不过这不代表你可以不懂底层——恰恰相反,只有把wait/notify/join的底层状态流转吃透了,你才能理解为什么ConcurrentLinkedQueue可以无锁,为什么ThreadPoolExecutor用LockSupport做线程管理,为什么AQS里的Condition要用一个类似WaitSet的队列。地基打得越扎实,上层工具用起来才越有底气。