多线程基础

面向对象程序设计(Java)

掌握线程创建、状态与同步,理解并发三要素

前置知识:进程与线程

进程 = "独立的舞台"

进程
  • 每个进程有独立的内存空间(堆、栈、代码段)
  • 进程间通信需要 IPC(管道、共享内存、消息队列)
  • 创建进程开销大:需要分配内存、复制资源
  • 例子:打开浏览器、启动 IDE、运行音乐播放器,各是一个进程

线程 = "舞台上的演员"

线程
  • 线程属于某个进程,共享该进程的内存空间
  • 每个线程有自己的栈(局部变量)和程序计数器
  • 线程间通信简单:直接读写共享变量
  • 例子:浏览器同时下载文件、渲染页面、响应用户输入,各是线程
类比记忆
  • 进程就像"舞台":独立的场地,独立的灯光、独立的音响、独立的道具
  • 线程就像"演员":共享舞台的资源(灯光、音响),各自表演
  • 多进程 = 多个舞台,各演各的戏,互不干扰但成本高
  • 多线程 = 一个舞台多个演员,资源共享但需要协调(同步)避免冲突

Java 程序默认有一个主线程(main),可以通过创建新线程实现并发执行。

为什么需要多线程

你点了一下“导出报表”按钮,整个窗口瞬间卡住,进度条一动不动。5秒后界面才恢复——单线程的代价。

问题:GUI卡死

  • 主线程既要响应界面,又要执行耗时任务
  • 耗时任务阻塞事件派发线程,界面失去响应
  • 把任务搬出主线程,窗口即可继续流畅操作

线程=“不卡”的代价

  • 线程切换开销远小于进程,适合并发与响应式场景
  • 同进程内线程共享堆与方法区,通信零拷贝
  • 但共享可变数据必须正确同步,否则结果错乱

先解决“卡”,再解决“乱”——多线程先让程序不卡,再用同步保证不乱。

交互式演示:串行 vs 并发 vs 并行

建议在学习理论后,通过交互式演示加深理解。 观察完成演示后返回教程,继续学习线程与任务的概念。

线程与任务:演员与剧本

核心概念
  • 线程(Thread)= 演员:被动的表演者,拥有独立的栈和程序计数器,由JVM调度上台
  • 任务(Task/Runnable)= 剧本:演员表演的具体内容,定义在 run() 方法中
  • 调度器 = 导演:决定哪个演员上台,换演员时当前演员就停止表演

为什么要分离?

  • 灵活复用:同一剧本可以由多个演员表演(如多窗口卖票)
  • 资源优化:演员可以表演不同剧本(线程池复用)
  • 解耦设计:剧本内容与演员管理分离,代码更清晰
// 剧本 = 表演内容(演什么)
Runnable script = () -> {
    System.out.println("表演中...");
};

// 演员 = 表演载体(谁来演)
Thread actor1 = new Thread(script);
Thread actor2 = new Thread(script);

actor1.start(); // 演员1上台表演
actor2.start(); // 演员2上台表演

演员只是被动的表演者,它不"理解"剧本的情节,只是按顺序表演台词。导演决定谁上台,不关心演员在表演什么剧本。

创建线程:继承 Thread

核心思想: 定义一个线程类,重写 run() 方法。

  • 步骤:
    1. 继承 Thread 类
    2. 重写 run() 方法(定义任务)
    3. 创建子类对象
    4. 调用 start() 启动
  • 缺点: Java 是单继承机制,继承了 Thread 就无法继承其他业务类,耦合度高。
class MyThread extends Thread {
    public void run() {
        for (int i = 0; i < 3; i++) {
            System.out.println("工作: " + i);
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start(); // 必须调用 start 而不是 run
    }
}

继承方式简单直接,常用于快速测试,但在实际开发中较少使用。

创建线程:实现 Runnable

核心思想: 将"任务"与"线程"分离,实现解耦。

  • 优势:
    • 避免单继承局限: 任务类还可以继承其他类。
    • 资源共享: 多个线程可以共享同一个 Runnable 对象中的数据。
    • 线程池支持: 线程池只接收 Runnable/Callable 任务。
💡 观察要点:
  • 两个线程共享同一个 TicketTask 对象
  • 共享 tickets 变量,从10减到0
  • 两个窗口交替卖票,体现并发执行
class TicketTask implements Runnable {
    private int tickets = 10;  // 共享资源:10张票

    public void run() {
        while (tickets > 0) {
            System.out.println(Thread.currentThread().getName() +
                " 卖出第 " + tickets-- + " 张票");
            try {
                Thread.sleep(100);  // 模拟卖票耗时
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    }
}

public class Main {
    public static void main(String[] args) {
        TicketTask task = new TicketTask();

        // 两个窗口同时卖票,共享同一批票
        new Thread(task, "窗口A").start();
        new Thread(task, "窗口B").start();
    }
}

// 输出示例(每次运行不同):
// 窗口A 卖出第 10 张票
// 窗口B 卖出第 9 张票
// 窗口A 卖出第 8 张票
// 窗口B 卖出第 7 张票
// ...

这是最常用的基础创建方式,不仅解耦,还更方便资源共享。 注意:这里尚未加锁,后面会学习如何用 synchronized 解决并发问题。

* 进阶:带返回值的异步任务

在基础多线程中,我们学习了 Thread 和 Runnable。但如果任务需要返回值呢?

📖 进阶学习: Callable 和 Future
  • Callable<V>:类似 Runnable,但可以返回值并抛出异常
  • Future:表示异步计算的结果,可以获取、取消、检查任务状态
  • 这些工具属于 java.util.concurrent 包(JUC)

详细内容请参见:第16章 JUC并发编程 → "Callable与Future:带返回值的异步任务"

🎯 为什么需要 Callable?

  • Runnable 的局限:无返回值,不能抛 checked 异常
  • Callable 的优势:有返回值,支持异常处理
  • 典型场景:并发计算、批量数据处理、异步IO结果获取

掌握 Thread 和 Runnable 已经足够应对大部分基础并发场景。 当需要更强大的异步编程能力时,继续学习 JUC 中的 Callable 和 Future。

启动与基本控制

start()

启动新线程,JVM会在新线程中异步调用该线程的run()方法。

  • 创建新线程并分配资源
  • 调用start后线程进入RUNNABLE状态
  • 多次调用start会抛出IllegalThreadStateException

🎭 导演(调度器)喊"开拍",演员(线程)上台开始表演。直接调用run()就像自己在后台念剧本,根本没上台!

Thread t = new Thread(() -> {
    System.out.println("新线程执行中...");
});
t.start(); // 启动新线程
System.out.println("主线程继续...");

sleep(long millis)

让当前线程暂停执行指定的毫秒数,进入TIMED_WAITING状态。

  • 释放CPU但不会释放锁
  • 休眠结束后重新进入就绪队列等待调度
  • 抛出InterruptedException需处理

🎭 演员说"中场休息5分钟",先到后台歇会儿,让其他演员接着演。休息完再上台继续表演。

Thread t = new Thread(() -> {
    for (int i = 0; i < 3; i++) {
        System.out.println("任务" + i);
        try {
            Thread.sleep(500); // 休眠500ms
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }
});
t.start();

yield()

建议调度器当前线程愿意让出CPU给其他线程。

  • 只是建议,不保证一定会切换
  • 线程仍处于RUNNABLE状态
  • 优先级相同的线程间更可能有效

🎭 演员礼貌地说"要不先让别的演员演会儿?"但导演可能不理会,继续让这个演员演。

Thread t1 = new Thread(() -> {
    for (int i = 0; i < 5; i++) {
        Thread.yield(); // 建议让出CPU
        System.out.println("线程1: " + i);
    }
});
Thread t2 = new Thread(() -> {
    for (int i = 0; i < 5; i++) {
        System.out.println("线程2: " + i);
    }
});
t1.start();
t2.start();

join()

等待目标线程执行完毕后再继续执行当前线程。

  • 调用者线程进入WAITING状态
  • 目标线程执行完毕后调用者恢复
  • 可指定超时时间:join(long millis)

🎭 主角(主线程)在后台等配角(子线程)演完,配角谢幕下台后,主角才继续登台表演。

Thread worker = new Thread(() -> {
    for (int i = 0; i < 3; i++) {
        System.out.println("工作中..." + i);
        try { Thread.sleep(300); } catch (Exception e) {}
    }
});
worker.start();
worker.join(); // 等待worker完成
System.out.println("工作完成!");

交互式演示:线程控制方法

通过交互式演示观察线程状态变化,加深对控制方法的理解。 掌握这些方法是实现线程协调的基础。

现实生活中的并发问题

前面我们学习了如何创建和控制线程。现在让我们思考一个问题: 当多个线程同时访问和修改共享数据时,会发生什么?

🤔 先思考一下:

在卖票示例(Slide 7)中,两个线程共享同一个 TicketTask 对象,并发地修改 tickets 变量。你有没有想过:如果两个线程恰好同时执行 tickets--,会发生什么?

答案是:可能会卖出相同的票号,或者数据丢失! 让我们通过生活中的场景来理解这个问题。

🎬 场景1:银行存款

问题:两人同时存钱
  • 账户余额:1000元
  • 丈夫读取余额:1000元
  • 妻子也读取余额:1000元(还没看到丈夫的修改)
  • 丈夫存入500元 → 1500元
  • 妻子存入300元 → 1300元(覆盖了丈夫的1500)
  • ❌ 结果:1300元,应该是1800元!

🎫 场景2:火车票售票

问题:两个窗口同时卖最后一张票
  • 剩余票数:1张
  • 窗口A查询:有票
  • 窗口B查询:有票(还没看到A的扣减)
  • 窗口A卖出 → 剩余0张
  • 窗口B卖出 → 剩余0张(但已经卖出!)
  • ❌ 结果:超卖1张票!

🔍 问题根源

关键发现:
  • "读-改-写"不是原子操作
  • 在"改"的过程中,其他人可能也在"读"
  • 大家都基于旧值修改,导致数据丢失
  • 这叫做:竞态条件(Race Condition)

⚡ 为什么会这样?

在计算机中,一个简单的 i++ 操作实际分成三步:

1. READ: 读取 i 的值
2. MODIFY: 计算 i+1
3. WRITE: 写回新值

如果线程A在步骤2时,线程B执行了步骤1,两个线程都会基于相同的旧值进行修改!

💡 解决思路:

生活中需要"排队"和"加锁"来避免冲突,程序中也是一样。

在多线程环境下,我们必须使用同步机制来保证数据的一致性。

现实中的并发问题时刻发生:银行存款、火车票、库存扣减... 理解这些问题后,让我们看看如何在代码中复现和解决它们。

并发编程的三大特性

📚 知识框架:

在并发编程中,我们面临的线程安全问题都可以归结为三个核心特性:原子性、可见性、有序性。

理解这三大特性,是掌握并发编程的基础!

刚才我们看到了生活中的并发问题。现在让我们把视角转回代码: 两个线程各给计数器加100000次,结果应该是200000,但实际往往更少。

🎯 学习目标:

通过这个例子,我们将学习:

  • 为什么 i++ 不是线程安全的?
  • 什么是原子性、可见性、有序性?
  • 如何用 synchronized 解决并发问题?
public class RaceCounter {
    static int counter = 0;

    public static void main(String[] args) throws Exception {
        Runnable r = () -> {
            for (int i = 0; i < 100000; i++) {
                counter++; // 非原子操作!
            }
        };

        Thread t1 = new Thread(r);
        Thread t2 = new Thread(r);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println(counter);
        // 输出可能是 180000、195000、198000...
        // 几乎永远不是 200000
    }
}

给"读-改-写"加锁,三步变一步,200000次更新不再丢失。

🔍 案发现场:20000次更新去哪了?
  • i++ 在字节码里是"取值→+1→写回"三步
  • 线程A写回前,线程B也读到旧值
  • 线程B也+1,然后写回,覆盖了A的结果
  • 一次自增丢失,累加就是"少"

💡 并发编程三大特性

Java内存模型(JMM)定义了并发编程需要关注的三个核心问题:

1. 原子性 (Atomicity)

定义:操作不可分割,要么全部执行成功,要么全部不执行。

类比:就像转账操作,要么"扣款+入账"同时成功,要么同时失败,不能只扣款不入账。
本例问题:i++ 不是原子操作(分三步),导致数据丢失。
解决方案:用 synchronized 或锁保证原子性。

2. 可见性 (Visibility)

定义:一个线程对共享变量的修改,其他线程能够立即看到。

类比:就像公告栏,一个人贴了通知,其他人应该马上能看到。
问题:每个线程有自己的缓存,修改不会立即同步到主内存。
解决方案:用 volatile 或 synchronized 保证可见性。

3. 有序性 (Ordering)

定义:程序执行的顺序按照代码的先后顺序执行。

问题:编译器和处理器为了优化性能,可能会指令重排序。
影响:单线程不影响结果,但多线程可能导致问题。
解决方案:用 volatile 禁止指令重排序(详见后续volatile章节)。

🎯 本节重点:用锁把三步打包成一步,保证原子性。

临界区与互斥锁

🛍️ 试衣间的故事:理解临界区

想象一家小服装店,只有一间试衣间。两位顾客,小张和小李,同时看中了几件衣服,都想试试。

试衣间门上没有锁,小张刚推门进去一半,小李也伸手要进——两个人在门口尴尬地卡住了,衣服掉了一地,场面一度非常混乱。

问题出在哪里?试衣间这个"空间",同一时间只能容纳一个人,这就是所谓的临界区。

解决方案:给试衣间装上锁。小张进去后从里面锁上门,小李看到锁了,只能在外面等待。等小张试完出来,小李再进去。

类比到多线程:

  • 试衣间 = 临界区:共享资源(如计数器),同一时间只能一个线程访问
  • 顾客 = 线程:多个线程同时要访问临界区

通过可视化演示理解临界区冲突和互斥锁的保护机制。 synchronized是Java中最基础也最重要的同步工具。

synchronized 关键字

刚才的试衣间故事中,"门锁"是解决冲突的关键——它保证了同一时间只有一个人在试衣间里。在Java中,synchronized关键字就是这样的"门锁"。当一个线程进入临界区(试衣间)时,它会先获取锁,把门从里面锁上;其他线程看到门锁着,只能在外面等待。等第一个线程完成工作、释放锁后,等待的线程才能进入。

问题:没有门锁的试衣间

试衣间没有锁,两个顾客同时进入,冲突发生了!

  • 多个线程同时访问共享资源(试衣间)
  • 没有保护机制,数据可能出错
  • 运行结果不确定

🎭 比喻:小张刚进试衣间,小李也跟着进去了,两个人挤在里面,衣服掉了一地。

class Shop {
    private boolean roomOccupied = false;
    
    public void tryOn(String customer) {
        if (!roomOccupied) {
            roomOccupied = true;
            System.out.println(customer + " 进入试衣间");
            try { Thread.sleep(1000); } catch (Exception e) {}
            System.out.println(customer + " 离开试衣间");
            roomOccupied = false;
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Shop shop = new Shop();
        new Thread(() -> shop.tryOn("顾客1")).start();
        new Thread(() -> shop.tryOn("顾客2")).start();
    }
}

// 可能的输出:两个顾客同时进入试衣间!

解决方案:在方法上加锁

🔐 给试衣间装上门锁,一次只能进一个人。

  • synchronized加在方法声明上
  • 同一对象的线程串行执行

🎭 比喻:给试衣间装上锁,顾客1进去后锁上门,顾客2只能在外面等待。

class Shop {
    private boolean roomOccupied = false;
    
    public synchronized void tryOn(String customer) {
        if (!roomOccupied) {
            roomOccupied = true;
            System.out.println(customer + " 进入试衣间");
            try { Thread.sleep(1000); } catch (Exception e) {}
            System.out.println(customer + " 离开试衣间");
            roomOccupied = false;
        }
    }
}

public class Main {
    public static void main(String[] args) {
        Shop shop = new Shop();
        new Thread(() -> shop.tryOn("顾客1")).start();
        new Thread(() -> shop.tryOn("顾客2")).start();
    }
}

// 输出:顾客1进入 → 顾客1离开 → 顾客2进入 → 顾客2离开

等效写法:给this加锁

🔑 synchronized(this) 代码块外的部分不受锁保护,只有代码块内的部分才同步。

  • synchronized(this) 修饰代码块
  • 只有代码块内的部分是同步的
  • 代码块外的代码不受锁保护

🎭 比喻:synchronized方法是"整个门都锁",synchronized(this)是"只锁试衣间",外面的走廊不锁。

class Shop {
    private boolean roomOccupied = false;
    
    public void tryOn(String customer) {
        System.out.println(customer + " 走进商店(不受锁保护)");
        
        synchronized (this) {
            if (!roomOccupied) {
                roomOccupied = true;
                System.out.println(customer + " 进入试衣间(受锁保护)");
                try { Thread.sleep(1000); } catch (Exception e) {}
                System.out.println(customer + " 离开试衣间");
                roomOccupied = false;
            }
        }
        
        System.out.println(customer + " 走出商店(不受锁保护)");
    }
}

public class Main {
    public static void main(String[] args) {
        Shop shop = new Shop();
        new Thread(() -> shop.tryOn("顾客1")).start();
        new Thread(() -> shop.tryOn("顾客2")).start();
    }
}

// 输出示例:
// 顾客1 走进商店
// 顾客2 走进商店  ← 注意:两个顾客可以同时"走进商店"!
// 顾客1 进入试衣间
// 顾客1 离开试衣间
// 顾客2 进入试衣间
// 顾客2 离开试衣间
// 顾客1 走出商店
// 顾客2 走出商店

死锁与线程协作

我们已经学会使用 synchronized 来保证操作的互斥性。

但仅有互斥是不够的。如果线程之间不懂得协作(例如:条件不满足时主动释放锁并等待),就会导致一方占着锁空转,另一方永远无法进入的“僵局”。

🏪 真实场景:汉堡店(生产者消费者问题)

想象一家只能放 5个汉堡 的出餐口:

  • 👨‍🍳 厨师(生产者):做太快出餐口满了,若不懂“休息”,就会拿着汉堡死盯出餐口(忙等待)。
  • 💁 服务员(消费者):卖太快出餐口空了,若不懂“休息”,就会每秒问100次“好了没”(忙等待)。
❌ 错误尝试
import java.util.LinkedList;

// 一个必定死锁的队列实现
class DeadlockQueue<T> {
    // 共享资源:LinkedList q (非线程安全)
    private final LinkedList<T> q = new LinkedList<>();
    private final int cap;

    public DeadlockQueue(int cap) { this.cap = cap; }

    // 厨师线程:生产汉堡
    public synchronized void put(T x) {
        // 死锁陷阱:
        // 厨师发现满了,进入 while 循环死等(或 sleep)。
        // 但他没有释放锁!服务员想拿菜,但拿不到锁,进不来。
        // 结果:厨师抱着满盘子不撒手,服务员在门外干着急。
        while (q.size() == cap) {
             // Thread.sleep(100); // 即使睡了,锁还在手里!
        } 
        q.add(x);
    }

    // 服务员线程:取走汉堡
    public synchronized T take() {
        // 同样的死锁:
        // 服务员发现空了,死等。
        // 但他不释放锁!厨师想放菜,但拿不到锁,进不来。
        while (q.isEmpty()) {
            // 空转中...
        }
        return q.removeFirst();
    }
    
    public static void main(String[] args) {
        DeadlockQueue<Integer> dq = new DeadlockQueue<>(1); // 容量为1
        new Thread(() -> dq.put(1)).start(); // 厨师放入1个汉堡
        new Thread(() -> dq.put(2)).start(); // 厨师想放第2个 -> 拿锁 -> 满 -> 死循环占锁
        new Thread(() -> dq.take()).start(); // 服务员想取 -> 拿不到锁 -> 饿死
    }
}

后果: 线程持有锁进入死循环,导致CPU 100% 或程序假死(Deadlock)。

✅ 理想状态(wait/notify): 出餐口满了,厨师释放锁并睡觉(wait);服务员拿走汉堡后摇铃(notify)叫醒厨师。
这就是 wait/notify 的核心价值:原子性地释放锁 + 挂起线程。

🤔 困境:如何优雅地“等”?

如果只是简单的 while 循环检查:

  • ❌ 忙等待 (Busy Wait)
    while(full) {} 导致 CPU 100% 空转。
  • ❌ 持有锁等待
    如果在 synchronized 块中死循环,锁永远不释放,对方永远进不来(死锁)。

🔑 破局:wait/notify 机制

我们需要一套“配合机制”:

  • wait(): 释放锁并睡觉(不占CPU,让出锁给别人)。
  • notify(): 叫醒睡着的人(通知条件已满足)。

机制原理:
1. wait(): 释放锁,进入等待室 (WaitSet),不占 CPU。
2. notify(): 随机唤醒等待室里的一个线程。
3. notifyAll(): 唤醒等待室里的所有线程(推荐,防永久等待)。

import java.util.LinkedList;

class BoundedQueue<T> {
    private final LinkedList<T> q = new LinkedList<>();
    private final int cap;
    public BoundedQueue(int cap) { this.cap = cap; }
    
    public synchronized void put(T x) throws InterruptedException {
        while (q.size() == cap) wait(); // 满了就睡,释放锁
        q.add(x);
        // 唤醒所有人(包括厨师和顾客)。
        // 为什么不用 notify()?因为可能错误地唤醒了另一个“厨师”,
        // 而我们需要唤醒“顾客”。notifyAll() 虽然吵醒所有人,但最安全。
        notifyAll(); 
    }
    
    public synchronized T take() throws InterruptedException {
        while (q.isEmpty()) wait(); // 空了就睡,释放锁
        T x = q.removeFirst();
        notifyAll(); // 唤醒所有人
        return x;
    }

    public static void main(String[] args) {
        BoundedQueue<Integer> queue = new BoundedQueue<>(1);
        // 生产者线程
        new Thread(() -> {
            try {
                for (int i = 0; i < 100; i++) {
                    queue.put(i);
                    System.out.println("做好了: " + i);
                }
            } catch (InterruptedException e) {}
        }).start();

        // 消费者线程
        new Thread(() -> {
            try {
                for (int i = 0; i < 100; i++) {
                    Integer x = queue.take();
                    System.out.println("吃掉了: " + x);
                }
            } catch (InterruptedException e) {}
        }).start();
    }
}

配合 synchronized 使用,完美解决“忙等待”和“死锁”风险。

线程生命周期与状态详解

🌈 Java 线程的 6 种状态

1. 初始与终结
  • � NEW
    新建未启动
  • 🏁 TERMINATED
    任务执行结束
2. 运行与就绪
  • � RUNNABLE
    正在运行 或 等待 CPU 时间片
3. 等待与阻塞 (关键!)
  • 💤 TIMED_WAITING:sleep(t) (午休)
  • ⏳ WAITING:wait() (等通知)
  • ⛔ BLOCKED:synchronized (等锁)

🔄 状态转化详解

NEW → RUNNABLE:调用 start() 方法,线程进入就绪队列等待CPU调度。

RUNNABLE → BLOCKED:尝试获取 synchronized 锁失败,进入阻塞队列等待锁释放。

RUNNABLE → TIMED_WAITING:调用 sleep(ms)、wait(timeout)、join(timeout) 等带超时的方法,在指定时间内挂起。

RUNNABLE → WAITING:调用 wait()、join()、LockSupport.park() 等无超时的方法,无限期等待被唤醒。

BLOCKED → RUNNABLE:成功获取到 synchronized 锁,重新进入就绪队列。

TIMED_WAITING / WAITING → RUNNABLE:超时到达、被 notify()/notifyAll() 唤醒、或被 interrupt() 中断。

RUNNABLE → TERMINATED:run() 方法正常执行结束或因未捕获异常退出。

💻 实战:捕捉状态变化

public class ThreadStateDemo {
    public static void main(String[] args) throws Exception {
        Object lock = new Object();
        Thread t = new Thread(() -> {
            // 2. 运行中 -> 睡 200ms (TIMED_WAITING)
            try { Thread.sleep(200); } catch (Exception e) {}
            // 4. 醒来抢锁失败 -> 阻塞 (BLOCKED)
            synchronized (lock) {} 
        });

        System.out.println("1. " + t.getState()); // NEW
        t.start();
        
        // 主线程抢先拿锁,困住子线程
        synchronized (lock) {
            Thread.sleep(100); // 等 t 进入 sleep
            System.out.println("2. " + t.getState()); // TIMED_WAITING
            Thread.sleep(200); // 等 t 醒来抢锁
            System.out.println("3. " + t.getState()); // BLOCKED
        }
        
        t.join(); // 等 t 结束
        System.out.println("4. " + t.getState()); // TERMINATED
    }
}

交互式演示:线程状态机

通过交互式状态机深入理解线程生命周期。 掌握线程状态转换是调试并发问题的关键。

问题:为什么线程停不下来?

public class VisibilityDemo {
    // ❌ 缺少 volatile,存在可见性问题
    static boolean running = true;

    public static void main(String[] args) throws Exception {
        Thread t = new Thread(() -> {
            long i = 0;
            // 子线程可能永远看不到 running 变为 false
            while (running) { 
                i++; 
            }
            System.out.println("Stopped: " + i);
        });
        t.start();
        
        Thread.sleep(100);
        running = false; // 主线程修改状态
        System.out.println("Set running = false");
    }
}

思考:

主线程将 running 设为 false 后,子线程 t 会立刻停止吗?

  • 现象: 程序可能永远陷入死循环,无法退出。
  • 原因:可见性(Visibility)问题
    • CPU 缓存(硬件层): 线程运行在不同的 CPU 核心,子线程将变量缓存在本地的高速缓存(L1/L2)中,未及时从主内存同步最新值。
    • JIT 编译器优化(软件层): 为了性能,JVM 可能会将循环条件判定“提升”到循环外,导致不再重复读取内存。

幕后黑手:JMM 与 JIT 优化

1. Java 内存模型 (JMM)

JMM 规定所有变量存在主内存,每个线程有自己的工作内存。

  1. 初始状态: 主内存中 running = true。
  2. 线程 A(主线程): 修改 running = false,但该修改可能仅存在于 A 的工作内存中,未立即同步回主内存。
  3. 线程 B(子线程): 读取 running 时,直接使用了自己工作内存中的缓存副本(旧值 true)。
  4. 结果: 线程 B 无法感知线程 A 的修改,继续死循环。

线程 B 读取的是自己工作内存中的“旧副本”,不知道线程 A 已经修改了主内存。

2. JIT 编译器激进优化

为了提升性能,JIT 编译器可能会重组指令(Hoisting):

源码

while (running) { 
    i++; 
}
👉

JIT 优化后

if (running) {
    while (true) {
        i++; 
    }
}

既然循环体内没有修改 running,编译器判定不用每次都去读内存,直接变成死循环!

volatile:保证可见性与有序性

✅ 解决方案

  • 可见性: 强制线程从主内存读取变量,修改后立即刷新回主内存。
  • 有序性: 禁止指令重排序(建立内存屏障)。
  • ⚠️ 局限性: 仅保证读/写原子性,不保证复合操作(如 i++)的原子性。
public class VolatileSolution {
    // ✅ 加上 volatile,保证可见性
    static volatile boolean running = true;

    public static void main(String[] args) throws Exception {
        Thread t = new Thread(() -> {
            while (running) { 
                // 此时每次读取都会去主内存获取最新值
            }
            System.out.println("Thread stopped.");
        });
        t.start();
        
        Thread.sleep(100);
        running = false; // 修改立刻对子线程可见
    }
}

适用场景:状态标志(Flag)、单例模式的双重检查锁定(DCL)。

ThreadLocal:线程独享变量

� 场景:SimpleDateFormat 线程不安全

全局共享会报错,每次 New 太浪费,加锁又太慢。我们需要既安全又高效的方案。

🚀 核心 API:构造与初始化

  • ✅ ThreadLocal.withInitial(Supplier):(Java 8+) 推荐方式!提供一个 Supplier 也就是“工厂”,延迟加载,只有当线程第一次调用 get() 时才会执行该逻辑创建对象。
public class ThreadLocalDemo {
    // ✅ 推荐用法:使用 withInitial 定义初始化工厂(懒加载)
    // 只有当线程第一次调用 get() 时,才会执行 lambda 创建 SimpleDateFormat
    static final ThreadLocal<SimpleDateFormat> fmt = 
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    public static void main(String[] args) {
        Runnable task = () -> {
            try {
                // get() 内部逻辑:检查当前线程是否有副本 ? 返回副本 : 调用 initialValue() 创建并绑定
                String date = fmt.get().format(new Date());
                System.out.println(date);
            } finally {
                fmt.remove(); // ⚠️ 务必清理,防止内存泄漏(尤其是线程池场景)
            }
        };
        new Thread(task).start();
    }
}

* 下一步:进入 JUC 高并发编程

掌握了基础多线程后,我们发现在实际开发中仍然面临许多挑战:

🤔 基础多线程的局限

  • 线程管理复杂:手动创建销毁线程,资源管理困难
  • 并发控制有限:synchronized 性能有限,功能单一
  • 工具缺乏:缺乏丰富的并发工具和容器
  • 异步编程困难:Future 使用繁琐,无法链式调用
  • 性能优化难:缺乏高性能的无锁数据结构

🚀 JUC 带来的突破

  • 线程池框架:ThreadPoolExecutor 自动管理线程
  • 高级锁机制:ReentrantLock、ReadWriteLock 提供更灵活控制
  • 丰富同步工具:CountDownLatch、CyclicBarrier、Semaphore
  • 高性能并发集合:ConcurrentHashMap、BlockingQueue
  • 原子操作类:AtomicInteger、AtomicReference 实现无锁编程
  • 异步编程革命:CompletableFuture 支持链式调用

从基础多线程到JUC,是从"手工作坊"到"工业化生产"的飞跃。JUC让并发编程变得简单、高效、可靠!

1 / X