【Java并发】彻底搞懂 wait/notify 与 join:从“抢锁”到“等待唤醒”的状态流转
前言
在学习 Java 多线程时,wait()、notify() 和 join() 是最让人头秃的三个 API。很多时候我们只记住了用法,却没搞清楚底层线程状态到底是怎么变化的:什么时候是 RUNNABLE?什么时候变 BLOCKED?什么时候又是 WAITING?
尤其是 join(),看起来像魔法一样让主线程停下来,它底层到底做了什么?
今天我们就通过几段最基础的代码,配合时间轴推演和源码分析,把这几个 API 扒得干干净净。
一、经典场景:wait() 与 notifyAll() 的接力赛
首先看这段代码,两个子线程 t1 和 t2 争抢同一把锁,拿到锁后都主动释放并进入等待,最后由主线程统一唤醒。
📝 代码示例
public class TestWaitNotify {
// 锁对象
final static Object obj = new Object();
public static void main(String[] args) throws InterruptedException {
// 线程 t1
new Thread(() -> {
synchronized (obj) {
System.out.println("t1: 执行....");
try {
// 释放锁并进入等待状态
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t1: 其它代码...."); // 被唤醒后执行
}
}, "t1").start();
// 线程 t2
new Thread(() -> {
synchronized (obj) {
System.out.println("t2: 执行....");
try {
// 释放锁并进入等待状态
obj.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2: 其它代码...."); // 被唤醒后执行
}
}, "t2").start();
// 主线程休眠 5 秒,确保 t1 和 t2 都已经进入 wait 状态
Thread.sleep(5000);
System.out.println("main: 唤醒 obj 上其它线程");
synchronized (obj) {
// 唤醒 obj 上所有等待的线程 (t1 和 t2)
obj.notifyAll();
}
}
}
🧠 核心疑惑解析
很多初学者不理解:T0 时刻 t1 和 t2 不是同时启动的吗?为什么不是同时竞争?
其实,虽然代码里几乎同时 start(),但在微观世界里,锁是互斥的。让我们用时间轴来还原现场:
⏱️ 时间轴推演
-
T0 时刻(起跑线):
- 线程 1 (
t1) 和 线程 2 (t2) 同时就绪 (RUNNABLE状态)。 - 系统调度:CPU 时间片有限,假设调度器先选中了 线程 1。
- 线程 1:获得锁,状态从
RUNNABLE➡️RUNNING。 - 线程 2:发现锁被占了,无法进入同步块,状态从
RUNNABLE➡️BLOCKED(阻塞,在门口排队)。
- 线程 1 (
-
T1 时刻(第一棒交接):
- 线程 1 执行完
println,遇到obj.wait()。 - 关键动作:
wait()会做两件事 —— ① 释放锁;② 自己进入WAITING状态(不再竞争 CPU,去休息区睡觉)。 - 连锁反应:因为锁被释放了,门口排队的 线程 2 瞬间抢到锁!
- 线程 2:状态从
BLOCKED➡️RUNNABLE➡️RUNNING。
- 线程 1 执行完
-
T2 时刻(第二棒交接):
- 线程 2 执行完
println,也遇到obj.wait()。 - 关键动作:线程 2 释放锁,自己也进入
WAITING状态。 - 现状:此时
t1和t2都在WAITING池子里睡觉,锁空闲。
- 线程 2 执行完
-
T3 时刻(主线程介入):
- 主线程睡了 5 秒醒来,获取锁,执行
obj.notifyAll()。 - 动作:将
t1和t2从WAITING状态唤醒,扔回RUNNABLE队列。 - 最终竞争:
t1和t2再次站在起跑线上,谁先抢到锁,谁就先执行wait()后面的代码。
- 主线程睡了 5 秒醒来,获取锁,执行
❓ 问:
notifyAll后,怎么确保先唤醒线程 A 再唤醒线程 B?
答:没办法保证! Java 规范没有规定唤醒顺序,这完全取决于操作系统调度器的喜好。它们被唤醒后,需要重新公平地竞争锁。
💡 通俗比喻:拳击台与休息区
为了区分 BLOCKED 和 WAITING,我们可以打个比方:
- BLOCKED (阻塞):就像选手 B 想上台打拳,但台上有人(锁被占用),他只能在台下排队。他眼睛死死盯着台上,一旦台上的人下来,他立刻就要冲上去。他还在“竞争”状态。
- WAITING (等待):就像选手 A 在台上打了一半,突然说“我累了,我要去休息室睡会儿”,并且主动把擂台让出来。他去休息室后,就不再关心擂台上的事了,直到有人去休息室拍醒他(notify)。他暂时退出了“竞争”。
总结一句话:
wait就像被人一闷棍放倒了(或者主动去睡觉),锁也被交出去了,也不再参与竞争。block就是需要锁,但是没抢到,在门口死磕等着抢。
二、实战对比:join() vs 无等待
理解了 wait,我们再来看看实际开发中常用的 join()。
场景 1:使用 join() 等待结果
这是最规范的写法,主线程会乖乖等待 t1 执行完毕。
public class TestJoinDemo {
static int r = 0;
public static void main(String[] args) {
System.out.println("[Main] 开始");
Thread t1 = new Thread(() -> {
System.out.println("[T1] 开始");
try { Thread.sleep(1000); } catch (InterruptedException e) {}
System.out.println("[T1] 结束,正在计算结果...");
r = 10;
});
t1.start();
try {
// 【关键】主线程在这里阻塞,直到 t1 运行结束
t1.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
// 只有当 t1 跑完,上面的 join() 返回,这里才会执行
System.out.println("[Main] 结果为: " + r); // 输出 10
System.out.println("[Main] 结束");
}
}
输出顺序:
- [Main] 开始
- [T1] 开始
- [T1] 结束...
- [Main] 结果为: 10 (正确拿到了结果)
- [Main] 结束
场景 2:不使用 join() (灾难现场)
如果去掉 join(),主线程根本不会等 t1。
// ... 前面代码一样 ...
t1.start();
// ❌ 没有 join(),主线程直接往下跑
System.out.println("[Main] 结果为: " + r); // 输出 0
System.out.println("[Main] 结束");
// ...
输出顺序:
- [Main] 开始
- [T1] 开始
- [Main] 结果为: 0 (主线程跑太快,t1 还没赋值)
- [Main] 结束
- [T1] 结束... (这时候赋值已经没意义了)
三、底层揭秘:join() 真的会魔法吗?
很多人觉得 join() 很神奇,好像主线程 magically 就停下来了。
真相是:join() 根本没有魔法,它底层完全就是靠我们刚才学的 wait() 实现的!
打开 JDK 源码(如上图所示),你会发现 join() 的核心逻辑就是一个 while 循环:
public final synchronized void join(long millis) throws InterruptedException {
long base = System.currentTimeMillis();
long now = 0;
// 【循环条件】只要这个线程 (t1) 还活着 (isAlive() 为 true)
while (isAlive()) {
long delay = millis - now;
if (delay <= 0) {
break;
}
// 【核心动作】调用 wait(delay)
// 主线程在这里释放锁,进入 WAITING 状态,暂停执行
wait(delay);
now = System.currentTimeMillis() - base;
}
}
🛌 主线程在“睡大觉”
这段代码翻译成人话就是:
- 检查:目标线程
t1还活着吗? - 睡觉:如果活着,主线程就调用
wait()去睡觉(进入WAITING状态)。- 这时候主线程完全不占 CPU,把资源让给
t1去干活。 - 这就好比你跟员工说:“你先干着,我去隔壁休息室睡会儿,闹钟定了 5 分钟,或者你干完了喊我一声。”
- 这时候主线程完全不占 CPU,把资源让给
- 唤醒:
- 要么闹钟响了(超时)。
- 要么
t1干完活结束了,JVM 内部会自动触发notifyAll(),把主线程吵醒。
- 循环:主线程醒来,再次检查
t1还活着吗?- 如果还活着(比如超时醒来的),继续睡。
- 如果死了(
isAlive()为 false),循环结束,join()方法返回,主线程继续执行后续代码。
⚖️ 为什么要睡?不睡行不行?
如果主线程不“睡”,而是死循环检查:
// ❌ 错误的做法:如果不睡,一直空转
while (t1.isAlive()) {
// 什么都不做,就一直在这里空转检查
// 这会疯狂占用 CPU 资源,把电脑跑烫!
}
这就好比你站在员工工位旁边,瞪大眼睛每秒问 100 次:“你干完了吗?你干完了吗?”
- 后果:你自己累得半死(CPU 占用率 100%),员工也被你吵得没法干活。
所以,join() 的本质就是让主线程在循环中不断地 wait(),既节省了 CPU 资源,又能及时响应线程结束的事件。
四、总结
通过今天的分析,我们理清了多线程协作的核心脉络:
-
状态流转:
RUNNABLE➡️RUNNING:抢到 CPU 时间片。RUNNABLE➡️BLOCKED:想抢锁,但锁被别人拿着(在门口排队)。RUNNING➡️WAITING:主动调用wait()或join(),释放锁,去休息区睡觉(退出竞争)。WAITING➡️RUNNABLE:被notify()唤醒,重新回到起跑线抢锁。
-
API 选择:
wait()/notify():适用于复杂的线程间协作(如生产者-消费者模型),需要精细控制“何时等待”、“何时通知”。join():适用于简单的“等待子线程执行完毕”的场景。它是wait()的封装版,好用且不易出错。
-
核心心法:
- 不要神话
join(),它就是循环wait()。 notifyAll()不保证顺序,唤醒后大家还得重新抢锁。- 主线程调用
join()后,就是在睡觉,这是在保护 CPU 资源。
- 不要神话
希望这篇博客能帮你彻底打通 Java 并发中 wait、notify 和 join 的任督二脉!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)