多线程基础
面向对象程序设计(Java)
掌握线程创建、状态与同步,理解并发三要素前置知识:进程与线程
进程 = "独立的舞台"
- 每个进程有独立的内存空间(堆、栈、代码段)
- 进程间通信需要 IPC(管道、共享内存、消息队列)
- 创建进程开销大:需要分配内存、复制资源
- 例子:打开浏览器、启动 IDE、运行音乐播放器,各是一个进程
线程 = "舞台上的演员"
- 线程属于某个进程,共享该进程的内存空间
- 每个线程有自己的栈(局部变量)和程序计数器
- 线程间通信简单:直接读写共享变量
- 例子:浏览器同时下载文件、渲染页面、响应用户输入,各是线程
- 进程就像"舞台":独立的场地,独立的灯光、独立的音响、独立的道具
- 线程就像"演员":共享舞台的资源(灯光、音响),各自表演
- 多进程 = 多个舞台,各演各的戏,互不干扰但成本高
- 多线程 = 一个舞台多个演员,资源共享但需要协调(同步)避免冲突
Java 程序默认有一个主线程(main),可以通过创建新线程实现并发执行。
为什么需要多线程
你点了一下“导出报表”按钮,整个窗口瞬间卡住,进度条一动不动。5秒后界面才恢复——单线程的代价。
问题:GUI卡死
- 主线程既要响应界面,又要执行耗时任务
- 耗时任务阻塞事件派发线程,界面失去响应
- 把任务搬出主线程,窗口即可继续流畅操作
线程=“不卡”的代价
- 线程切换开销远小于进程,适合并发与响应式场景
- 同进程内线程共享堆与方法区,通信零拷贝
- 但共享可变数据必须正确同步,否则结果错乱
先解决“卡”,再解决“乱”——多线程先让程序不卡,再用同步保证不乱。
交互式演示:串行 vs 并发 vs 并行
🎬 可视化对比演示
通过动画直观对比三种执行模式的差异,理解"并发"和"并行"的本质区别。
- ✨ 观察3个任务在不同模式下的执行顺序和时间
- ✨ 理解单核并发(时间片轮转)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() 方法。
- 步骤:
- 继承
Thread类 - 重写
run()方法(定义任务) - 创建子类对象
- 调用
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<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("工作完成!");
交互式演示:线程控制方法
🎬 sleep() / yield() / join() 演示
深入理解三个关键线程控制方法的实际效果和状态变化。
- ✨ 体验线程休眠(sleep)和时间片轮转
- ✨ 观察join()的阻塞等待效果
- ✨ 理解yield()的让出机制(礼让)
通过交互式演示观察线程状态变化,加深对控制方法的理解。 掌握这些方法是实现线程协调的基础。
现实生活中的并发问题
前面我们学习了如何创建和控制线程。现在让我们思考一个问题: 当多个线程同时访问和修改共享数据时,会发生什么?
在卖票示例(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++ 操作实际分成三步:
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次更新不再丢失。
i++在字节码里是"取值→+1→写回"三步- 线程A写回前,线程B也读到旧值
- 线程B也+1,然后写回,覆盖了A的结果
- 一次自增丢失,累加就是"少"
💡 并发编程三大特性
Java内存模型(JMM)定义了并发编程需要关注的三个核心问题:
定义:操作不可分割,要么全部执行成功,要么全部不执行。
类比:就像转账操作,要么"扣款+入账"同时成功,要么同时失败,不能只扣款不入账。
本例问题:i++ 不是原子操作(分三步),导致数据丢失。
解决方案:用 synchronized 或锁保证原子性。
定义:一个线程对共享变量的修改,其他线程能够立即看到。
类比:就像公告栏,一个人贴了通知,其他人应该马上能看到。
问题:每个线程有自己的缓存,修改不会立即同步到主内存。
解决方案:用 volatile 或 synchronized 保证可见性。
定义:程序执行的顺序按照代码的先后顺序执行。
问题:编译器和处理器为了优化性能,可能会指令重排序。
影响:单线程不影响结果,但多线程可能导致问题。
解决方案:用 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 种状态
- � NEW
新建未启动 - 🏁 TERMINATED
任务执行结束
- � RUNNABLE
正在运行 或 等待 CPU 时间片
- 💤 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
}
}
交互式演示:线程状态机
🎬 线程状态转换演示
探索Java线程的6种状态以及它们之间的转换关系。
- ✨ 交互式状态转换图,触发各种状态变化
- ✨ 观察BLOCKED、WAITING、TIMED_WAITING的区别
- ✨ 理解锁等待和wait()等待的不同机制
通过交互式状态机深入理解线程生命周期。 掌握线程状态转换是调试并发问题的关键。
问题:为什么线程停不下来?
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 规定所有变量存在主内存,每个线程有自己的工作内存。
- 初始状态: 主内存中
running = true。 - 线程 A(主线程): 修改
running = false,但该修改可能仅存在于 A 的工作内存中,未立即同步回主内存。 - 线程 B(子线程): 读取
running时,直接使用了自己工作内存中的缓存副本(旧值true)。 - 结果: 线程 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让并发编程变得简单、高效、可靠!