并发编程里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创建还没startnew 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先拿到锁然后调用waitB再去抢锁最后A被notify唤醒。3.1 第一步从抢锁到进入同步块三个线程同时运行线程A抢到了LOCK的monitor锁_owner AA进入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 BB进入同步块执行。此时A还在_WaitSet里安静地躺着。这时的状态格局是线程状态位置AWAITINGLOCK对象的_WaitSetBRUNNABLE持有LOCK锁正在执行CBLOCKEDLOCK对象的_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-beforewait/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线程退出时的自动notifyAlljoin不手动调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对象做了notifyb并没有死那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的线程在等待期间被其他线程interruptwait会抛InterruptedException线程状态被清掉。所以调用join的时候建议要么向外抛要么捕获后重新设置中断标志try { worker.join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); // 做中断相关的清理工作 }5. 实战中的坑与排查思路wait/notify/join用得不熟最容易出现一堆很难查的死锁、活锁问题。我把这些年实际踩过的、帮别人排查过的坑集中列一下每一个几乎都在生产环境或真实面试中出现过。5.1 锁对象不一致wait的是Anotify的是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 prio5 os_prio0 cpu156.25ms elapsed25.60s tid0x00007f7cfd4c1000 nid0x4cd3 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的队列。地基打得越扎实上层工具用起来才越有底气。