昨天面了个三年经验的候选人,问到他sleep()和wait()的区别,背得倒挺溜——"sleep是Thread的方法,wait是Object的方法,sleep不释放锁,wait释放锁",完了。我再追问一句"那面试官问这个问题到底想考什么",他愣了一下。这个场景我见的太多了。说实话,这道题如果只背到"释放不释放锁"这一层,在面试官眼里和三年前刚培训出来的初级没什么区别。今天把这道经典题彻底拆开,从底层原理到面试追问、再到实战中的选型和常见坑,一次讲清楚。
1. 从一个面试现场说起:为什么这道题年年必问
这道题在Java面试里出现的频率,大概和"HashMap原理"一个级别。它考的不是你记没记住答案,而是测试你对多线程核心机制——同步、锁、线程状态流转、线程间通信——的理解深度。
很多候选人回答的时候,习惯先背一条"区别列表",但这恰恰是面试官最头疼的答法。因为列表背下来容易,但一旦换个问法就露馅。比如这样几个追问:
- "sleep(0)有意义吗?"
- "为什么wait()必须在synchronized代码块里调用,而sleep()不用?"
- "wait()和sleep()都被中断会怎样?"
- "产消模型中,为什么用wait/notify而不是sleep轮询?"
这些问题一句"释放不释放锁"是答不上的。所以我在下文里不只是讲区别,更要把这两个方法各自的"设计目的"讲明白——它们是两套完全不同的机制,只是恰好都叫"让线程暂停一下"而已。
先给一句话定调:sleep()是线程的自我暂停,wait()是线程间的协作通信。后面的所有细节,都是对这句话的展开。
2. sleep():不释放锁的"暂停键"
2.1 源码与底层行为
Thread.sleep(long millis)是一个静态本地方法,它让当前正在执行的线程(注意,不是"某个线程对象")进入TIMED_WAITING状态,睡眠指定毫秒数后自动恢复。
这里第一个容易踩的认知误区在于:Thread.sleep()虽然是静态方法,但很多人会写thread.sleep(1000)这样调用。这在语法上不报错,但效果完全等同于Thread.sleep(1000)——因为静态方法不依赖实例,编译器真正执行的是Thread.sleep(1000),和那个thread对象没关系,更不可能让thread这个引用指向的线程去睡觉。
public class SleepDemo { public static void main(String[] args) throws InterruptedException { Runnable task = () -> { synchronized (SleepDemo.class) { System.out.println(Thread.currentThread().getName() + " 进入同步块,准备 sleep"); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); } System.out.println(Thread.currentThread().getName() + " sleep 结束,离开同步块"); } }; Thread t1 = new Thread(task, "线程A"); Thread t2 = new Thread(task, "线程B"); t1.start(); Thread.sleep(100); // 保证 t1 先拿到锁 t2.start(); } }运行结果:线程A先输出"进入同步块并准备sleep",然后3秒内线程B一直在blocked状态等待锁,线程A sleep结束、释放锁之后,线程B才进入。
这就是关键结论:sleep()期间持有监视器锁不释放。哪怕线程B已经就绪,也只能干等。
提示:sleep()不会释放任何锁——不只是synchronized锁,包括
ReentrantLock等显式锁也一样不释放。它只是纯粹地把CPU让出去,但持有资源不放手。
2.2 sleep的场景与设计意图
sleep()在源码注释里写得很明白:它存在的意义是让出CPU时间片给其他线程,而不是用来协调线程间的先后顺序。所以它的典型应用场景一般是:
- 模拟耗时操作:比如测试慢接口、模拟网络延迟、限流测试。
- 控制轮询节奏:比如某个后台线程每隔5秒检查一次配置是否更新。
- 给某个操作"等待"的时间窗口:比如等待外部资源就绪后再次尝试。
但注意,用sleep做线程间顺序协调是最常见的错误用法。很多人刚学多线程时都有过这种代码:
// 错误示范:用 sleep 等待另一个线程执行完 threadA.start(); Thread.sleep(1000); // 猜测线程A大概1秒能跑完,结果它跑了2秒 threadB.start(); // 此时线程B的执行依赖线程A的结果,但已经被破坏这种"猜时间"的做法在复杂环境下必然出问题。机器负载高、GC停顿、调度器切换多了,时间窗口就不可靠。正确的做法应该是用join()、Future、CountDownLatch这类机制,而不是sleep。这也引出一个隐含考点:join()底层其实也是用wait实现的,而不是sleep——它需要在条件满足时被唤醒,而不是死等一个固定的时间。
3. wait():释放锁的"通信枢纽"
3.1 监视器与等待集
Object.wait()是实例方法,它的语义是:让当前线程进入该对象的"等待集"(Wait Set),并释放该对象的监视器锁,直到其他线程调用同一个对象的notify()或notifyAll()将其唤醒。
这里有几个理解重点。
第一,wait()释放的是"这个对象"的锁。如果线程持有多个对象的锁,调用objA.wait()时,只有objA的锁会被释放,其他锁都还继续持有。这是很多人没注意到的细节。
第二,wait()必须和synchronized配合使用。原因后面专门讲,但这里先记住:没有监视器锁的线程调用wait()会立刻抛IllegalMonitorStateException。
第三,调用wait()时,线程从RUNNABLE进入WAITING状态(无参数的wait),或TIMED_WAITING状态(wait(long timeout))。没有参数时只能靠notify唤醒;有时间参数时,超时到点也会自动苏醒并重新竞争锁。
public class WaitDemo { private static final Object lock = new Object(); private static boolean condition = false; public static void main(String[] args) throws InterruptedException { Thread waiter = new Thread(() -> { synchronized (lock) { System.out.println("等待线程进入同步块,条件不满足,调用 wait"); try { while (!condition) { lock.wait(); // 释放锁,等待通知 } System.out.println("等待线程被唤醒,条件满足,继续执行"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "等待线程"); Thread notifier = new Thread(() -> { synchronized (lock) { System.out.println("通知线程进入同步块,修改条件,notifyAll"); condition = true; lock.notifyAll(); // 唤醒等待线程 } }, "通知线程"); waiter.start(); Thread.sleep(100); // 确保 waiter 先进入 wait notifier.start(); } }这里有个极其重要的细节:notifier必须先进入同步块拿到同一个lock的锁,然后调用notifyAll,之后退出同步块释放锁,waiter才能真正从wait()返回并继续执行。也就是说,wait()的唤醒不是"立刻执行",而是:被唤醒 -> 重新参与锁竞争 -> 拿到锁 -> 从wait()处返回。这中间的锁竞争可能让"唤醒"变成"假唤醒后又阻塞",也正因如此,wait的标准写法一定是while(condition)循环而非if(condition)。
3.2 wait/notify的协作模型
wait()设计出来就是为了跨线程通信。生产者和消费者模型是最经典的解释:
- 消费者发现队列为空,调用
queue.wait()——释放锁并睡眠,把CPU让给生产者; - 生产者往队列里放了一个数据,调用
queue.notifyAll()——告诉消费者"有货了"; - 消费者被唤醒,重新拿到锁,继续消费。
public class ProducerConsumer { private final Queue<String> queue = new LinkedList<>(); private final int CAPACITY = 5; public synchronized void produce(String item) throws InterruptedException { while (queue.size() >= CAPACITY) { wait(); // 队列满了,等待消费者消费 } queue.offer(item); System.out.println("生产: " + item + ",当前队列: " + queue.size()); notifyAll(); } public synchronized String consume() throws InterruptedException { while (queue.isEmpty()) { wait(); // 队列空了,等待生产者生产 } String item = queue.poll(); System.out.println("消费: " + item + ",当前队列: " + queue.size()); notifyAll(); return item; } }注意,这个例子中produce()和consume()都是synchronized方法,它们的监视器锁都是this对象,所以wait()和notifyAll()无须显式指定锁对象。如果你用的是synchronized代码块,则必须写成lock.wait()和lock.notifyAll(),且必须与进入代码块时锁的对象保持一致——不一致立刻抛异常。
4. 核心差异对比:能把"锁"这件事讲透才是真懂
4.1 对比表格
先给一个完整的对比表格,后端面试时能在白板上画出这个,已经比大多数人强了。
| 对比维度 | sleep() | wait() |
|---|---|---|
| 归属 | Thread类的静态方法 | Object类的实例方法 |
| 是否释放锁 | 不释放任何锁 | 释放当前对象的监视器锁 |
| 调用前置条件 | 无,任何地方都可调用 | 必须在synchronized代码块/方法内,否则抛IllegalMonitorStateException |
| 唤醒方式 | 时间到了自动苏醒 | 依赖notify/notifyAll唤醒,或wait(timeout)超时自动苏醒 |
| 线程进入状态 | TIMED_WAITING | WAITING(无参wait)或TIMED_WAITING(有参) |
| 本质用途 | 线程自我暂停,让出CPU | 线程间通信,实现等待/通知机制 |
| 对锁竞争的影响 | 持有锁继续阻塞其他线程 | 释放锁,让其他线程有机会进入临界区 |
| 可中断性 | 可被interrupt打断并抛InterruptedException | 可被interrupt打断并抛InterruptedException |
| 是否属于Object类的方法 | 否 | 是 |
关键点:凡是Object类的方法,意味着每个对象都有。任何Java对象都可以作为锁,因此任何对象都可以调用wait/notify,这就是"管程模型"在Java中的体现——每个对象天生自带一个监视器。
4.2 为什么wait()必须在synchronized里而sleep()不用
这个问题是面试官最爱深挖的,需要从语义和底层两个层面理解。
语义层面:wait()的本意是"在条件不满足时让线程等待,并释放锁让别人去改变条件"。如果调用wait()时线程根本不持有锁,那释放锁无从谈起,也无法保证判断条件与等待之间的原子性。设想一下:如果不在同步块里,一个线程先检查队列为空,在它准备调用wait()的间隙,另一个线程往队列里放入了数据并调用了notifyAll——但由于前面的线程还没进入wait状态,这次通知就丢失了,前面的线程则会永远等下去。把判断条件、写入wait、释放锁放进同一个synchronized临界区,才能确保"等之前先检查,检查结果与等待动作不被打断"。
底层层面:JVM内的每个对象都关联一个监视器(Monitor)。wait()的底层操作是把当前线程放入该监视器的等待集合(WaitSet),并调用底层操作系统原语释放监视器。这整个过程只对"已持有该监视器"的线程是合法操作。HotSpot虚拟机中Object.wait()的native实现,第一步就是检查线程是否拥有当前对象的监视器——ObjectSynchronizer::wait会先做这个校验,不通过直接抛出IllegalMonitorStateException。这个异常名的字面意思就是:你调用了wait,但你的线程状态根本不在合法的监视器持有状态。
反过来看sleep()为什么不用在同步块里:sleep()不涉及锁的交互,它只是把线程挂起指定时间。它不需要锁,因为它压根不准备和别的线程协作,也不需要"原子地检查条件+睡眠"。所以Thread这类静态工具方法,在任何上下文都能调用。
这个追问的完整回答应该是:wait()的语义决定了它必须与synchronized配合,确保检查条件到释放锁的过程是原子操作,避免通知丢失;sleep()只是个定时暂停工具,不依赖锁状态,所以不需要放在同步块里。
4.3 谁该被哪个方法"拦住":锁竞争差异的实战含义
sleep()持锁睡眠,对锁竞争的影响是阻碍性的:线程明明不干活,却还占着锁,其他线程只能阻塞等待。这在生产代码里很危险——如果持锁线程sleep 30秒,所有争这把锁的线程全部卡死,可能引发线程池饱和、请求超时堆积,甚至线上事故。
wait()则完全不同:它是配合性的——线程愿意放弃锁,通知其他线程"你可以进来改条件"。这样锁的利用率是最高效的。
举一个我实际遇到过的案例。某次线上服务偶发响应缓慢,排查发现代码里有个分布式锁的刷新线程,在持有锁的同步块里做了Thread.sleep(2000)。表面看只是"隔两秒刷新一次租期",但这2秒内所有需要读取同一把锁保护的资源的请求全被堵住。改成wait/notify的模型后,锁的持有时间缩短到几乎只有状态更新的那几毫秒,响应时间立刻恢复平稳。
这个案例说明一个很重要的开发原则:锁临界区内能不用sleep就不用sleep,如果确实需要等待某个条件变化再去执行,优先考虑wait/notify或Lock的condition机制,而不是盲目sleep。
5. 面试官的连环追问:从这里看出"真懂"和"背过"
5.1 虚假唤醒:为什么必须在while循环里wait
这是wait()使用中最容易翻车的坑,也是面试官很爱问的进阶题。
先看这段代码:
// 错误写法:用 if 而不是 while synchronized (lock) { if (!condition) { lock.wait(); } // 执行依赖于 condition 为 true 的逻辑 }问题在于:**wait()被唤醒并重新拿到锁之后,条件可能已经被别的线程再次改掉了。**比如产消模型中,两个消费者都被唤醒,其中一个消费掉了唯一的数据,另一个拿到锁从wait()返回后,队列已经空了,但它的代码还继续往下执行消费逻辑——轻则空转,重则空指针。
这就叫"虚假唤醒"(spurious wakeup),即使只用notifyAll也依然存在。更严谨地说,即使没有操作系统层面的虚假唤醒,"被唤醒后与其他线程竞争锁、在等待期间条件已经被改变"这一点,本身就足以要求我们必须用while循环重新检查条件。
所以标准写法必须是这样:
synchronized (lock) { while (!condition) { // 循环检查,而不是 if lock.wait(); } // 到这里才真正满足条件 }在《Effective Java》和Java官方文档中都明确建议:wait()必须放在while循环中。这也是开发规范里少有的"官方强制要求"。候选人如果能自己说出这一点,面试官基本可以判定他真的写过多线程代码。
5.2 wait()后的线程是WAITING还是BLOCKED
这个问题经常被用来考察对线程状态机的理解。
调用wait()后,线程进入WAITING状态(无参wait)或TIMED_WAITING状态(wait(timeout))。WAITING的特点是:它不参与锁竞争、不占用CPU,直到被notify或中断才会重新进入锁等待(BLOCKED或RUNNABLE)。
而被notify唤醒之后、还在等待获取锁的那段时间,线程才会进入BLOCKED状态。所以严格来说,wait()之后的状态是WAITING,唤醒后先变成BLOCKED(如果锁还被占着),拿到锁后才RUNNABLE。
sleep()则直接进入TIMED_WAITING,sleep期间同样不占CPU,但锁没释放。这两者的状态流转也是面试常考的变体:sleep和wait(0)的状态不同、Thread.yield()的状态不同——yield还在RUNNABLE,只是让出当前CPU调度机会。
5.3 能拿notify/notifyAll对比做文章吗
sleep()没有"唤醒"概念,时间到自动醒来。面试官一般会顺着notify/notifyAll往下问:"为什么用notifyAll而不是notify?"
这里有个经典结论:notify()是随机唤醒一个线程,notifyAll()是唤醒所有等待线程。在只有一个等待者时两者等价;但当有多个生产者和多个消费者时,只用notify()可能造成信号丢失或线程饥饿。
举个例子:两个消费者都因为队列空而wait了,一个生产者放了一个数据后调用notify(),结果唤醒的还是消费者A,消费者A消费完了队列又空了,消费者B还在永久等待。即使生产者随后再次放数据并notify,如果这次恰好又唤醒A,B可能连续多次"躺枪"。相比之下,notifyAll唤醒全部,让所有线程重新检查条件,安全性高得多。所以无特殊理由,一律推荐notifyAll。
5.4 JDK 5之后的替代品:Condition
有了synchronized/wait/notify之后,JUC包里的ReentrantLock和Condition也值得提。Condition的await()和signal()/signalAll()等价于wait/notify,但功能更强——条件的粒度更细,可以一个锁下面挂多个等待队列。
public class ConditionDemo { private final ReentrantLock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); private final Condition notEmpty = lock.newCondition(); private final Queue<String> queue = new LinkedList<>(); private final int capacity = 5; public void produce(String item) throws InterruptedException { lock.lock(); try { while (queue.size() >= capacity) { notFull.await(); // 生产者在"队列满"条件上等待 } queue.offer(item); notEmpty.signalAll(); // 唤醒"队列非空"条件上的等待者 } finally { lock.unlock(); } } public String consume() throws InterruptedException { lock.lock(); try { while (queue.isEmpty()) { notEmpty.await(); } String item = queue.poll(); notFull.signalAll(); return item; } finally { lock.unlock(); } } }Condition能将"队列满"和"队列空"两种等待分离开,避免synchronized模型中所有线程都阻塞在同一个等待集上造成的无效唤醒。面试时提到这一点,直接拉开与其他候选人的差距。
6. 实战项目中的选型与排坑经验
6.1 什么时候用sleep,什么时候用wait
很多初学者写完"产消模型"之后,顺手就把sleep当延时工具塞进去了。下面这个表是我在实际帮助团队review代码时经常提到的最小选型标准:
| 场景 | 推荐方案 | 不推荐的方案 |
|---|---|---|
| 周期性执行(定时拉取、心跳上报、批量轮询) | ScheduledExecutorService,或代码内用sleep控制节奏 | wait/notify |
| 等待某个条件满足后再继续(消息到达、任务完成、队列非空) | wait/notify、Condition、CompletableFuture、BlockingQueue | sleep轮询 |
| 模拟延迟、限速、测试延时场景 | sleep | wait |
| 需要等待另一线程执行完毕 | join()、CountDownLatch、Future.get() | sleep猜时间 |
| 分布式场景跨进程等待 | 消息队列、ZooKeeper、Redis的阻塞读 | 本地wait/notify(不同JVM不共享监视器) |
这里补充一点:wait/notify的适用范围仅限于同一个JVM内的线程间通信。跨进程场景根本不会用到它,这也是为什么生产上用得更多的是BlockingQueue、CompletableFuture——它们底层确实用了锁和条件等待,但对外暴露的API友好得多。
6.2 我踩过的三个坑
坑一:try/finally中丢失notify导致线程池永久阻塞。某个内部任务调度系统,用wait实现"任务完成后唤醒下一个任务",但有一次某个任务抛异常后直接return,跳过了finally中的notify,结果后续线程全部卡在wait上,整个任务链挂了半个小时。修复方案很简单:notify/signal放在finally块或者用try-with-resources闭包管理。这里建议所有用到wait的生产代码,唤醒动作一定要确保执行。
坑二:用sleep在锁内等外部接口返回。有同事在synchronized块里调外部HTTP接口,还加了个Thread.sleep(500)防止频率太高,结果接口响应慢的时候,这个锁被hold了好几分钟,所有请求线程全部堆积。后面改为在锁外等待,把HTTP调用从同步块里挪出来,只把需要保护的共享数据结构更新放在临界区。这个教训是:锁的粒度越小越好,sleep这种纯粹浪费时间的操作绝不进临界区。
坑三:对lock.wait(timeout)的超时唤醒抱有错误期待。wait(3000)确实会在3秒之后自动醒来,但醒来后它还会继续参与锁竞争,而不是"3秒后立刻执行完wait()返回"。如果锁一直被别人占着,它可能3秒后依然处于BLOCKED状态,还要再等锁释放。这一点在设置超时参数时必须清楚,避免以为timeout是硬性截止时间。
6.3 如何自查:一个简单的"是否真的懂"清单
问自己几个问题,能答上来基本算过关:
- 能不能说出wait()必须在synchronized里的两个层面原因?
- 知不知道
wait(1000)和sleep(1000)在锁竞争下的行为差异? - 写不写得出
while(condition)而不是if(condition)? - 知不知道notify()和notifyAll()在"多消费者单生产者"下哪个安全?
- 能不能说出WAITING、BLOCKED、TIMED_WAITING之间的状态流转关系?
- 知不知道在
ReentrantLock中与wait/notify等价的是哪两个方法?
7. 写在最后:一道题背后的一整套体系
回看这道题,它看起来是"两个方法的区别",实际考察的是整个Java并发体系的地基:锁、同步、线程状态、线程间通信、锁竞争、JMM内存可见性,一个都不能少。把这些串起来之后,你在面试中不仅能把这道题答漂亮,还能顺手结合Condition、BlockingQueue、CompletableFuture等现代工具讲出自己的实战理解,让面试官真正觉得你是一个"写过并发代码"的工程师,而不是背了一晚上八股文的求职者。
最后再分享一条我总结的经验:以上这些面试题,很多人是看的、背的、不是写的。我强烈建议你花一个下午,自己手写一遍"生产者-消费者——分别用synchronized/wait/notify版本、ReentrantLock/Condition版本、BlockingQueue版本"实现一遍,然后把三个版本放一起对比。写完之后你会发现,sleep和wait的区别你会在任何面试场景下都能张口就来,而且顺着这套代码,你还能自然展开到锁升级、公平锁、队列有界性、线程池拒绝策略这些更深的面试话题。这才是这一道题最值钱的部分。