Skip to content

第14章:并发编程基础

这一章是 Vol 1 最难的一章。不用一次全看懂——先理解"为什么"和"基本概念",剩下的在实践中慢慢消化。

📌 代码说明:标注"可复制运行"的代码块包含完整的 main 方法。没标注的是概念示意片段。

一个故事开头

你的奶茶店生意火爆。现在只有一个店员(小张),他一次只能做一杯奶茶。

来了 10 个客人,小张先做第 1 杯,再做第 2 杯……第 10 杯。最后一个人等了 20 分钟。

如果雇 3 个店员呢?小张做第 1 杯、小李做第 2 杯、小王做第 3 杯——三个人同时干活,总时间缩短到原来的三分之一。

这就是并发(Concurrency)——让多个任务"同时"执行,提高效率。

java
// 单线程:一个人干所有事
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 类

java
// 可复制运行,保存为 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()——看看发生了什么。

java
t1.run();   // ❌ 这是普通方法调用——按顺序执行,不是并发
t2.run();   // 要等 t1.run 执行完才开始

输出变成了顺序的:"波波奶茶..." → "...完成" → "柠檬茶..." → "...完成"。因为 run() 是在当前线程(main)里执行的,根本不是多线程。

方式2:实现 Runnable 接口(推荐)

java
// 可复制运行,保存为 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 的地方。

java
// 可复制运行,保存为 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——给关键代码上锁

java
// 可复制运行,保存为 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 的其他写法

java
// 方法上加 synchronized
public synchronized void increment() { ... }

// 代码块上加 synchronized(更精细的控制)
public void increment() {
    // 不需要同步的代码(比如日志)
    synchronized (this) {  // 只锁这一块
        count++;
    }
}

5. volatile——确保一个线程的修改被其他线程看到

java
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 线程

频繁创建和销毁线程代价很大。线程池让你复用已有的线程。

java
// 可复制运行,保存为 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. 常见陷阱

死锁——两个人互相等对方手里的东西

java
// 概念示意
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——两个人都卡住了。

💥 拆了它:实际跑一个死锁

java
// 可复制运行,保存为 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();
    }
}

程序不会结束——两个线程互相等着对方释放资源。


本章小结

  1. 进程 = 程序,线程 = 进程里的执行路径
  2. 创建线程:继承 Thread 或实现 Runnable(推荐后者)
  3. start() vs run()——start() 创建新线程,run() 普通调用
  4. 线程安全——多个线程同时改共享数据 → 数据不一致
  5. synchronized——上锁,同一时刻只让一个线程进
  6. volatile——强制从主存读变量,保证可见性
  7. 线程池——复用线程,别每次都 new
  8. 死锁——互相等对方释放资源,程序卡死

✅ 验收标准

完成本章后,你应该能:

  • [ ] 用 Thread 和 Runnable 创建线程
  • [ ] 用 synchronized 保护共享资源
  • [ ] 用 volatile 保证变量可见性
  • [ ] 使用线程池复用线程
  • [ ] 识别和避免死锁

📌 常见卡点

  • start()run() 搞混——前者启线程后者普通调用
  • 不恰当的同步范围——太大性能差,太小不安全
  • volatile 不保证原子性——复合操作仍需锁
  • 线程池不 shutdown——应用不退出

🔜 现在不需要理解

  • LockReentrantLock 等显式锁——synchronized 够用
  • CountDownLatchCyclicBarrier 同步器
  • ConcurrentHashMap 的内部分段锁机制
  • ForkJoinPool 分治框架


🧪 练习

1. 创建线程:写一个程序,创建三个线程,分别打印不同的奶茶名字(各打印 5 次),观察输出顺序。

2. 线程安全:创建一个 BankAccount 类,有 balance 字段和 withdraw(amount) 方法。用两个线程同时取钱,在不加 synchronized 的情况下观察结果,然后加上 synchronized 对比。

3. 线程池:用 Executors.newFixedThreadPool(2) 处理 6 个任务,观察线程如何被复用。


下一篇

第15章 I/O 与文件

用 ❤️ 构建 | Software Systems Atlas