第14章:并发编程基础
这一章是 Vol 1 最难的一章。不用一次全看懂——先理解"为什么"和"基本概念",剩下的在实践中慢慢消化。
📌 代码说明:标注"可复制运行"的代码块包含完整的
main方法。没标注的是概念示意片段。
一个故事开头
你的奶茶店生意火爆。现在只有一个店员(小张),他一次只能做一杯奶茶。
来了 10 个客人,小张先做第 1 杯,再做第 2 杯……第 10 杯。最后一个人等了 20 分钟。
如果雇 3 个店员呢?小张做第 1 杯、小李做第 2 杯、小王做第 3 杯——三个人同时干活,总时间缩短到原来的三分之一。
这就是并发(Concurrency)——让多个任务"同时"执行,提高效率。
// 单线程:一个人干所有事
public void serveCustomers() {
makeTea("波波奶茶"); // 5分钟
makeTea("柠檬茶"); // 5分钟
makeTea("抹茶拿铁"); // 5分钟
// 总共 15 分钟
}
// 多线程:三个人同时干
// 线程1: makeTea("波波奶茶")
// 线程2: makeTea("柠檬茶")
// 线程3: makeTea("抹茶拿铁")
// 总共 5 分钟1. 进程 vs 线程
| 进程 | 线程 | |
|---|---|---|
| 什么 | 一个运行中的程序 | 进程里的一个执行路径 |
| 例子 | 你打开了 Chrome、VS Code、微信 | Chrome 里一个标签页 |
| 内存 | 各自独立,互不干扰 | 共享进程的内存 |
| 通信 | 复杂(管道、网络) | 简单(直接读写同一块内存) |
一个 Java 程序启动时,至少有一个线程——就是执行 main 的那个。
2. 创建线程
方式1:继承 Thread 类
// 可复制运行,保存为 MyThreadDemo.java
class TeaMaker extends Thread {
private String drink;
TeaMaker(String drink) {
this.drink = drink;
}
@Override
public void run() {
// run() 里放线程要执行的代码
for (int i = 0; i < 3; i++) {
System.out.println(drink + " 制作中...");
try {
Thread.sleep(500); // 模拟耗时
} catch (InterruptedException e) {
e.printStackTrace();
}
}
System.out.println(drink + " 完成!");
}
}
public class MyThreadDemo {
public static void main(String[] args) {
TeaMaker t1 = new TeaMaker("波波奶茶");
TeaMaker t2 = new TeaMaker("柠檬茶");
t1.start(); // 启动线程——自动调 run()
t2.start();
// 注意:调的是 start(),不是 run()
// t1.run() 是单线程调方法,不会创建新线程
}
}输出(顺序不固定):
波波奶茶 制作中...
柠檬茶 制作中...
波波奶茶 制作中...
柠檬茶 制作中...每次运行顺序都可能不同——因为操作系统决定哪个线程先执行。
💥 拆了它:start() vs run()
把 t1.start() 改成 t1.run()——看看发生了什么。
t1.run(); // ❌ 这是普通方法调用——按顺序执行,不是并发
t2.run(); // 要等 t1.run 执行完才开始输出变成了顺序的:"波波奶茶..." → "...完成" → "柠檬茶..." → "...完成"。因为 run() 是在当前线程(main)里执行的,根本不是多线程。
方式2:实现 Runnable 接口(推荐)
// 可复制运行,保存为 RunnableDemo.java
class OrderTask implements Runnable {
private String customer;
OrderTask(String customer) {
this.customer = customer;
}
@Override
public void run() {
System.out.println("为 " + customer + " 制作奶茶");
}
}
public class RunnableDemo {
public static void main(String[] args) {
Thread t1 = new Thread(new OrderTask("小明"));
Thread t2 = new Thread(new OrderTask("小红"));
t1.start();
t2.start();
}
}为什么推荐 Runnable? Java 只允许继承一个类。如果你继承了 Thread,就不能继承别的了。实现 Runnable 接口更灵活。
3. 线程安全问题——多人同时动同一个账本
这是并发编程里最核心、也最容易出 bug 的地方。
// 可复制运行,保存为 UnsafeCounter.java
class Counter {
private int count = 0;
public void increment() {
// 问题出在这里——count++ 不是一步完成的
count++; // 实际是三步:读count → 加1 → 写回count
}
public int getCount() {
return count;
}
}
public class UnsafeCounter {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
// 创建两个线程,每个加 10000 次
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) counter.increment();
});
t1.start();
t2.start();
t1.join(); // 等 t1 结束
t2.join(); // 等 t2 结束
System.out.println("最终 count: " + counter.getCount());
// 期望 20000,但很可能不是!
// 比如:17753、16321、20000……
}
}为什么不是 20000? 因为 count++ 不是原子操作:
时间 →
线程A: 读取 count=100 → 计算 101 → 写回 101
线程B: → 读取 count=100 → 计算 101 → 写回 101两个线程同时读到 100,各自加了 1,都写回 101——本该是 102,结果变成了 101。一次更新丢失了。
4. synchronized——给关键代码上锁
// 可复制运行,保存为 SafeCounter.java
class SafeCounter {
private int count = 0;
public synchronized void increment() { // synchronized 上锁
count++; // 同一时刻只有一个线程能进入这个方法
}
public int getCount() {
return count;
}
}
public class SafeCounterDemo {
public static void main(String[] args) throws InterruptedException {
SafeCounter counter = new SafeCounter();
Thread t1 = new Thread(() -> {
for (int i = 0; i < 10000; i++) counter.increment();
});
Thread t2 = new Thread(() -> {
for (int i = 0; i < 10000; i++) counter.increment();
});
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("最终 count: " + counter.getCount()); // 总是 20000
}
}synchronized 就像奶茶店的操作间门锁——一个人进去了,其他人得等。 等里面的人出来,下一个才能进。
synchronized 的其他写法
// 方法上加 synchronized
public synchronized void increment() { ... }
// 代码块上加 synchronized(更精细的控制)
public void increment() {
// 不需要同步的代码(比如日志)
synchronized (this) { // 只锁这一块
count++;
}
}5. volatile——确保一个线程的修改被其他线程看到
class RunningFlag {
// volatile 告诉 JVM:"这个变量可能被多个线程改,别做优化缓存"
volatile boolean running = true;
public void stop() {
running = false; // 另一个线程修改
}
public void work() {
while (running) {
// 执行任务
}
}
}没有 volatile 时,JVM 可能优化让线程一直读缓存里的值,无限循环。volatile 强制线程每次都从主存读。
synchronized 保证互斥 + 可见性。volatile 只保证可见性,不保证互斥。
6. 线程池——别每次都 new 线程
频繁创建和销毁线程代价很大。线程池让你复用已有的线程。
// 可复制运行,保存为 ThreadPoolDemo.java
import java.util.concurrent.*;
public class ThreadPoolDemo {
public static void main(String[] args) {
// 创建一个有 3 个线程的线程池
ExecutorService pool = Executors.newFixedThreadPool(3);
// 提交 10 个任务
for (int i = 1; i <= 10; i++) {
int orderId = i;
pool.submit(() -> {
System.out.println("订单 #" + orderId + " 由 "
+ Thread.currentThread().getName() + " 处理");
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
});
}
// 关闭线程池(不再接受新任务)
pool.shutdown();
}
}输出:
订单 #1 由 pool-1-thread-1 处理
订单 #2 由 pool-1-thread-2 处理
订单 #3 由 pool-1-thread-3 处理
订单 #4 由 pool-1-thread-1 处理 ← 线程被复用了
...7. 常见陷阱
死锁——两个人互相等对方手里的东西
// 概念示意
Object lockA = new Object();
Object lockB = new Object();
// 线程1:先拿 A,再拿 B
synchronized (lockA) {
synchronized (lockB) { ... }
}
// 线程2:先拿 B,再拿 A
synchronized (lockB) {
synchronized (lockA) { ... }
}线程1拿了 A 等 B,线程2拿了 B 等 A——两个人都卡住了。
💥 拆了它:实际跑一个死锁
// 可复制运行,保存为 DeadlockDemo.java
public class DeadlockDemo {
private static final Object spoon = new Object();
private static final Object cup = new Object();
public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (spoon) {
System.out.println("线程1: 拿到了勺子");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (cup) {
System.out.println("线程1: 拿到了杯子");
}
}
});
Thread t2 = new Thread(() -> {
synchronized (cup) { // 注意:顺序和线程1相反
System.out.println("线程2: 拿到了杯子");
try { Thread.sleep(100); } catch (Exception e) {}
synchronized (spoon) {
System.out.println("线程2: 拿到了勺子");
}
}
});
t1.start();
t2.start();
}
}程序不会结束——两个线程互相等着对方释放资源。
本章小结
- 进程 = 程序,线程 = 进程里的执行路径
- 创建线程:继承
Thread或实现Runnable(推荐后者) start()vsrun()——start()创建新线程,run()普通调用- 线程安全——多个线程同时改共享数据 → 数据不一致
synchronized——上锁,同一时刻只让一个线程进volatile——强制从主存读变量,保证可见性- 线程池——复用线程,别每次都 new
- 死锁——互相等对方释放资源,程序卡死
✅ 验收标准
完成本章后,你应该能:
- [ ] 用 Thread 和 Runnable 创建线程
- [ ] 用 synchronized 保护共享资源
- [ ] 用 volatile 保证变量可见性
- [ ] 使用线程池复用线程
- [ ] 识别和避免死锁
📌 常见卡点
start()和run()搞混——前者启线程后者普通调用- 不恰当的同步范围——太大性能差,太小不安全
- volatile 不保证原子性——复合操作仍需锁
- 线程池不 shutdown——应用不退出
🔜 现在不需要理解
Lock、ReentrantLock等显式锁——synchronized 够用CountDownLatch、CyclicBarrier同步器ConcurrentHashMap的内部分段锁机制ForkJoinPool分治框架
🧪 练习
1. 创建线程:写一个程序,创建三个线程,分别打印不同的奶茶名字(各打印 5 次),观察输出顺序。
2. 线程安全:创建一个 BankAccount 类,有 balance 字段和 withdraw(amount) 方法。用两个线程同时取钱,在不加 synchronized 的情况下观察结果,然后加上 synchronized 对比。
3. 线程池:用 Executors.newFixedThreadPool(2) 处理 6 个任务,观察线程如何被复用。