上一篇解决了”线程怎么抢”,本篇解决”线程怎么合作”:wait/notify 通信、Lock 锁、线程池是并发进阶的三件套;再配上单例模式的线程安全演进史和两个常用设计模式,入门到中级之间的桥就搭好了。

本篇目标

读完本篇你应该能够:用 wait/notify 写出正确的生产者消费者模型;说出 Lock 相对 synchronized 的改进点;解释为什么需要线程池并用 Executors 提交任务;写出饿汉式、懒汉式、双重检查锁三种单例并讲清它们的线程安全性;理解简单工厂、工厂方法、适配器三种模式各解决什么问题。

线程通信:wait 与 notify

synchronized 保证的是互斥,但线程之间还需要协作。经典场景是生产者消费者:生产者往缓冲区放数据,满了就等;消费者取数据,空了就等;双方互相唤醒。

class Buffer {
private int data;
private boolean hasData = false;

public synchronized void produce(int value) throws InterruptedException {
while (hasData) { // 必须用 while,不能用 if
wait(); // 释放锁并挂起,等待被唤醒
}
this.data = value;
hasData = true;
System.out.println("生产: " + value);
notifyAll(); // 唤醒等待的消费者
}

public synchronized void consume() throws InterruptedException {
while (!hasData) {
wait();
}
System.out.println("消费: " + data);
hasData = false;
notifyAll(); // 唤醒等待的生产者
}
}

三个硬规则必须记牢:

  1. wait/notify/notifyAll 必须在同步块内,由锁对象调用。这三个方法属于 Object 而非 Thread,因为它们是”锁对象上的等待队列管理”,脱离锁调用会抛 IllegalMonitorStateException。
  2. wait 会释放锁,sleep 不会。这是两者最本质的区别,也是高频面试题。
  3. 判断条件用 while 不用 if。notify 随机唤醒一个等待线程,如果唤醒的是同类线程(比如已有数据时又唤醒一个生产者),if 只判断一次就会出错;while 让线程醒来后重新检查条件。多线程通信场景一律 while + notifyAll 搭配。

Lock 与 Condition:更灵活的锁

JDK 5 引入 ReentrantLock,把锁从语法变成对象:

private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();

public void produce(int value) throws InterruptedException {
lock.lock();
try {
while (hasData) {
notFull.await();
}
// ... 生产
notEmpty.signal();
} finally {
lock.unlock(); // 必须在 finally 中释放
}
}

相比 synchronized 的三点改进:lock/unlock 手动控制,可以跨方法、可以 tryLock 带超时避免死等(呼应上篇的死锁预防);一把锁可挂多个 Condition,生产者等 notFull、消费者等 notEmpty,signal 时精准唤醒对方,解决了 notifyAll 无差别唤醒的浪费;可中断、可公平,排队策略可选。代价是必须手动 unlock,忘记释放就是永久死锁——所以 finally 块是铁律。

线程池:为什么不能再”每任务一线程”

直接 new Thread 有两个硬伤:创建贵——每个线程都要向操作系统申请资源、分配栈空间(默认 1MB 左右);不可控——并发量一高,线程数暴涨,CPU 全耗在上下文切换上,最终 OOM。线程池的思路是”一次创建、反复复用”:线程执行完任务不销毁,回到池子里等下一个任务,把创建销毁的成本摊平。

JDK 5 起内置支持:

ExecutorService pool = Executors.newFixedThreadPool(2);

pool.submit(new Runnable() {
@Override
public void run() {
System.out.println("任务由 " + Thread.currentThread().getName() + " 执行");
}
});

// Callable 可以拿到返回值
Future<Integer> future = pool.submit(new Callable<Integer>() {
@Override
public Integer call() {
int sum = 0;
for (int i = 1; i <= 100; i++) {
sum += i;
}
return sum;
}
});
System.out.println(future.get()); // get() 会阻塞直到拿到结果
pool.shutdown(); // 不再接收新任务,等存量任务完成后关闭

这是创建线程的第三种方式:Runnable 无返回值,Callable 有返回值还能抛异常。需要提醒一个时代化细节:Executors 的工厂方法虽然方便,但 newFixedThreadPool 用的是无界队列(任务堆积会 OOM),newCachedThreadPool 允许近乎无上限的线程数,生产环境建议手动 new ThreadPoolExecutor,显式指定核心线程数、最大线程数、队列容量和拒绝策略,把风险暴露在参数里而不是藏在默认值里。

单例模式:一场线程安全的演进

单例保证一个类在内存中只有一个实例:私有构造 + 私有静态实例 + 公开静态获取方法。

饿汉式——类加载时就创建:

class Singleton {
private Singleton() {}
private static final Singleton instance = new Singleton();
public static Singleton getInstance() {
return instance;
}
}

天生线程安全(类加载由 JVM 保证只发生一次),缺点是用不用都先创建。

懒汉式——用到才创建,但并发下有坑:

public static Singleton getInstance() {
if (instance == null) { // 线程 1 和线程 2 可能同时通过判断
instance = new Singleton();
}
return instance;
}

两个线程同时进入 if,就会创建两个实例。加 synchronized 能修,但每次获取都加锁太重。

双重检查锁(DCL)——演进终点:

class Singleton {
private Singleton() {}
private static volatile Singleton instance; // volatile 不能省

public static Singleton getInstance() {
if (instance == null) { // 第一次检查:避免已创建后还排队
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查:排队进来的不再创建
instance = new Singleton();
}
}
}
return instance;
}
}

new Singleton() 不是原子操作,包含分配内存、初始化、赋值三步,指令重排序可能让”赋值”先于”初始化”完成,其他线程就会拿到半初始化对象——volatile 禁止重排序,正是为此而加。这段代码把前面所有并发知识串在了一起,值得逐行吃透。JDK 里 Runtime.getRuntime() 就是饿汉式单例的活例子。

工厂与适配器:两个入门设计模式

简单工厂:一个静态方法按参数创建对象,客户端不再 new 具体类。缺点是新增产品要改工厂类,违反开闭原则。

工厂方法:为每种产品配一个工厂类,工厂实现统一接口。新增产品只需加一对”产品类 + 工厂类”,不改旧代码——用加类的代价换掉改类的风险,这是设计模式”以扩展代替修改”的典型思路。

适配器:让不兼容的接口协同工作。Java GUI 里的 WindowAdapter 就是活例:WindowListener 接口有七个方法,只想处理关闭事件时继承适配器重写一个方法即可,其余空实现由适配器兜底。

GUI 事件监听机制

以 AWT 为例,事件模型三要素:事件源(被操作的组件,如窗口)、事件对象(封装这次操作的细节)、监听器(实现了监听接口的对象,注册到事件源上)。用户点关闭按钮,JVM 自动回调监听器里的方法:

Frame frame = new Frame("我的窗口");
frame.setSize(500, 400);
frame.addWindowListener(new WindowAdapter() { // 适配器 + 匿名内部类
@Override
public void windowClosing(WindowEvent e) {
System.exit(0);
}
});
frame.setVisible(true);

这套”注册—回调”机制就是观察者模式的落地:事件源维护一份监听器列表,事件发生时逐个回调,自己完全不关心监听器做了什么,双方因此解耦。它也是日后理解 Spring 事件、消息队列监听、前端 addEventListener 的思想原型。GUI 本身已被 JavaFX 和前端技术取代,但事件驱动思想长青——学它不是为了写桌面程序,而是为了领会这种”反转控制权”的编程范式。

小结

  • wait/notify 三铁律:锁对象调用、wait 释放锁、while 判条件;多线程通信用 notifyAll。
  • Lock + Condition 提供更细粒度的锁与精准唤醒,unlock 必须放 finally。
  • 线程池用复用换创建成本;Executors 快速上手,生产环境手动 new ThreadPoolExecutor。
  • 单例演进线是”饿汉安全但浪费 → 懒汉省但漏 → DCL + volatile 两全”,它是并发知识的综合考题。
  • 工厂模式以扩展代修改,适配器弥合接口差异,GUI 事件监听则是观察者模式的启蒙。