几周前在帮团队做 Code Review 时,看到一个线程池任务里用 Thread.sleep(3000) 代替等待条件成立的写法——任务里明明在轮询一个共享标记位,却让线程白白睡死三秒,不仅响应延迟拉满,CPU 也被空转的检查逻辑折腾得够呛。我当时就问了一句:“这里怎么不用 wait/notify?”得到的回答是“怕 wait 把锁释放了出问题”。这个回答让我意识到,很多写过几年 Java 的老手,对 Thread.sleep() 和 Object.wait() 的理解也停留在“一个会释放锁、一个不会”的表面,真到了并发协作的复杂场景里,要么不敢用 wait,要么用 sleep 硬顶。这篇文章就把这对经典组合彻底掰开揉碎,从锁、状态、唤醒、中断这些底层机制讲到实际工程里的选型,看完你不仅能应付面试,更重要的是能在生产代码里做出正确的选择。
1. 核心区别一:方法归属与调用前提
1.1 所属类与静态/实例方法的本质差异
Thread.sleep() 是 java.lang.Thread 类的静态方法,调用时直接 Thread.sleep(1000) 就行,它操作的是“当前正在执行的线程”。这是一个很关键的语义:你写 t1.sleep(1000),实际睡着的也不是 t1,而是当前线程。静态方法本质上和具体线程实例无关。
Object.wait() 是 java.lang.Object 的实例方法,调用时必须是某个具体的对象,比如 lock.wait()。它操作的是“当前线程”和“这个对象的内置锁(monitor)”之间的关系。因为所有类都继承自 Object,所以理论上任何对象都能当锁、都能调 wait。
一个是静态工具方法,一个是需要依托对象的实例方法——这决定了它们的使用场景分工:sleep 就是单纯让线程暂停,不掺和任何协作逻辑;wait 天生就是为“线程间通过某个共享对象进行协调”设计的。
1.2 调用前置条件:wait 的强制性要求
Object.wait() 有一个硬性要求:调用线程必须持有该对象的监视器锁(monitor lock)。也就是说,wait() 必须在 synchronized 修饰的代码块或方法内部调用,否则运行时直接抛 java.lang.IllegalMonitorStateException。
java复制// 错误示范:没有持有锁就调用 wait
Object lock = new Object();
public void bad() {
try {
lock.wait(); // 抛 IllegalMonitorStateException
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
为什么 JVM 要强制这个约束?因为 wait() 的语义是“让出 CPU 并释放锁,进入等待队列”,如果线程根本没持有锁,它拿什么释放?这个设计不是刁难开发者,而是从机制上杜绝“无锁瞎等”的混乱状态。而 Thread.sleep() 没有任何前置条件,任何地方都能调用,它根本不关心锁的状态。
注意:
wait()还有几个重载版本——wait()、wait(long timeout)、wait(long timeout, int nanos)。wait(0)代表无限期等待,直到被唤醒。这一点和sleep(0)的意义完全不同,后面详细说。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心区别二:锁的持有与释放行为
2.1 sleep 不释放锁:一个最容易踩坑的行为
Thread.sleep() 在执行期间不会释放任何已经持有的锁。下面这段代码用生活化的场景比喻:你把唯一一把洗手间钥匙(锁)带进去了,然后在里面睡了三小时,门外所有人都得等着。
java复制public class SleepHoldsLock {
private static final Object LOCK = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("t1 拿到锁,开始 sleep 5秒");
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("t1 sleep 结束,释放锁");
}
});
Thread t2 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("t2 拿到锁");
}
});
t1.start();
// 确保 t1 先执行并拿到锁
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
t2.start();
}
}
运行这段代码,t2 的 "t2 拿到锁" 必须等 t1 睡满 5 秒后才会打印。这说明 sleep 期间锁一直被占着。这点在生产环境非常危险:如果你在持锁状态下调用 sleep,其他等待锁的线程全部阻塞,轻则响应变慢,重则连锁超时、线程池队列堆满。持锁休眠几乎等同于制造一次小范围死锁。
2.2 wait 释放锁:线程协作的核心机制
Object.wait() 执行后,当前线程会立即释放该对象的监视器锁,然后进入该对象的等待集合(wait set)。这是 wait/notify 协作机制存在的前提——如果 wait 不释放锁,消费者线程永远没机会拿到锁去处理生产者生产的数据,生产者也永远没机会继续生产,整个系统就死锁了。
java复制public class WaitReleasesLock {
private static final Object LOCK = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("t1 拿到锁,进入 wait,释放锁");
try {
LOCK.wait(2000); // 带超时,2秒后自动醒来
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("t1 从 wait 返回,重新持有锁");
}
});
Thread t2 = new Thread(() -> {
synchronized (LOCK) {
System.out.println("t2 拿到锁");
}
});
t1.start();
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
t2.start();
}
}
注意这里的时间线:t1 进入 wait(2000) 后,锁立刻释放,t2 不用等两秒就能拿到锁并打印 "t2 拿到锁"。而 sleep 版本里 t2 必须等待整个休眠周期结束。
2.3 一个容易混淆的细节:wait 返回后需要重新竞争锁
wait 被唤醒后,线程并不会立刻继续执行。它必须先重新竞争到对象的监视器锁,才能从 wait() 调用处返回继续往下走。换句话说,唤醒只是一个“通知”,真正执行还得等锁到手。这种“重新拿锁”的逻辑导致一个经典问题——虚假唤醒(spurious wakeup),以及条件判断必须使用 while 而不是 if。这一块放到第 4 章详细展开。
3. 核心区别三:线程状态迁移与唤醒机制
3.1 状态迁移路径:TIMED_WAITING 和 WAITING
从 java.lang.Thread.State 枚举来看,两个方法有交集也有区别:
Thread.sleep(long millis):无论传多少毫秒,只要参数大于 0,线程进入TIMED_WAITING状态(有时间限制的等待)。Object.wait():无参wait()进入WAITING状态(无限期等待);wait(long timeout)进入TIMED_WAITING状态。wait(0)比较特殊:超时时间为 0 代表无限等待,线程进入的是WAITING,而不是TIMED_WAITING。
关于 TIMED_WAITING 和 WAITING 的区别,可以理解为“定了闹钟的等待”和“不确定等多久的等待”。sleep(1) 就算只睡 1 毫秒,也是 TIMED_WAITING;wait() 不传参数就可能永久挂起。这直接影响故障排查:当你用 jstack 看线程栈时,如果看到大量线程卡在 WAITING 状态且没有任何超时机制,往往意味着生产代码里用了无参 wait 且对应的 notify 可能永远不来——这就是经典的线程挂死隐患。
3.2 唤醒方式的本质差异
Thread.sleep() 的唤醒方式是时间驱动:时间一到,线程自动回到可运行状态(RUNNABLE),不需要其他线程干预。如果你想提前叫醒正在 sleep 的线程,唯一的手段是调用该线程的 interrupt(),但这会抛出 InterruptedException,而不是正常提前返回。这点常被误解,很多人以为 interrupt 能“打断睡眠并继续执行”,实际上它是打断后让你处理中断逻辑。
Object.wait() 的唤醒方式是事件驱动 + 时间驱动的组合:
- 其他线程调用同一个对象的
notify():随机唤醒一个在该对象等待集合中的线程。 - 其他线程调用同一个对象的
notifyAll():唤醒该对象等待集合中的所有线程。 - 超时时间到达:自动唤醒。
- 线程被
interrupt():抛出InterruptedException,唤醒线程。
这里有一个极其重要的细节:notify() 和 notifyAll() 都必须在持有同一个对象的监视器锁时才能调用,否则同样抛 IllegalMonitorStateException。而且,被唤醒的线程并不会立即执行——它要先进入锁的竞争队列,等 notify 调用线程释放锁后才有机会真正拿到锁。很多初学者以为 notify 一调用,消费者立刻恢复执行,这是错的。
java复制public class NotifyVsNotifyAll {
private static final Object LOCK = new Object();
private static boolean ready = false;
public static void main(String[] args) throws InterruptedException {
Runnable waiter = () -> {
synchronized (LOCK) {
while (!ready) {
try {
LOCK.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
System.out.println(Thread.currentThread().getName() + " 被唤醒");
}
};
for (int i = 0; i < 3; i++) {
new Thread(waiter, "waiter-" + i).start();
}
Thread.sleep(500);
synchronized (LOCK) {
ready = true;
LOCK.notifyAll(); // 切换成 notify() 试试,哪个线程会打印?
}
}
}
4. 中断处理与安全调用细节
4.1 InterruptedException 的两条处理路径
Thread.sleep() 和 Object.wait() 都是阻塞方法,都声明抛出 InterruptedException。这个异常背后的机制是:当线程处于 WAITING/TIMED_WAITING 状态时,另一个线程调用该线程的 interrupt(),JVM 会唤醒该线程并抛出 InterruptedException,同时清除中断标志位。
注意“清除中断标志位”这六个字。这意味着你在 catch 块里捕获异常后,线程的中断状态已经被重置为 false。如果上层调用方依赖中断状态来做取消判断,你必须手动重新设置中断标志:
java复制try {
Thread.sleep(1000);
} catch (InterruptedException e) {
// 正确做法:重新设置中断标志,让上层逻辑感知中断
Thread.currentThread().interrupt();
// 或者:如果你能处理中断,就处理完后主动退出
// 但绝不要吞掉这个异常
return;
}
这个习惯在生产代码里经常被忽略。很多人图省事直接 catch (InterruptedException e) {} 把异常吞了,这会导致线程池关闭时任务不响应取消信号,继续执行,造成资源浪费甚至数据错乱。
4.2 虚假唤醒与 while 条件循环
Java 官方文档和 JVM 规范都明确提到,wait 可能发生虚假唤醒——线程在没有任何 notify、没有超时、没有被中断的情况下,突然自己醒来。这是底层操作系统和 JVM 实现允许出现的行为,目的是为了在某些平台下保证实现的简单性和正确性。
应对虚假唤醒的标准写法是把条件判断放在循环里:
java复制synchronized (lock) {
while (!conditionMet) { // 不是 if,而是 while
lock.wait();
}
// 条件满足,继续执行
}
if 只在唤醒时检查一次条件,而 while 会在每次醒来后都重新检查。即使发生虚假唤醒,循环会再次判断条件,如果条件不满足就继续 wait;只有条件真正满足时才会退出循环。这个写法的额外好处是,即使 notifyAll 唤醒了所有线程,它们也会依次检查条件,不会出现“线程 A 抢到锁改变了条件,线程 B 拿到锁后按旧条件执行”的竞态问题。
4.3 经典的生产者-消费者模型对照
把 wait/notify 用到正道上,标准的生产者-消费者模型是这样的:
java复制public class ProducerConsumer {
private static final Object LOCK = new Object();
private static final LinkedList<Integer> QUEUE = new LinkedList<>();
private static final int CAPACITY = 5;
static class Producer extends Thread {
@Override
public void run() {
int value = 0;
while (true) {
synchronized (LOCK) {
while (QUEUE.size() == CAPACITY) { // 队列满:生产者等待
try {
LOCK.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
QUEUE.offer(value++);
System.out.println("生产: " + value + ", 队列: " + QUEUE.size());
LOCK.notifyAll(); // 通知消费者可以消费了
}
try {
Thread.sleep(300); // 模拟生产耗时,注意这里已不在锁内
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
}
static class Consumer extends Thread {
@Override
public void run() {
while (true) {
synchronized (LOCK) {
while (QUEUE.isEmpty()) { // 队列空:消费者等待
try {
LOCK.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
System.out.println("消费: " + QUEUE.poll() + ", 队列: " + QUEUE.size());
LOCK.notifyAll();
}
try {
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}
}
public static void main(String[] args) {
new Producer().start();
new Consumer().start();
}
}
注意我在生产/消费耗时这里用的是 sleep,而且是在退出 synchronized 块之后才睡的。这是一个实战中容易犯的错:不要在持有锁的情况下调用 sleep,除非你能明确接受其他线程全部阻塞的后果。模拟耗时、限流、退避这种操作,都应该在锁外执行。
5. 实战选型:什么场景用 sleep,什么场景用 wait
5.1 该用 sleep 的典型场景
sleep 的本质是“延时”,和线程间协作没有直接关系。以下场景适合用 sleep:
- 定时轮询:比如定期刷新配置、定期拉取心跳,用
sleep控制轮询周期。 - 重试退避:调用外部接口失败后,休息一段时间再重试,
sleep简单直接。 - 模拟耗时:测试代码里模拟网络延迟、IO 等待。
- 节奏控制:比如在动画循环、限流器里控制请求发送间隔。
用 sleep 时记得把它包在工具类里,防止上下文切换时的时间损耗导致累计偏差。比如你想让线程每隔 1 秒执行一次,直接在循环里 Thread.sleep(1000),实际间隔会略大于 1 秒;如果要精确控制周期,应该计算执行时间然后 sleep(delay - elapsed)。
java复制// 简单轮询:不推荐这种写法
while (!isDone()) {
Thread.sleep(1000); // 每次醒来检查一次,如果检查本身耗时就累计漂移
}
// 更精确的周期控制
long start = System.currentTimeMillis();
while (!isDone()) {
doWork();
long elapsed = System.currentTimeMillis() - start;
long waitTime = INTERVAL - elapsed;
if (waitTime > 0) {
Thread.sleep(waitTime);
}
start = System.currentTimeMillis();
}
5.2 该用 wait 的典型场景
wait 的本质是“等待条件成立”,它与共享资源的协调紧密相关。典型场景包括:
- 生产者-消费者:队列满时生产者
wait,队列空时消费者wait。 - 线程池任务调度:任务队列有任务时唤醒工作线程,没任务时工作线程
wait。 - 游戏服务器中的房间匹配:匹配条件不满足时,玩家线程
wait,有新玩家加入时notifyAll。 - 缓存击穿防护:多个线程请求同一个缓存 key,第一个线程去加载数据,其他线程
wait等待加载完成再一次性放行。
wait/notify 的优势是事件驱动,线程不需要反复醒来检查条件,CPU 利用率高。而且它天然和 synchronized 绑定,保证了条件变量的可见性和原子性——因为所有等待/通知操作都在锁的保护下完成,不存在数据竞争问题。
5.3 两者混用的正确姿势
在实际项目中,sleep 和 wait 经常搭配使用,但必须各司其职:
- 线程之间的条件协作(队列满/空、任务到达/完成)用
wait/notifyAll。 - 单线程内的延时(重试间隔、轮询周期)用
sleep。 - 但有一个反模式必须避免:用
sleep模拟wait的等待条件,即轮询一个共享变量并用sleep控制检查频率。这种写法既浪费 CPU(高频轮询)又增加延迟(低频轮询),还容易出现可见性问题——你轮询的变量如果不是volatile,可能长时间看不到其他线程的修改。这个反模式在接手老项目时尤其常见,我见过太多用while (!flag) { sleep(100); }写的“假等待”了。
更现代的替代方案还有 LockSupport.park/unpark、Condition.await/signal、以及各种 BlockingQueue。如果你在写新代码,我的建议是:**能用 BlockingQueue 解决的并发协作,优先用 BlockingQueue;需要更细粒度的等待/通知控制再用 Condition;而 wait/notify 更适合在网络框架、中间件源码阅读以及需要直接控制 monitor 的底层场景。**但理解 wait/notify 原理依然重要,因为很多框架底层(比如某些线程池实现)依然直接使用 wait/notify。
6. 常见问题速查与踩坑实录
6.1 高频面试/工作中问题速查表
| 对比维度 | Thread.sleep() |
Object.wait() |
|---|---|---|
| 所属类 | Thread |
Object |
| 方法性质 | 静态方法 | 实例方法 |
| 是否持有锁 | 不需要持有锁 | 必须在持有该对象 monitor 时调用 |
| 释放锁 | 不释放任何锁 | 释放对象的 monitor 锁 |
| 线程状态 | TIMED_WAITING |
WAITING(无参/0)或 TIMED_WAITING(带超时) |
| 唤醒方式 | 时间到自动唤醒 | notify/notifyAll 或超时自动唤醒,或中断唤醒 |
| 调用场景 | 延时、节奏控制 | 线程间条件协作 |
| 抛出的异常 | InterruptedException |
InterruptedException + IllegalMonitorStateException(未持锁时) |
| 返回值/标志 | 无 | 无,唤醒后需重新竞争锁 |
这个表格基本就是面试时的标准答案骨架。但面试官如果追问一句“wait 为什么必须放在 synchronized 里?”你不会回答“因为 JVM 规定”,而要说出“防止丢失唤醒”这个本质原因。下面单独讲。
6.2 防止丢失唤醒:wait 必须配合锁的深层原因
考虑一个经典竞态:如果 wait 不要求持锁,那么下面这个逻辑就会出现致命问题:
java复制// 如果 wait 不需要锁,伪代码
if (queue.isEmpty()) {
thread.wait(); // 假设这里可以调用
}
// 另一个线程:
if (queue.notEmpty()) {
thread.notify();
}
当消费者检查完 queue.isEmpty() 发现队列为空、正准备执行 wait 的瞬间,生产者恰好插入一条数据并执行 notify。由于消费者此时还没进入等待队列,这个 notify 就丢失了。消费者随后进入 wait,但队列里其实已经有数据了,而这次 notify 已经错过,消费者可能永远等下去——这就是“丢失唤醒”问题。
wait 强制要求持锁后,检查条件和 wait 操作变成一个原子操作(在同一个 synchronized 块内)。消费者持锁检查到队列为空后进入等待队列并释放锁,这个过程中生产者无法插入数据,只能等消费者真正进入 wait 状态释放锁后才能执行插入和 notify。这样 notify 永远不会丢失。锁的存在让“检查-等待”变得原子,这就是 wait 必须配合 synchronized 的根因。
6.3 生产环境实战教训
最后分享几个我在实际项目中踩过的坑,每个都对应了 sleep/wait 的错误用法:
教训一:持锁调用 sleep 导致服务雪崩。 有一次微服务在调用外部接口失败后,在 synchronized 块内直接 Thread.sleep(5000) 做退避,结果该对象的锁被占住 5 秒,所有请求在这个锁上排队,线程池很快被打满。正确的做法是先把锁释放掉,再用 sleep 退避——也就是我在第 4 章示例里强调的“退出锁块后再 sleep”。
教训二:wait 没有超时导致线程永久挂起。 接手的一个老系统里有一段 lock.wait()(无参)等待某个异步回调。正常流程下回调线程会 notify,可一旦回调线程抛异常提前退出,notify 永远不会调用,等待线程就永远 WAITING。排查时用 jstack 看到一堆 "waiting on monitor" 的线程,就是这里。后来全部改成 lock.wait(3000) 并在醒来后循环判断条件,至少保证有超时兜底。
教训三:sleep 之后的共享变量没有 volatile。 用 while (!flag) { Thread.sleep(100); } 轮询一个普通实例变量,结果 flag 被另一个线程改了之后,当前线程永远看不见。问题不在 sleep,而在可见性。轮询标记位必须用 volatile 修饰,或者改用 wait/notify 这种本身就带锁内存可见性保证的机制。
教训四:把 sleep(0) 当成让出 CPU 的万能药。 有些同学写自旋锁时用 Thread.sleep(0) 期待让出 CPU。实际上 sleep(0) 的语义是“不睡眠,仅重新参与线程调度”,如果当前线程处于高优先级且不阻塞,可能依然抢占 CPU。真正“让出 CPU 但保持运行状态”应该用 Thread.yield(),而自旋锁在竞争激烈时根本不该用自旋,应该用 LockSupport.parkNanos() 或 wait。
写在最后的一点建议
其实 Thread.sleep() 和 Object.wait() 更像是两种完全不同的并发原语:一个是“让我自己安静一会儿”的延时开关,一个是“让我和别的线程协作”的通信渠道。你用 sleep 去实现协作,等于用定时器去模拟信号灯,虽然有时也能跑通,但效率、可靠性和可维护性都大打折扣。
我个人在写并发代码时的习惯是:先问自己一个问题——“这个线程是在等待一个时间点,还是在等待一个条件?”等时间点用 sleep,等条件用 wait/notify 或者更高级的并发工具。想清楚这个问题,大多数选型困扰都会迎刃而解。最后再分享一个调试技巧:排查卡顿问题的时候,优先用 jstack 看线程栈,如果线程停在 Thread.sleep 上,那是它自己定的闹钟没响;如果停在 Object.wait 上,那就是它在等一个可能永远不会来的通知——后者通常才是真正的 bug 所在。
