第 14 章 · 多线程

Java 课的最后一章,也是把前面所有伏笔收网的一章:第 2 章的「GC 免了手动管理」、第 11 章的「UI 冻结」、第 13 章的「一线程一连接」、操作系统课第 2、4 章的竞态与锁,全在这里会师。多线程是「共享内存 + 并发执行」的世界,麻烦只有一个来源:两个线程同时读写同一个变量。本章从复现竞态开始,以正确加锁收尾。

14.1 线程:共享内存的执行流

操作系统课第 2 章讲过理论:线程共享进程的地址空间,各有各的栈和寄存器。Java 把它做成了语言内置:

// 方式一:继承 Thread(占掉唯一的继承名额,不推荐)
class MyThread extends Thread {
    public void run() { System.out.println("线程跑起来了"); }
}
new MyThread().start();       // start 才创建线程;直接调 run() 是普通方法调用!

// 方式二:实现 Runnable(推荐:任务与线程解耦,不占继承)
Runnable task = () -> System.out.println("Lambda 版任务");
new Thread(task).start();

两个必纠误区:start() 才启动新线程,run() 只是普通调用(主线程里同步执行,这题面试年年考);setDaemon(true) 的后台线程随主线程退出而死(第 11 章「窗口关了进程还在」的解药之一:把残留线程设为守护)。

线程的生命周期和操作系统课第 2 章的五状态模型一一对应:新建 → 就绪 → 运行 → 阻塞/等待 → 终止。控制手段:sleep(ms)(睡)、join()(等某线程干完再继续)、yield()(礼貌让出,不保证)。

14.2 复现竞态:课程开始前先炸一次

操作系统课第 4 章用 C 复现过计数器丢失更新,Java 版再来一遍,这次看得更细:

class Counter {
    int count = 0;
    void increment() { count++; }        // 读-加-写,三步,不是原子的!
}

public class Race {
    public static void main(String[] args) throws Exception {
        Counter c = new Counter();
        Thread t1 = new Thread(() -> { for (int i = 0; i < 100000; i++) c.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < 100000; i++) c.increment(); });
        t1.start(); t2.start();
        t1.join(); t2.join();             // 等两个线程跑完
        System.out.println(c.count);      // 理论 200000,实际 132751(每次都不一样)
    }
}

count++ 编译成字节码是三条指令(读字段、加一、写回),两个线程交错执行就丢更新,和 C 版一模一样的病。Java 的「没有指针」没有救它,因为病灶是并发,不是指针。修法见下。

14.3 synchronized:语言内置的锁

class Counter {
    private int count = 0;
    public synchronized void increment() { count++; }   // 同步方法:锁 this
    public int get() { return count; }                   // 读也要加锁(可见性,见 14.4)
}

synchronized 是操作系统课第 4 章 mutex 的语言内置版:同一时刻只许一个线程进入。三种用法:同步方法(锁 this)、同步代码块 synchronized(lock) { ... }(锁指定对象,粒度更细)、静态同步方法(锁 Class 对象)。底层是 JVM 的监视器锁,进出自动加解锁(对比 C 的 pthread_mutex_lock/unlock 手动配对,Java 没有「忘了解锁」,这是语言级封装的红利)。锁的选择纪律:同一份共享数据,所有访问必须用同一把锁,锁了两把等于没锁。

修完计数器,再给进阶方案留个名字:AtomicInteger.incrementAndGet()(无锁 CAS 实现,操作系统课第 4 章 compare-and-swap 的官方实现),单计数器场景它比 synchronized 更快,入门阶段知道有这个选项即可。

14.4 可见性:比互斥更隐蔽的坑

有一个不写错代码就发现不了的深坑:一个线程改了变量,另一个线程可能永远看不到(JIT 把变量缓存在寄存器/缓存里了)。复现实验:

static boolean stop = false;          // 不加 volatile
new Thread(() -> { while (!stop) { } }).start();   // 子线程忙等
Thread.sleep(1000);
stop = true;                          // 主线程改,子线程死循环!

子线程读到的 stop 永远是旧值,循环出不来(开 -O2 级别的 JIT 后必现)。这是 Java 内存模型(JMM)的可见性问题,最小修复:volatile boolean stop(保证每次读都从主内存、每次写立即刷回)。务必记住第 8 章(计组 volatile)的警告在这里同样成立:volatile 只保可见性,不保原子性,volatile int count 的 count++ 照样丢更新,该加锁还得加锁。

14.5 线程协作:生产者-消费者的 Java 版

操作系统课第 4 章用信号量写生产者-消费者,Java 的语言内置版是 wait/notify(必须在 synchronized 块里调用):

class Buffer {
    private final Queue<Integer> q = new LinkedList<>();
    private final int CAP = 10;

    public synchronized void put(int v) throws InterruptedException {
        while (q.size() == CAP) wait();      // 满了等;用 while 不是 if(虚假唤醒)
        q.add(v);
        notifyAll();                          // 叫醒所有等的人
    }
    public synchronized int take() throws InterruptedException {
        while (q.isEmpty()) wait();           // 空了等
        int v = q.remove();
        notifyAll();
        return v;
    }
}

两处必须较真的细节:等待条件用 while 不用 if(醒来后条件可能又被别人抢了,while 会重新检查;虚假唤醒是规范允许的);用 notifyAll 不用 notify(notify 只叫一个,叫错了线程就全员睡死)。这段代码是面试和真实工程的交集,手写一遍胜过看十遍。实际开发更常用 ArrayBlockingQueue(put/take 内置了上面全部逻辑)或 ExecutorService 线程池(第 13 章 thread-per-connection 的升级版:池化线程、复用回收,操作系统课第 2 章「线程创建开销」的官方解法)。

14.6 死锁:最后一案

操作系统课第 5 章的四个必要条件在 Java 全部成立,复现只要两把锁:

// 线程1:synchronized(a) { synchronized(b) {...} }
// 线程2:synchronized(b) { synchronized(a) {...} }

运气好(错开)能跑,运气不好互相握着对方的锁,线程永久卡死,jstack 一看「Found one Java-level deadlock」(第 5 章开头那个输出)。预防之道也照搬:全局锁排序、缩小锁粒度、能用无锁结构(ConcurrentHashMap)就不加锁。检测工具就是 jstack/Thread.dump,它连哪两把锁互相指都标给你。

常见错误

  • 调 run() 不调 start()。没有并发,还以为多线程在跑;这是 Java 面试第一题的原因。
  • 锁粒度错配。写用 lockA、读不加锁(可见性问题);或两段代码锁不同对象却操作同一数据(等于没锁)。
  • wait/notify 配 if 和 notify。虚假唤醒 + 唤醒丢失,偶发的「卡住」;while + notifyAll 是唯一稳妥组合。
  • GUI 事件线程里做重活。第 11 章的冻结,解法 SwingWorker(后台线程 + 完成回调回 EDT)。
  • 把 static 集合当缓存不管并发。多线程写 HashMap 可能死循环(JDK 7 的著名事故)或丢数据,并发场景用 ConcurrentHashMap。

练习

  1. 动手:跑 14.2 的竞态实验十次记录结果;分别用 synchronized 方法和 AtomicInteger 修复,对比耗时,验证「无锁 CAS 更快」。
  2. 动手:跑 14.4 的可见性实验(注意加 -Xss 或多跑几次;JIT 优化需要循环跑一会儿才生效),再修 volatile 验证;最后用 volatile int 做 count++ 试试,验证「volatile 不保原子」。
  3. 实现:三个线程轮流打印 A、B、C 各五轮(经典面试题),用 wait/notify 实现;再用 ReentrantLock + Condition 的版本重写(查 API),对比两种写法。
  4. 思考:第 2 章 GC 免去了手动内存管理,本章 synchronized 免去了手动锁管理,但两者都没免去「设计并发」。结合操作系统课第 4 章「锁的粒度」和第 11 章 GUI 的 EDT 纪律,写一段话总结:语言能替你管什么,永远替不了你管什么。