设计模式与线程池:享元模式共享线程、命令模式封装任务、策略模式选择调度
线程池这个东西,说白了就是「复用线程」的容器。我刚开始做并发编程时,总觉得每次 new Thread() 挺爽的,直到线上服务被频繁创建线程搞到 OOM……嗯,从那以后我才真正理解线程池的价值。
但今天我想聊的不是线程池 API 怎么用,而是它背后藏着的三个设计模式:享元模式、命令模式、策略模式。你想想看,一个线程池能同时把这三个模式揉在一起,这本身就是个绝佳的教学案例。
1. 享元模式:线程池的核心思想
享元模式(Flyweight Pattern)的核心就一句话:共享细粒度对象,避免重复创建。线程池里的工作线程,就是典型的享元对象。
我习惯把线程池里的线程比作「出租车队」。每辆车(线程)可以拉不同的乘客(任务),但车本身是共享的。你不需要为每个乘客买一辆新车,对吧?
关键点:享元模式区分「内部状态」和「外部状态」。在线程池中,线程的 ID、生命周期是内部状态;而线程当前执行的任务是外部状态,由调用方传入。
// Java 中的享元线程——ThreadPoolExecutor 内部的工作线程
// 这些 Worker 对象被池化复用,不会频繁创建销毁
private final class Worker extends AbstractQueuedSynchronizer implements Runnable {
final Thread thread; // 内部状态:线程本身
Runnable firstTask; // 外部状态:当前任务(可替换)
Worker(Runnable firstTask) {
setState(-1); // inhibit interrupts until runWorker
this.firstTask = firstTask;
this.thread = getThreadFactory().newThread(this);
}
public void run() {
runWorker(this); // 循环从队列取任务执行
}
}
我在项目中遇到过一个问题:某系统需要处理大量短连接请求,如果每个请求都 new 一个线程,系统很快就扛不住了。后来改用固定大小线程池,吞吐量提升了 5 倍。这就是享元模式最直接的收益——减少资源消耗。
我的建议:线程数不是越多越好。CPU 密集型任务,线程数 = CPU 核心数 + 1 就够了;IO 密集型可以适当多些,但也要考虑上下文切换的开销。
2. 命令模式:把任务封装成对象
命令模式(Command Pattern)把「请求」封装成对象,让调用方和接收方解耦。在线程池里,每个 Runnable 或 Callable 就是一个命令对象。
你想想看,线程池的 submit() 方法接收的是什么?是一个任务对象。它不关心这个任务具体做什么,只负责把它丢到队列里,让空闲线程去执行。这就是命令模式的精髓——请求的发起者和执行者分离。
// 命令模式在线程池中的体现
// 调用方:提交任务
ExecutorService executor = Executors.newFixedThreadPool(4);
Future<String> future = executor.submit(new Callable<String>() {
@Override
public String call() throws Exception {
// 这是命令的具体执行逻辑
return "任务完成";
}
});
// 接收方:线程池内部
// 命令被封装成 FutureTask 对象,放入工作队列
// 工作线程从队列取出 FutureTask,执行其 run() 方法
我记得有一次做批处理系统,需要支持任务的重试、取消、超时控制。如果直接用裸线程,这些功能都得自己手写。但用线程池 + Future 模式,命令对象天然支持这些操作——因为命令本身就是一个可管理的对象。
我曾经踩过的坑:命令对象里如果持有大对象引用,会导致内存泄漏。因为任务执行完后,线程池可能还持有这个命令对象的引用(比如在队列里等待)。所以任务对象最好设计成轻量级的,或者执行完后主动释放资源。
3. 策略模式:调度算法的选择
策略模式(Strategy Pattern)定义一系列算法,把它们封装起来,并且可以互相替换。线程池的拒绝策略(RejectedExecutionHandler)就是最典型的例子。
当线程池满了,新任务怎么办?Java 提供了四种内置策略:
| 策略名称 | 行为 | 适用场景 |
|---|---|---|
| AbortPolicy | 直接抛异常 | 关键任务,不能丢失 |
| CallerRunsPolicy | 调用者线程自己执行 | 降低任务提交速度 |
| DiscardPolicy | 默默丢弃 | 不重要的任务 |
| DiscardOldestPolicy | 丢弃队列中最旧的任务 | 消息类场景,新任务更重要 |
我习惯在项目中自定义策略。比如有一个日志处理系统,我不希望因为线程池满了就丢日志,但也不想阻塞主流程。于是我写了一个策略:把任务暂存到本地文件,等线程池有空了再加载回来。这就是策略模式的灵活性——你可以随时替换算法,而不影响线程池的其他部分。
// 自定义拒绝策略——策略模式的典型应用
public class FileBackupPolicy implements RejectedExecutionHandler {
private static final String BACKUP_DIR = "/tmp/task_backup/";
@Override
public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
// 将任务序列化到文件
try (ObjectOutputStream oos = new ObjectOutputStream(
new FileOutputStream(BACKUP_DIR + UUID.randomUUID() + ".task"))) {
oos.writeObject(r);
System.out.println("任务已备份到文件");
} catch (Exception e) {
// 如果文件备份也失败,才抛异常
throw new RejectedExecutionException("备份失败", e);
}
}
}
// 使用自定义策略
ThreadPoolExecutor executor = new ThreadPoolExecutor(
2, 4, 60, TimeUnit.SECONDS,
new LinkedBlockingQueue<>(10),
new FileBackupPolicy() // 注入策略
);
4. 三个模式如何协同工作?
这三个模式在线程池里不是孤立的,它们配合得天衣无缝:
- 享元模式提供「资源池」,让线程可复用
- 命令模式提供「任务封装」,让任务可排队、可管理
- 策略模式提供「调度决策」,让线程池在边界情况下有章可循
说白了,线程池就是一个「享元工厂 + 命令队列 + 策略注入」的组合体。你想想看,如果没有享元模式,线程频繁创建销毁,性能崩了;如果没有命令模式,任务无法排队和取消;如果没有策略模式,线程池满了就只能干瞪眼。
核心总结:设计模式不是孤立存在的理论,它们在实际框架中往往是「组合拳」。学会识别这些模式,你就能看懂框架的设计意图,甚至自己写出高质量的并发组件。
实践建议:如果你要自己实现一个简单的线程池,不妨从这三个模式入手。先写一个享元模式的线程复用框架,再引入命令模式封装任务,最后加上策略模式处理边界情况。一步步来,你会发现设计模式其实就在身边。