资讯动态

Java线程协作机制核心解析:wait/notify、join与yield实战

发布时间:2026/10/14 12:37:33 来源:尧图企业网站定制
1. 线程协作机制全景从“各跑各的”到“互相配合”写线程相关的博客最容易被忽略的就是“协作”这两个字。很多人一说线程就只想到synchronized、Lock这些互斥手段仿佛把资源锁住就万事大吉了。但实际做并发编程你很快就会遇到一类更棘手的问题线程之间不仅要“不打架”还要“互相通知”“互相等待”“主动让路”。比如一个线程要等另一个线程算完结果才能继续再比如生产者要等消费者消费完缓冲区才能继续生产。标题里列的wait/notify、join、yield、suspend/resume这四组机制就是 Java 里处理线程协作的核心手段。它们解决的问题各不相同wait/notify解决的是“条件不满足时挂起条件满足时唤醒”的等待通知问题join解决的是“我等另一个线程结束”的汇合问题yield是“我主动把 CPU 让给别人”的谦让问题suspend/resume是历史遗留的“暴力暂停/恢复”问题。这篇博文适合两类人看一类是刚学完synchronized想知道线程之间怎么配合的新手另一类是写过一些并发代码但被wait的虚假唤醒、notify丢失通知、join底层原理这类细节坑过的开发者。我会把每个机制的原理、正确写法、坑点全部摊开讲代码都能直接拿去改着用。先说一个整体的选型结论后面再逐个拆解日常开发里wait/notify是协作的基础但推荐优先用ReentrantLock配合Conditionjoin用得非常频繁理解它的底层就是理解waityield是个容易误用的方法大部分场景其实不需要suspend/resume已经被废弃绝对不要在新代码里出现。2. wait/notify 机制协作的基石与最容易翻车的地方2.1 理解 wait/notify 的本质不是“让路”而是“条件等待”先说个最容易误解的点wait不是简单地“让线程睡一会儿”notify也不是“把睡着的线程叫醒”。wait/notify解决的是条件变量的问题——一个线程在执行过程中发现某个条件不满足比如缓冲区满了它不能继续往下走但又不想白白占着锁空转于是它释放锁进入等待状态另一个线程把条件满足了比如消费了一个数据于是通知等待的线程“条件变了你可以起来再试试了。”这里有两个关键点理解了它们就理解了一半第一调用wait必须持有该对象的监视器锁monitor也就是必须在synchronized代码块或方法里调用否则直接抛IllegalMonitorStateException。这不是语法限制而是因为wait的操作语义是“释放当前持有的锁并挂起”。如果没有锁谈何释放第二线程被唤醒后并不会立即继续执行而是重新参与锁的竞争拿到锁之后才会从wait调用处返回。而且返回之后它不会检查条件是否真的满足了——所以wait几乎总是要放在循环里这就是所谓的“wait 循环”范式synchronized (lock) { while (!condition) { // 必须是 while不能是 if lock.wait(); } // 条件满足继续干活 }为什么必须用while而不是if两个原因。一是虚假唤醒即使没有线程调用notify某些 JVM 实现里等待线程也可能被唤醒二是竞争唤醒多个线程在同一个对象上等待你调用了notifyAll所有线程都被唤醒但它们拿锁是有先后的先拿到锁的线程可能已经把条件再次改成了不满足后拿到锁的线程如果不重新检查条件就会在错误的前提下继续执行。这两种情况都是真实存在的不是理论上的吹毛求疵。2.2 wait/notify 的标准写法与三个常见误区直接给一套经过实践验证的标准模板后面写代码就按这个来// 等待方 synchronized (lock) { while (条件不满足) { lock.wait(); // 释放锁挂起等待 } // 条件满足后的逻辑 } // 通知方 synchronized (lock) { // 修改条件 修改共享状态; lock.notifyAll(); // 或 lock.notify() }我从这个模板里提炼出三个容易翻车的点都是自己在项目里实际踩过或者帮别人排查过的误区一notify和notifyAll用错。面试题经常问它俩的区别notify只唤醒一个等待线程而且是 JVM 自己选的你控制不了选谁notifyAll唤醒所有等待线程让它们自己去抢锁。什么时候用哪个简单粗暴的准则是如果不确定就用notifyAll。为什么因为notify有个隐蔽的问题——如果被选中的线程唤醒后发现条件还是不满足它需要等其他线程的条件它只能再次wait但此时其他等待线程可能永远没机会被唤醒了这就是“信号丢失”的变种。我遇到过最典型的生产事故多个消费者线程在wait生产者调用notify每次只唤醒一个消费者结果这个消费者要消费的数据还没准备好它又回去等了其他消费者没被唤醒队列越积越长。换成notifyAll之后问题立刻消失。误区二wait的等待时间参数理解不到位。wait(1000)的意思不是“睡一秒再继续”而是“最多等待一秒超时后自动醒来重新抢锁”。但要注意超时唤醒和notify唤醒在返回之前都会重新竞争锁所以wait(1000)实际返回的时间可能远大于 1000 毫秒。如果你用wait做超时控制一定要在外面再包一层超时判断不能指望wait返回就代表锁到手了long remain 1000; long deadline System.currentTimeMillis() remain; synchronized (lock) { while (!condition remain 0) { lock.wait(remain); remain deadline - System.currentTimeMillis(); } if (!condition) { // 超时了做超时处理 } }误区三把wait/notify用在错误的锁对象上。wait和notify必须作用于同一个对象而且这个对象就是synchronized加锁的对象。如果你synchronized (lockA)却调用lockB.wait()即使不报错逻辑也是错的——因为等待的是lockB上的条件而唤醒的人可能根本不知道lockB的存在。我见过有人在synchronized方法里调用this.wait()这倒是合法的因为锁的就是this但可读性很差。更实用的建议是把锁对象和条件状态封装在一个专门的类里要么用现有的Condition接口要么自己写一个线程安全的“条件守卫”类别把协作逻辑散落在业务代码里。2.3 生产-消费者实战亲手搭一个带缓冲区的协作模型说了这么多理论直接写一个完整的生产者-消费者模型。这是wait/notify最经典的应用场景也是很多并发框架的核心结构。先看用wait/notify实现的最简版本public class SimpleBlockingQueue { private final Object lock new Object(); private final Object[] items; private int head, tail, count; public SimpleBlockingQueue(int capacity) { items new Object[capacity]; } public void put(Object item) throws InterruptedException { synchronized (lock) { while (count items.length) { lock.wait(); // 队列满等待消费者 } items[tail] item; tail (tail 1) % items.length; count; lock.notifyAll(); // 通知消费者可以取了 } } public Object take() throws InterruptedException { synchronized (lock) { while (count 0) { lock.wait(); // 队列空等待生产者 } Object item items[head]; head (head 1) % items.length; count--; lock.notifyAll(); // 通知生产者可以放了 return item; } } }注意几个细节队列满和队列空用了两个不同的等待条件但共用一把锁。这种情况下用notifyAll是最稳妥的因为notify可能把“消费者线程”唤醒去处理“生产者等待的条件”逻辑就乱了。我见过有人在这个场景用notify还写对了的例子——他们能保证等待的线程类型单一但那是非常脆弱的假设依赖线程调度顺序生产环境一上线就现原形。再升级一下。用ReentrantLock配合两个Condition是这个场景的“标准工业答案”因为你可以精确控制唤醒哪一类线程public class ConditionBlockingQueue { private final ReentrantLock lock new ReentrantLock(); private final Condition notFull lock.newCondition(); private final Condition notEmpty lock.newCondition(); private final Object[] items; private int head, tail, count; public ConditionBlockingQueue(int capacity) { items new Object[capacity]; } public void put(Object item) throws InterruptedException { lock.lockInterruptibly(); try { while (count items.length) { notFull.await(); // 等待“不满”条件 } items[tail] item; tail (tail 1) % items.length; count; notEmpty.signal(); // 精准唤醒一个等待“非空”条件的线程 } finally { lock.unlock(); } } public Object take() throws InterruptedException { lock.lockInterruptibly(); try { while (count 0) { notEmpty.await(); // 等待“非空”条件 } Object item items[head]; head (head 1) % items.length; count--; notFull.signal(); // 精准唤醒一个等待“不满”条件的线程 return item; } finally { lock.unlock(); } } }这两个版本的对比很有意思。第一版代码量更少用起来也直观但存在“惊群效应”——notifyAll把所有线程都叫起来但它们最终只有一个能拿到锁干活其他线程又回去睡了。第二版用Condition精确唤醒性能更好可控性也更强。在实际项目中我基本都会选第二版因为它的语义清晰notFull管生产者的等待notEmpty管消费者的等待signal只唤醒对应类型的线程不会出现“唤醒错人”的尴尬。3. join 与 yield一个等待汇合一个主动让路3.1 join 的底层语义隐藏的 wait 调用Thread.join()可能是这四个机制里表面最简单、底层最复杂的。表面上它就是“让当前线程等目标线程执行完”比如Thread worker new Thread(() - { // 模拟耗时计算 try { Thread.sleep(2000); } catch (InterruptedException ignored) {} }); worker.start(); worker.join(); // 主线程会一直等到 worker 执行结束 System.out.println(worker 已经结束);join()还支持带超时的版本join(long millis)表示“最多等这么多毫秒”超时后不管目标线程是否结束当前线程都继续往下走。但底层呢join的 JDK 源码实现其实是调用了wait。核心逻辑是public final synchronized void join(long millis) throws InterruptedException { if (millis 0) { while (isAlive()) { wait(0); // 永久等待直到被 notify } } else { // 带超时的循环等待 } }也就是说当主线程调用worker.join()时它进入的是worker对象的等待集条件就是worker.isAlive()为false。线程终止时JVM 会执行notifyAll唤醒等待在该线程对象上的线程。这也是一个很形象的例子join本质就是wait/notify机制在 JVM 内部的一个应用。理解了wait就理解了join为什么必须在synchronized方法里源码里join方法声明为synchronized也就理解了为什么join会抛出InterruptedException。3.2 join 的实战场景汇总子线程结果join最常见的实用场景是“主线程分派任务给多个子线程然后汇总结果”。比如一个并行计算任务把一个大数组拆成四段四个线程分别算各自那段的 sum主线程最后把四个 sum 加起来int[] data new int[10000]; // 假设 data 已初始化 int segment data.length / 4; int[] results new int[4]; Thread[] workers new Thread[4]; for (int i 0; i 4; i) { final int start i * segment; final int end (i 1) * segment; final int index i; workers[i] new Thread(() - { int sum 0; for (int j start; j end; j) { sum data[j]; } results[index] sum; }); workers[i].start(); } for (Thread t : workers) { t.join(); // 等待所有子线程完成 } int total 0; for (int sum : results) { total sum; }注意这里results数组的写入不需要额外加锁因为每个线程只写自己的下标位并且主线程是在join之后才读取存在先行发生关系join语义保证线程结束后对共享变量的修改对主线程可见。这是join容易被忽略的一个价值——它不仅有控制流的作用还有一个内存可见性的保证。写代码时特别提醒一点join要和start配对。如果线程还没start就调用join会立刻返回因为isAlive()是false。反过来如果在调用join之前线程已经结束了那join也是立刻返回。只有线程运行中join才会真正进入等待。3.3 yield 的语义一个被高估的“谦让”方法Thread.yield()的语义是让出当前线程对 CPU 的占用使当前线程从运行态变成就绪态但 JVM 不保证让出的效果——调度器可能立刻又把 CPU 分配给这个线程。这个“不保证”让yield成了四种机制里最“鸡肋”的一个。yield和sleep的区别经常被问到。sleep会让线程进入阻塞态更准确地说是 TIMED_WAITING 状态期间不参与 CPU 调度时间到了才重新回到就绪态yield只是从运行态短暂切到就绪态本质上还是可运行的只是“皇帝轮流做这次我先不抢”。另外sleep需要抛出或捕获InterruptedException而yield不需要。实际工作中我几乎从不在生产代码里用yield。原因很简单它的行为依赖 JVM 实现和操作系统调度器可预测性太差。但它也不是完全没用——在测试代码里模拟线程调度、压测高并发下的线程上下文切换时偶尔会用yield让竞争更激烈一些。另外在某些自旋等待的场景yield比Thread.sleep(0)稍微“软”一点但现代 JDK 里更推荐用Thread.onSpinWait()Java 9或直接交给LockSupport.parkNanos处理。如果一定要用yield记住这个原则它不是调度工具更不是同步手段。别指望用yield解决共享资源的竞争问题——那是锁和wait的活。3.4 一个踩坑实录join 超时导致的数据不完整最后分享一个真实项目里的教训。有个同事写了一段“限时等待”逻辑子线程做网络请求可能超过 10 秒主线程不想干等就写了worker.join(10000)。10 秒后不管结果如何都继续处理。但处理的时候直接读了子线程设置的共享变量结果经常读到null或者过期的默认值。我当时帮他排查的结论是join(10000)只是“最多等 10 秒”超时后没有任何机制保证子线程的变量写入完成也不保证退出等待时变量更新的可见性。正确做法是如果限时等待后还要用结果就必须在join返回之后、读取变量之前额外判断子线程是否真的完成了比如用一个AtomicBoolean标记或者干脆用Future/FutureTask这些类是专门为“异步取结果”设计的比手动join加共享变量稳妥得多ExecutorService pool Executors.newFixedThreadPool(2); FutureInteger future pool.submit(() - { // 计算结果 return 42; }); try { Integer result future.get(10, TimeUnit.SECONDS); // 拿到结果 } catch (TimeoutException e) { future.cancel(true); // 超时取消 // 做超时兜底 }用Future.get(timeout)之后超时拿不到结果是有明确异常的不会带出一个“脏数据”。这算是 Java 并发演进给开发者的福利——能用标准并发工具就不要手动join拼凑了。4. suspend / resume被废弃的“危险动作”理解它才能警惕它4.1 suspend/resume 为什么危险死锁与数据损坏Thread.suspend()和Thread.resume()是 JDK 早期提供的“挂起/恢复”线程的机制。从 API 设计上看非常直观suspend暂停线程resume让它继续。但这两个方法从 Java 2 起就被标记为Deprecated官方文档直接建议“不要使用”。核心原因有两条第一suspend挂起线程时不会释放它持有的锁。这会造成极其隐蔽的死锁场景。想象一个场景线程 A 持有锁 L执行到一半另一个线程 B 调用了A.suspend()把 A 挂起了。A 不会释放锁 L此时任何想要获取锁 L 的线程包括 B 自己都会被卡死。而最麻烦的是如果 B 在suspend之后又调用了A.resume()但这个resume本身需要获得某个锁而该锁正被一个等待 L 的线程持有那就是连环死锁。这种死锁极难排查因为现场看起来“所有线程都在等”但根源是一个被挂起且锁不放的线程。第二suspend/resume之间存在竞态条件。如果resume在suspend之前被调用那么预期的“恢复”信号就丢失了被挂起的线程永远无法醒来。请注意这和我们前面讲的wait/notify的“信号丢失”问题很像但wait/notify通过while循环和notifyAll可以规避suspend/resume却没有任何内置的规避机制。4.2 替代方案从 LockSupport 到显式状态控制弃用suspend/resume之后Java 社区找到了两条替代路线路线一LockSupport.park()/unpark(thread)。这是 JUC 内部的底层原语AQSAbstractQueuedSynchronizer的所有锁和Condition都建立在它之上。park和unpark比suspend/resume安全得多因为unpark具有“许可”语义——如果unpark先于park调用park会立即返回不会丢信号。示例Thread worker new Thread(() - { System.out.println(worker 开始); LockSupport.park(); // 等待许可 System.out.println(worker 被唤醒); }); worker.start(); Thread.sleep(1000); LockSupport.unpark(worker); // 发放许可这种“许可证”的方式就很好地解决了resume先于suspend导致信号丢失的问题。注意park也可以响应中断但不会抛InterruptedException给调用栈而是默默返回需要自己通过Thread.interrupted()检查状态。路线二用volatile标志位 wait/notify或Condition自己控制状态机。这也是我在业务代码里更推荐的方式因为语义完全掌握在自己手里。比如要实现一个“可暂停的线程”就定义一个volatile boolean pausedpublic class PausableTask implements Runnable { private final Object lock new Object(); private volatile boolean paused false; private volatile boolean running true; Override public void run() { while (running) { synchronized (lock) { while (paused running) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } } // 执行一段工作 doWork(); } } public void pause() { paused true; } public void resume() { synchronized (lock) { paused false; lock.notifyAll(); } } }这套模型核心在于paused是volatile的外部线程可以直接读不会读到过期的暂停状态而wait和notifyAll负责处理真正的挂起和唤醒。这样既避免了suspend的锁不释放问题又因为状态是自己维护的后续要加暂停超时、暂停计数等功能都很方便。我个人看了太多因为历史代码使用suspend导致的线上问题。任何还在跑suspend/resume的老项目都应该尽快重构。你不是在用“简单直观”的 API你是在维护一颗定时炸弹。5. 从理论到实战一把锁和一个协同流程的完整示例按理说到这里四大机制都过了一遍应该可以收尾了。但我想加一章专门把前面提到的知识点串成一个综合案例。因为线程协作很少是单独用某一个机制的真实场景往往是“高档次的组合拳”。假设我们要实现一个简单的“会议协同”系统会议有一个主持人线程有若干个参会者线程。参会者必须先“签到”等待主持人宣布“开始”主持人要等所有参会者都签到后才能宣布开始每个参会者发言结束后要等待主持人说“下一位”主持人则要依次安排发言。这个场景里签到等开始的等待与通知适合用wait/notify主持人统计签到人数等待最后一个参会者适合用join而yield可以用在参会者准备发言时偶尔让出 CPU模拟不时发生的“网络卡顿”。当然真实系统我不会这么设计但作为演示极其合适。先定义参会者class Participant implements Runnable { private final String name; private final Meeting meeting; private final Thread thread; Participant(String name, Meeting meeting) { this.name name; this.meeting meeting; this.thread new Thread(this, name); } Thread getThread() { return thread; } Override public void run() { meeting.signIn(name); // 签到内部有 wait meeting.speak(name); // 发言内部有 wait System.out.println(name 结束会议); } }再定义会议主持人逻辑class Meeting { private final Object lock new Object(); private int signedCount 0; private final int total; private String currentSpeaker null; Meeting(int total) { this.total total; } void signIn(String name) { synchronized (lock) { signedCount; System.out.println(name 签到); // 不是最后一个就等待主持人喊开始 while (signedCount total) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } lock.notifyAll(); // 最后一个签到的人唤醒所有人 } } void speak(String name) { synchronized (lock) { // 只有轮到自己才能发言 while (!name.equals(currentSpeaker)) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } System.out.println(name 正在发言...); Thread.yield(); // 模拟发言过程中的随机停顿 currentSpeaker null; lock.notifyAll(); // 通知主持人我讲完了 } } void start() { synchronized (lock) { while (signedCount total) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } System.out.println(主持人所有人已签到会议开始); currentSpeaker A; lock.notifyAll(); } } void next() { synchronized (lock) { while (currentSpeaker ! null) { try { lock.wait(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } } // 安排下一位 currentSpeaker ...; // 轮流 lock.notifyAll(); } } }这个示例可能有点绕但你能看到最核心的东西所有的协作都围绕两个要素——共享的等待条件signedCount、currentSpeaker和一把锁。wait让不满足条件的人自觉去睡notifyAll让条件可能满足的人全部醒来醒来之后必须再检查一遍条件。一个综合经验写任何协作代码的时候先把“状态”理清楚再想“谁等什么”“谁改什么”。状态一清锁和条件就都清晰了。6. 常见问题速查与排查技巧基于真实 Call Stack 的经验写到最后一部分我整理了自己在项目里遇到、帮同事排查过的线程协作相关高频问题做成一张速查表附带排查思路。症状可能原因排查与修复程序卡死CPU 占用低wait多线程相互等待且无人 notifyjstack 看 WAITING 状态检查 notify 是否漏发、条件是否在 while 外检查程序卡死CPU 占用高自旋等待或频繁yieldjstack 看 RUNNABLE检查是否把yield/自旋当成了等待手段偶发空指针 / 数据不一致join超时后未判断子线程是否完成改用Future.get(timeout)或加完成标记IllegalMonitorStateException在synchronized块外调用wait/notify检查锁对象一致性唤醒后条件不满足继续执行用了if而不是while改为while只有部分线程被唤醒业务不完整用了notify但等待条件有多个改用notifyAll或Condition.signalAll老系统suspend后卡死挂起线程持锁不放立即替换为LockSupport.park/unpark或状态机排查线程问题时我第一个动作永远是抓线程栈。jstack pid输出里WAITING、TIMED_WAITING、BLOCKED、RUNNABLE四种状态的分布本身就是线索。比如大量WAITING在同一个锁对象上说明大家都在等同一个条件如果还有一个RUNNABLE的线程在做无意义的循环那八成是轮询替代了等待通知。另外补一条实战技巧给线程起有意义的名字。你将来一定会感谢自己给每个线程命名。thread-12这种名字在排查时毫无帮助consumer-queue-order这样的名字能让 jstack 的输出瞬间变成业务流程图。排查的时候除了jstackJava 9 自带的jhsdb jstack在某些场景下更给力可以直接看 native 线程如果用了 JUC 的锁还可以用jstack里输出的- parking to wait for 0x...信息配合jcmd可以查看锁的持有情况。但不要过度依赖工具理解协作机制本身才是排障最快的路。7. 一些写在最后的操作体会写到这里该聊的机制基本聊透了。最后分享几个算是个人经验的东西不是什么系统性总结就是实际干活的感受。第一线程协作比线程互斥难调试一个数量级。互斥出问题往往是死锁或争用jstack 一看就清楚协作出问题经常是“偶尔卡一下”“有时候结果不对”这种“漂浮不定”的问题是复现和排查的噩梦。所以写协作代码的时候心态上就要默认“一定会出错”把条件检查、超时兜底、状态恢复全部写到位别指望一次写对。第二能不用wait/notify就别用。这句话听着有点反直觉毕竟本文花了大量篇幅讲它。但 Java 并发工具包提供了太多更可靠的上层封装要“异步拿结果”用Future、CompletableFuture要“等待一段时间直到某个条件”用CountDownLatch、CyclicBarrier、Semaphore、Phaser要“生产者-消费者”上面说的BlockingQueue现成接口就有多种实现。wait/notify的核心价值在于理解底层机制以及在没有现成工具覆盖的少数定制场景兜底。直接拿wait/notify去做复杂业务的往往会绕回“一个 bug 修三天”的老路。第三把状态、条件、唤醒逻辑集中管理。如果某个类内部的状态变化会触发多个线程的等待和唤醒强烈建议把共享状态封装成独立的类或模块暴露方法而不是暴露锁。比如ConditionBlockingQueue这种设计外部调用方只需要put/take根本不需要知道底层用了Condition还是wait/notify。这不仅是封装更是给未来排查问题留了一条清晰的路径。大概就这些了。如果这篇梳理能帮你少踩几个线程协作的坑那比什么都值。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑