Java 课的最后一章,也是把前面所有伏笔收网的一章:第 2 章的「GC 免了手动管理」、第 11 章的「UI 冻结」、第 13 章的「一线程一连接」、操作系统课第 2、4 章的竞态与锁,全在这里会师。多线程是「共享内存 + 并发执行」的世界,麻烦只有一个来源:两个线程同时读写同一个变量。本章从复现竞态开始,以正确加锁收尾。
操作系统课第 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()(礼貌让出,不保证)。
操作系统课第 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 的「没有指针」没有救它,因为病灶是并发,不是指针。修法见下。
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 更快,入门阶段知道有这个选项即可。
有一个不写错代码就发现不了的深坑:一个线程改了变量,另一个线程可能永远看不到(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++ 照样丢更新,该加锁还得加锁。
操作系统课第 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 章「线程创建开销」的官方解法)。
操作系统课第 5 章的四个必要条件在 Java 全部成立,复现只要两把锁:
// 线程1:synchronized(a) { synchronized(b) {...} }
// 线程2:synchronized(b) { synchronized(a) {...} }
运气好(错开)能跑,运气不好互相握着对方的锁,线程永久卡死,jstack 一看「Found one Java-level deadlock」(第 5 章开头那个输出)。预防之道也照搬:全局锁排序、缩小锁粒度、能用无锁结构(ConcurrentHashMap)就不加锁。检测工具就是 jstack/Thread.dump,它连哪两把锁互相指都标给你。
volatile int 做 count++ 试试,验证「volatile 不保原子」。