前言

在学习 Java 多线程时,wait()notify()join() 是最让人头秃的三个 API。很多时候我们只记住了用法,却没搞清楚底层线程状态到底是怎么变化的:什么时候是 RUNNABLE?什么时候变 BLOCKED?什么时候又是 WAITING

尤其是 join(),看起来像魔法一样让主线程停下来,它底层到底做了什么?

今天我们就通过几段最基础的代码,配合时间轴推演和源码分析,把这几个 API 扒得干干净净。


一、经典场景:wait() 与 notifyAll() 的接力赛

首先看这段代码,两个子线程 t1t2 争抢同一把锁,拿到锁后都主动释放并进入等待,最后由主线程统一唤醒。

📝 代码示例

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 (阻塞,在门口排队)。
  • T1 时刻(第一棒交接)

    • 线程 1 执行完 println,遇到 obj.wait()
    • 关键动作wait() 会做两件事 —— ① 释放锁;② 自己进入 WAITING 状态(不再竞争 CPU,去休息区睡觉)。
    • 连锁反应:因为锁被释放了,门口排队的 线程 2 瞬间抢到锁!
    • 线程 2:状态从 BLOCKED ➡️ RUNNABLE ➡️ RUNNING
  • T2 时刻(第二棒交接)

    • 线程 2 执行完 println,也遇到 obj.wait()
    • 关键动作:线程 2 释放锁,自己也进入 WAITING 状态。
    • 现状:此时 t1t2 都在 WAITING 池子里睡觉,锁空闲。
  • T3 时刻(主线程介入)

    • 主线程睡了 5 秒醒来,获取锁,执行 obj.notifyAll()
    • 动作:将 t1t2WAITING 状态唤醒,扔回 RUNNABLE 队列。
    • 最终竞争t1t2 再次站在起跑线上,谁先抢到锁,谁就先执行 wait() 后面的代码

❓ 问:notifyAll 后,怎么确保先唤醒线程 A 再唤醒线程 B?
答:没办法保证! Java 规范没有规定唤醒顺序,这完全取决于操作系统调度器的喜好。它们被唤醒后,需要重新公平地竞争锁。

💡 通俗比喻:拳击台与休息区

为了区分 BLOCKEDWAITING,我们可以打个比方:

  • 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] 结束");
    }
}

输出顺序

  1. [Main] 开始
  2. [T1] 开始
  3. [T1] 结束...
  4. [Main] 结果为: 10 (正确拿到了结果)
  5. [Main] 结束

场景 2:不使用 join() (灾难现场)

如果去掉 join(),主线程根本不会等 t1

// ... 前面代码一样 ...
        t1.start(); 
        
        // ❌ 没有 join(),主线程直接往下跑
        System.out.println("[Main] 结果为: " + r); // 输出 0
        System.out.println("[Main] 结束");
// ...

输出顺序

  1. [Main] 开始
  2. [T1] 开始
  3. [Main] 结果为: 0 (主线程跑太快,t1 还没赋值)
  4. [Main] 结束
  5. [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; 
    }
}

🛌 主线程在“睡大觉”

这段代码翻译成人话就是:

  1. 检查:目标线程 t1 还活着吗?
  2. 睡觉:如果活着,主线程就调用 wait() 去睡觉(进入 WAITING 状态)。
    • 这时候主线程完全不占 CPU,把资源让给 t1 去干活。
    • 这就好比你跟员工说:“你先干着,我去隔壁休息室睡会儿,闹钟定了 5 分钟,或者你干完了喊我一声。”
  3. 唤醒
    • 要么闹钟响了(超时)。
    • 要么 t1 干完活结束了,JVM 内部会自动触发 notifyAll(),把主线程吵醒。
  4. 循环:主线程醒来,再次检查 t1 还活着吗?
    • 如果还活着(比如超时醒来的),继续睡。
    • 如果死了(isAlive() 为 false),循环结束,join() 方法返回,主线程继续执行后续代码。

⚖️ 为什么要睡?不睡行不行?

如果主线程不“睡”,而是死循环检查:

// ❌ 错误的做法:如果不睡,一直空转
while (t1.isAlive()) {
    // 什么都不做,就一直在这里空转检查
    // 这会疯狂占用 CPU 资源,把电脑跑烫!
}

这就好比你站在员工工位旁边,瞪大眼睛每秒问 100 次:“你干完了吗?你干完了吗?”

  • 后果:你自己累得半死(CPU 占用率 100%),员工也被你吵得没法干活。

所以,join() 的本质就是让主线程在循环中不断地 wait(),既节省了 CPU 资源,又能及时响应线程结束的事件。


四、总结

通过今天的分析,我们理清了多线程协作的核心脉络:

  1. 状态流转

    • RUNNABLE ➡️ RUNNING:抢到 CPU 时间片。
    • RUNNABLE ➡️ BLOCKED:想抢锁,但锁被别人拿着(在门口排队)。
    • RUNNING ➡️ WAITING:主动调用 wait()join(),释放锁,去休息区睡觉(退出竞争)。
    • WAITING ➡️ RUNNABLE:被 notify() 唤醒,重新回到起跑线抢锁。
  2. API 选择

    • wait()/notify():适用于复杂的线程间协作(如生产者-消费者模型),需要精细控制“何时等待”、“何时通知”。
    • join():适用于简单的“等待子线程执行完毕”的场景。它是 wait() 的封装版,好用且不易出错。
  3. 核心心法

    • 不要神话 join(),它就是循环 wait()
    • notifyAll() 不保证顺序,唤醒后大家还得重新抢锁。
    • 主线程调用 join() 后,就是在睡觉,这是在保护 CPU 资源。

希望这篇博客能帮你彻底打通 Java 并发中 waitnotifyjoin 的任督二脉!

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐