拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Java并发避坑:wait/notify/sleep与线程饥饿的根源与解法

Java并发避坑:wait/notify/sleep与线程饥饿的根源与解法

写 Java 并发代码这么多年,wait、notify、sleep这组基础 API 依然是面试和线上事故里绕不过去的坎。之前一个小服务莫名其妙“卡死”,jstack一抓全是WAITING状态的线程,定位到最后就是wait/notify的条件判断写错了,导致通知丢失,一批线程饿死在队列里。所以这次想把这几个方法和“线程饥饿”放一起,系统地捋一遍,既讲清语义,也讲清实际项目里怎么避免踩坑。

为啥要把wait、notify、sleep和“线程饥饿”放一块儿聊?因为线程饥饿最常见的触发路径,就是你用错了等待唤醒机制,或者低估了锁的竞争公平性。sleep抱着锁不放,wait释放锁等着被叫醒,notify只会随机挑一个线程,这些细节单独看都不难,但组合到多线程协作场景里,稍不注意就会引发饥饿。这篇文章不绕弯子,直接用代码和排查经验讲清楚:三者的区别、唤醒机制的隐患、饥饿怎么产生、以及怎么用公平队列和超时机制把它摁住。

1. wait 和 sleep:一个释放锁,一个抱着锁睡觉

1.1 两个方法最容易被误解的三个点

Thread.sleep(long millis)和Object.wait()在外观上很像:都能让当前线程暂停执行,都声明抛出InterruptedException。很多人因此把它们当成“不同姿势的延时”,这是最大的误解。

第一,它们所在的“语境”完全不同。sleep是Thread类的静态方法,调用它不需要获取任何锁,随时可以睡多久睡多久。而wait是Object的实例方法,而且必须得在synchronized代码块或方法里调用,否则立刻抛IllegalMonitorStateException。原因是wait的语义是“释放当前线程持有的这个对象的监视器锁,并进入等待队列”,如果你手上根本没有这个锁,那就无从释放。

第二,睡眠期间对锁的态度完全不同。sleep不会释放已经持有的锁,哪怕是当前线程在synchronized块内调用Thread.sleep(5000),这 5 秒里其他线程想进这个同步块依然会被堵住。wait则会真正释放锁,让其他线程有机会进入临界区。这也是为什么生产者消费者模型里,消费者等不到数据时要用wait而不是sleep:如果用sleep死等,锁被自己占着,生产者根本无法生产。

第三,唤醒机制不同。sleep到时间自己醒来,不依赖其他线程;wait必须由另一个线程调用notify或notifyAll唤醒,或者使用带超时的wait(long timeout)自动苏醒。也就是说,wait的结束条件是不确定的,你得靠条件变量配合完成协作,这也是最容易出问题的地方。

1.2 用同一把锁对比 wait 和 sleep 的行为

下面这段代码可以直观看到sleep不释放锁:

public class SleepVsWaitDemo { private static final Object LOCK = new Object(); public static void main(String[] args) throws InterruptedException { // 线程 A:持锁后 sleep 3 秒 Thread a = new Thread(() -> { synchronized (LOCK) { System.out.println("A 进入临界区,开始 sleep 3s"); try { Thread.sleep(3000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } System.out.println("A sleep 结束,释放锁"); } }, "A"); // 线程 B:尝试获取同一把锁 Thread b = new Thread(() -> { System.out.println("B 尝试获取锁..."); synchronized (LOCK) { System.out.println("B 拿到了锁"); } }, "B"); a.start(); Thread.sleep(100); b.start(); } }

运行结果应该是:A 先输出进入临界区,B 输出尝试获取锁,但“B 拿到了锁”要等 3 秒之后才出现。因为 A 在sleep期间锁还在自己手里。

如果把Thread.sleep(3000)换成LOCK.wait(3000),因为wait会释放锁,B 会立刻进入临界区。注意wait之后线程从WAITING恢复,通常要重新参与锁竞争,并且要在同步块内重新检查条件。

1.3 为什么 wait 必须放在 synchronized 里

wait的约束不是设计者故意刁难,而是为了保证“条件检查”和“条件等待”的原子性。生产者和消费者协作时,生产者修改状态、消费者读取状态,如果中间没有同步保护,消费者可能在生产者修改完成之前就判断“没货”然后开始等,这一等就可能永远等不到唤醒。

// 错误示范:没进同步块就 wait Object lock = new Object(); try { lock.wait(); // IllegalMonitorStateException } catch (InterruptedException e) { Thread.currentThread().interrupt(); }

wait/notify机制的经典范式是:在同步块里先检查条件,不满足就wait;被唤醒后立刻回到同步块继续执行,再次检查条件。整个过程都在锁保护之下,才不会有竞争条件。这个约束也解释了为什么wait通常和synchronized深度绑定,换到Lock体系后,对应的则是Condition.await()和Condition.signal()。

提示:wait()被唤醒后并不会直接越过synchronized块继续跑,而是要重新竞争锁。拿到锁之后,从上次wait的位置继续执行。这里环境已经变了,所以必须重新检查条件。

2. notify 与 notifyAll:唤醒的到底是谁,以及通知丢失

2.1 notify 只唤醒一个幸运儿,notifyAll 唤醒全部

notify()从该对象等待队列里随机挑一个线程唤醒,notifyAll()唤醒全部。随机这个词在HotSpot里其实没有人们想象中那么随机,具体选哪个线程由 JVM 实现决定,开发者无法控制。这带来一个很现实的后果:如果等待队列里有多个线程,而你只notify一个,你没法保证被唤醒的就是“最该被唤醒”的那个。

比如一个队列同时被多个消费者线程wait,生产者只调用了notify,那被唤醒的消费者可能处理能力较弱,或者它醒来后依然发现队列空,再次wait。另一个本来能立刻消费的线程反而一直等着。这种随机性在多消费者场景下会放大不公平,甚至直接演变成饥饿。

所以在生产代码里,我默认使用notifyAll。除非你能明确证明等待队列永远只有一个线程,否则notify带来的唤醒不确定性远大于那点性能收益。notifyAll的代价是唤醒了多个线程,但只有少数能抢到锁、满足条件,其余的会重新wait,这算“用一点无效竞争换正确性”,对大多数业务来说完全值得。

2.2 通知丢失:wait 之前没检查条件,等睡到天荒地老

通知丢失是最隐蔽的坑。考虑这个错误写法:

if (queue.isEmpty()) { // 错误:应该用 while lock.wait(); } // 消费 queue

线程醒来后不会重新检查queue.isEmpty(),如果另一个消费者已经把队列消费光了,这个线程会直接读到空队列出错。

但通知丢失更典型的场景是这样的:线程先判断条件不满足,然后准备调用wait;刚好这个时刻生产者线程发布了通知“条件满足啦”。如果中间有同步块保护,通知不可能丢。一旦你脱离了synchronized的保护,或者条件判断和wait不是同一把锁,通知就有可能在“判断之后、wait 之前”发出,而线程还没开始等,这个通知就白白丢失了,线程随后进入wait,再也没人叫醒它。

这也是wait/notify必须配合synchronized使用的根本原因。同步块保证了“检查条件”和“进入等待”是一个原子操作。

2.3 虚假唤醒:被叫醒不一定真有货

教科书里反复要求用while而不是if来判断等待条件,主要有两个原因:一是被唤醒后条件不一定成立,二是wait存在虚假唤醒的可能。所谓虚假唤醒,是指线程没有被notify、也没有被中断,却自己从wait中返回了。这种情况在现实 JVM 里极少出现,但它是一种被规范允许的行为,操作系统层面也偶有发生。

正确写法是这样:

synchronized (lock) { while (queue.isEmpty()) { lock.wait(); } // 处理队列 }

while意味着无论什么原因醒过来,都要重新确认条件。这个习惯必须像肌肉记忆一样刻进代码里。不要因为觉得“我 notify 的时候条件肯定成立”就用if,因为你唤醒的是“当时”的条件成立,等被唤醒线程真正拿到锁时,条件可能早被别的线程改掉了。

3. 线程饥饿是怎么发生的:活着,但一直在等

3.1 饥饿的典型场景:低优先级与高竞争

线程饥饿(starvation)指的是线程一直无法获得它所需要的资源,导致任务无法推进,但线程本身并没有永久阻塞。它和死锁一样会导致程序“卡住”,但本质不同:死锁是互相占着对方需要的资源,形成一个环;饥饿则是资源被别的线程反复抢占,某一个线程永远排不上号。

最常见的饥饿场景有三个。第一个是优先级设置:给某些线程设置了很高的优先级,调度器总是优先执行它,低优先级线程就可能长期得不到 CPU。Java 里Thread.setPriority在不同操作系统上的表现差异很大,依赖优先级做调度并不可靠,生产上我也不建议用优先级来控制业务。第二个是锁竞争不公平:多个线程争抢同一把锁,如果锁的实现总是偏向某一个线程,或者后来者不断插队,老线程就一直等待。第三个是持锁时间过长:一个线程在同步块里又做计算又做 IO,其他线程即便抢到锁的概率也不小,但每次发现锁被占,给线程带来的等待时间被不断拉长,表现上就是某些线程任务处理明显越来越慢。

3.2 synchronized 为什么不公平

synchronized默认是不公平锁。所谓不公平,是指当锁被释放时,新来的线程可以和已经在等待的线程一起竞争,甚至新线程更容易抢到锁。这种设计有一点性能上的考量:刚释放锁的线程还在热高速缓存里,让它立刻重新拿到锁可以减少上下文切换。但对等待很久的线程来说,这就非常不友好。在高并发下,一个热点同步块不断被新线程闯入,等待队列里的老线程可能一直被插队,这就是典型的饥饿。

ReentrantLock提供了公平模式,构造时传入true即可:

Lock fairLock = new ReentrantLock(true);

公平锁会让等待时间最长的线程优先获得锁,从根源上避免了“新线程插队”导致的饥饿。但公平是有代价的:线程要按顺序排队,系统的整体吞吐量通常低于非公平锁。所以并不是所有场景都该用公平锁,在线程饥饿已经实际影响到业务时,优先用它兜底。

3.3 饥饿和死锁、活锁的区别

排查“卡死”问题时,我通常先分清是哪种状态:

问题类型线程状态典型场景是否能自动恢复
死锁BLOCKED线程互相持锁等待对方释放不能
活锁RUNNABLE线程反复互相谦让,任务无法推进可能没有进展
饥饿RUNNABLE / WAITING低优先级线程或等待线程长期拿不到资源理论上可能,现实很难

死锁里所有线程都“干不了活”,jstack 能看到循环等待。活锁是线程没阻塞,但一直在执行无用动作,比如两个事务互相看到对方让路,结果两人反复让路谁也没前进。饥饿则是某些线程始终拿不到锁,其他线程却运行正常,所以服务通常表现为“部分请求超时慢”,不像死锁那样彻底卡死。

4. 实战:从 wait/notify 到不会饥饿的公平队列

4.1 用 wait/notify 实现生产者消费者时的三个坑

先用经典方式写一个队列:

public class SimpleMessageQueue { private final Object lock = new Object(); private final java.util.LinkedList<Integer> items = new java.util.LinkedList<>(); private final int capacity; public SimpleMessageQueue(int capacity) { this.capacity = capacity; } public void put(int value) throws InterruptedException { synchronized (lock) { while (items.size() >= capacity) { lock.wait(); } items.addLast(value); lock.notifyAll(); } } public int take() throws InterruptedException { synchronized (lock) { while (items.isEmpty()) { lock.wait(); } int value = items.removeFirst(); lock.notifyAll(); return value; } } }

这版是“标准答案”,但生产环境依然有几个隐坑。

第一,所有线程使用同一个等待队列。如果消费者有很多种,比如处理日志的消费者和发送邮件的消费者都wait在同一个对象上,生产者notifyAll会把所有人叫醒,大部分线程醒来会发现条件不满足,继续睡。这种无效唤醒消耗次要,真正危险的是:一个条件变化会唤醒所有无关线程,无法做到精确通知。

第二,notifyAll之后,所有被唤醒线程都要竞争同一把锁,抢不到的线程再次wait。大量消费者线程在一轮唤醒里反复抢锁,抢不到又继续等,高峰期会产生剧烈的“惊群”效应。基于wait/notify的这个模型很难做到精细化控制。

第三,如果等待者数量大于可消费任务数量,某些消费者可能永远抢不到任务。虽然notifyAll理论上人人有机会,但在极端竞争下,线程调度不公平会让同一个消费者连续抢到任务,另一个消费者持续wait,饿得越来越久。

4.2 用 Lock + Condition 做公平排队,从机制上消除饥饿

java.util.concurrent.locks.Condition就是为了解决“精确通知”和“多条件队列”的问题。它允许你把等待队列拆成多个,每一类条件一个队列:

import java.util.LinkedList; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; public class FairMessageQueue { private final Lock lock = new ReentrantLock(true); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); private final LinkedList<Integer> items = new LinkedList<>(); private final int capacity; public FairMessageQueue(int capacity) { this.capacity = capacity; } public void put(int value) throws InterruptedException { lock.lock(); try { while (items.size() >= capacity) { notFull.await(); } items.addLast(value); notEmpty.signalAll(); } finally { lock.unlock(); } } public int take() throws InterruptedException { lock.lock(); try { while (items.isEmpty()) { notEmpty.await(); } int value = items.removeFirst(); notFull.signalAll(); return value; } finally { lock.unlock(); } } }

这段代码有两个关键改进。

一是ReentrantLock(true)启用了公平模式,等待最久的线程优先获得锁,从锁竞争的层面切断了插队式饥饿。二是notFull和notEmpty是两个独立的条件队列,生产者只在队列满时等待,消费者只在队列空时等待。生产者释放队列空间后只需要唤醒notFull上的生产者,消费者生产出数据后只需要唤醒notEmpty上的消费者,不再把无关线程全部震醒。

另外lock.lock()和lock.unlock()之间一定要用try/finally,否则等待过程中抛异常会导致锁永远不释放。这一点和synchronized的自动释放很不一样,切换 API 时很容易忘。

4.3 超时等待与线程池饥饿:饥饿不只是锁的锅

除了加公平锁,给await加超时也是避免饥饿的实用手段。带超时的等待保证线程不会无限期等下去:

// Condition 版 long nanos = TimeUnit.SECONDS.toNanos(5); boolean signaled = notEmpty.awaitNanos(nanos) > 0; // Object.wait 版 lock.wait(5000);

超时后线程会醒来,自己检查条件是否满足。如果条件仍不满足,可以根据业务决定重试、放弃或上报告警。这在高负载下尤为重要:即使某个线程因为极端情况长时间抢不到任务或锁,也不会永久挂在WAITING状态里,至少有机会执行超时后的恢复逻辑。

线程池也会产生饥饿,而且这种饥饿更容易被忽视。考虑一个典型的“单线程池里提交子任务”的例子:

ExecutorService pool = Executors.newSingleThreadExecutor(); pool.submit(() -> { // 主任务占用了唯一的线程,然后提交子任务给自己 pool.submit(() -> System.out.println("子任务永远不会执行")); try { Thread.sleep(Long.MAX_VALUE); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } });

主任务持有了线程池里唯一的线程,并且进入sleep不释放线程;子任务被提交到同一个单线程池,排在队列里永远等不到执行。这是一种“线程饥饿”,虽然这里锁的竞争被线程池的提交队列替代了,但本质是一样的:资源被占住不放,需要资源的人排队无止境。

排查线程池饥饿时,你需要的不是锁的公平性,而是合理的线程池参数。比如把线程池核心线程数调大,或者用有界队列加拒绝策略,避免某个长任务拖死整个池子。更重要的是,不要在提交给线程池的任务内部继续阻塞等待另一个由同一线程池执行的任务,这种“嵌套提交”非常容易把池子饿死。

5. 现场排查与常见问题速查

5.1 jstack 一眼定位等待与阻塞状态

线上服务卡住时,第一步不是猜,而是抓现场。用jps找到 Java 进程 PID,然后执行:

jstack <pid> > thread_dump.txt

打开 dump 文件,重点看线程状态:

  • WAITING (parking)或WAITING (on object monitor):线程在等待被唤醒,可能是调用了wait()、await()或LockSupport.park()。
  • TIMED_WAITING (sleeping):线程正在sleep,或者带超时的等待。
  • BLOCKED (on object monitor):线程在竞争synchronized锁时被阻塞。

如果发现大量业务线程都停在WAITING,但服务本身没有宕机,说明要么通知丢失,要么条件永不满足。这时候去查是谁应该负责唤醒,通常能在代码里找到“改了一个条件,却忘了signal/notify”的低级问题。

如果发现某个锁对象上堆了成百上千个BLOCKED线程,而持锁线程却一直在执行耗时操作,那大概率是持锁时间过长或锁竞争不公平引起的饥饿。这时可以考虑缩小同步块、改用ReentrantLock公平模式,或者用读写锁分离读写路径。

5.2 常见问题速查表

下面这表是这几年踩坑总结出来的,适合贴在工位上随时翻:

现象可能原因解决思路
IllegalMonitorStateException未持有锁就调用wait/notify确保在synchronized同步块内调用
线程一直WAITING,没人唤醒通知丢失或条件判断错误用while检查条件;检查是否所有能改变条件的地方都调用了notifyAll
唤醒后处理空队列条件判断用了if改成while
大量线程惊群多个条件共用同一个等待队列使用Condition拆分条件队列
部分线程长期抢不到锁非公平锁竞争激烈使用ReentrantLock(true)公平模式
单线程池提交嵌套任务线程池被占满,任务排队避免嵌套提交;调大池子;使用有界队列
线程没事但 CPU 占用异常高活锁或忙等待检查是否有while(true)空转,考虑用wait/notify或LockSupport.park替代自旋

5.3 从设计上规避饥饿的三条原则

第一,能不手动用wait就尽量不用。BlockingQueue、CompletableFuture、CountDownLatch、Semaphore这些高层并发工具已经封装好了等待唤醒的细节,比手写wait/notify稳妥得多。手写这组 API 的场景,通常只剩自定义同步协议或面试。

第二,把持锁时间压到足够短。锁竞争激烈和饥饿往往是一起出现的,你可以在同步块里只做状态变更和轻量操作,耗时逻辑全部挪到锁外。锁的使用寿命缩短,其他线程等待时间就缩短,饥饿的概率也会大幅下降。

第三,在任何等待逻辑里都要有“兜底”。要么用wait(timeout)、awaitNanos设置超时,要么配合监控告警在“等待线程数量异常增长”时发出信号。没有超时的等待等于把命运交给了其他线程,一旦对方写错,你这边就是永久卡死。

结尾

个人经验里,wait/notify/sleep和线程饥饿总是绑定出现,因为你很难单独避开某一个。最后再分享一个排查心得:遇到奇怪的“假死”问题,可以先抓两次jstack,间隔 5 秒,比较等待线程是否有变化。如果两次完全一样,说明等待链路是真的一动不动,基本可以确认是通知丢失或锁长时间占有的饥饿问题;如果线程在换批,但服务整体不可用,可能就是锁竞争导致吞吐被拉爆。写并发代码不追求花哨,把等待唤醒的细节处理对了,线上能少熬好几个晚上。

返回列表