并发设计模式不是「炫技」,而是把并发难题收敛为可复用的解决方案:数据怎么共享、任务怎么协作、状态怎么保护。本文从经典的生产者-消费者讲起,覆盖工作窃取、不可变对象、读写分离、双重检查锁定等模式,每个模式都给出适用场景、代码骨架与 JUC 的落地方式,帮助你在真实业务中选对模式。
一、并发设计模式概览
1.1 为什么要并发模式
并发编程的三大难题:可见性、原子性、有序性。设计模式把这些难题包装成固定的协作形态,让并发代码「好写、好读、好维护」。
| 模式 | 解决的核心问题 | 典型应用 |
|---|---|---|
| 生产者-消费者 | 解耦生产与消费速率 | 消息队列、异步任务 |
| 工作窃取 | 负载均衡 + 利用率 | ForkJoinPool |
| 不可变对象 | 天然线程安全 | 值对象、缓存 |
| 读写分离 | 读多写少的锁开销 | 缓存、配置 |
| 双重检查锁定 | 延迟初始化的安全写法 | 单例 |
| Copy-on-Write | 读多写极少的并发容器 | 事件监听器列表 |
选型思路:
共享数据 → 优先不可变 / 线程封闭
协作分工 → 生产者-消费者 / 工作窃取
读多写少 → 读写锁 / Copy-on-Write
延迟创建 → 双重检查锁定 / Holder 类
一句话总结: 并发模式是把「共享、协作、保护」三大问题做成固定配方;先回答「数据形态」和「读写比例」,再选模式,就不会滥用锁。
二、生产者-消费者模式
2.1 核心思想
生产者与消费者之间用阻塞队列解耦:生产者只管放,消费者只管取,速率不一致时由队列缓冲。
// JUC 落地:用 BlockingQueue 免手写同步
BlockingQueue<Order> queue = new ArrayBlockingQueue<>(1000);
// 生产者:提交订单
Runnable producer = () -> {
try { queue.put(new Order("A-1")); }
catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
// 消费者:处理订单
Runnable consumer = () -> {
try {
Order order = queue.take(); // 队列空则阻塞等待
process(order);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};
2.2 队列选型
| 队列 | 特性 | 适用 |
|---|---|---|
ArrayBlockingQueue | 有界数组,公平可配 | 有界缓冲,防止堆积 |
LinkedBlockingQueue | 可选有界/无界链表 | 吞吐高,默认无界慎用 |
SynchronousQueue | 不缓冲,直接交接 | 严格一对一交接 |
PriorityBlockingQueue | 按优先级出队 | 优先级任务 |
DelayQueue | 延迟到期才出队 | 定时任务、重试 |
生产关键点:
有界队列 + 拒绝策略,防止生产者无限堆积
消费者失败要有重试/死信队列,避免丢失
用 offer/poll 配合超时,而不是裸 put/take 死等
一句话总结: 生产者-消费者的核心是「阻塞队列 + 有界 + 超时」;用 ArrayBlockingQueue 做有界缓冲、用 offer 带超时,就能优雅应对峰值与背压。
三、工作窃取(Work Stealing)
3.1 原理
每个工作线程有自己的双端队列,任务不足时偷取其他线程队尾的任务,保持所有线程忙碌,最大化利用率。
ForkJoinPool 的工作窃取:
1. 大任务 fork 成子任务,放入自己队列
2. 线程空闲时从别人队列 steal 任务
3. 任务合并用 join,形成递归分治
对比普通线程池:
固定队列 → 空闲线程干等,任务堆积某队列
工作窃取 → 空闲线程主动找活,利用率高
// ForkJoinPool 示例:并行求和
class SumTask extends RecursiveTask<Long> {
private final long[] arr; private final int lo, hi;
static final int THRESHOLD = 1000;
@Override
protected Long compute() {
if (hi - lo <= THRESHOLD) {
long sum = 0;
for (int i = lo; i < hi; i++) sum += arr[i];
return sum;
}
int mid = (lo + hi) >>> 1;
SumTask left = new SumTask(arr, lo, mid);
SumTask right = new SumTask(arr, mid, hi);
left.fork(); // 压入队列
return right.compute() + left.join(); // 合并
}
}
3.2 使用注意
陷阱:
1. 任务太小 → fork 开销超过计算,设阈值
2. 共享可变状态 → 子任务互相踩踏,要保持子任务独立
3. join 顺序 → 先 fork 后 join,避免栈深爆炸
并行流 parallelStream() 底层就是 ForkJoinPool.commonPool
一句话总结: 工作窃取用「双端队列 + 空闲偷取」提升利用率,
RecursiveTask是递归分治的标准骨架;阈值设得太小、共享状态不隔离是两个最常见的坑。
四、不可变对象模式
4.1 为什么不可变最安全
不可变对象创建后状态不再变化,因此不存在可见性、原子性问题——任何线程看到的就是完整一致的状态。
// 不可变对象:final 字段 + 无 setter
public final class Point {
private final int x;
private final int y;
public Point(int x, int y) { this.x = x; this.y = y; }
public int x() { return x; }
public int y() { return y; }
// 更新返回新对象,而不是修改自身
public Point move(int dx, int dy) {
return new Point(x + dx, y + dy);
}
}
4.2 不可变与性能的权衡
| 维度 | 可变对象 | 不可变对象 |
|---|---|---|
| 线程安全 | 需同步 | 天然安全 |
| 更新开销 | 原地改,低 | 复制新对象,高 |
| 缓存友好 | 一般 | 可安全共享 |
| 适用 | 高频小范围修改 | 配置、值对象、缓存 key |
实践组合:
更新频繁的少量字段 → 可变 + 锁
低频但被广泛共享 → 不可变,无锁
大量只读引用 → 不可变 + Copy-on-Write 容器
一句话总结: 不可变对象是「零成本线程安全」——final 字段 + 无 setter + 更新返回新对象;代价是频繁更新时的复制开销,适合共享多、修改少的数据。
五、读写分离(Read-Write Lock)
5.1 读多写少用读写锁
ReentrantReadWriteLock 让读读并发、写写互斥、读写互斥:读线程之间不阻塞,写线程独占。
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
Map<String, Config> configs = new HashMap<>();
// 读路径:多个读线程可并发
Config get(String key) {
rwLock.readLock().lock();
try { return configs.get(key); }
finally { rwLock.readLock().unlock(); }
}
// 写路径:独占,写时读也阻塞
void put(String key, Config c) {
rwLock.writeLock().lock();
try { configs.put(key, c); }
finally { rwLock.writeLock().unlock(); }
}
5.2 读写锁 vs StampedLock
| 对比 | ReentrantReadWriteLock | StampedLock |
|---|---|---|
| 读并发 | 支持 | 支持,另有乐观读 |
| 乐观读 | 无 | tryOptimisticRead 无锁读 |
| 重入 | 支持 | 不支持重入 |
| 适用 | 通用读多写少 | 纯读比例极高的热点 |
// StampedLock 乐观读:读时不加锁,validate 校验版本
StampedLock lock = new StampedLock();
long stamp = lock.tryOptimisticRead();
int v = value; // 乐观读
if (!lock.validate(stamp)) { // 期间被写过 → 升级悲观读
stamp = lock.readLock();
try { v = value; }
finally { lock.unlockRead(stamp); }
}
一句话总结: 读写分离用「读锁并发、写锁独占」服务读多写少;极致读场景用 StampedLock 乐观读,但要注意它不可重入、也更容易用错。
六、双重检查锁定(Double-Checked Locking)
6.1 为什么朴素写法是错的
延迟初始化单例的朴素写法在并发下有可见性问题——对象引用可见时,其内部字段可能尚未初始化完成。
// 错误示范:不安全,可能读到半初始化对象
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 第一次检查
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查
instance = new Singleton();
}
}
}
return instance;
}
6.2 正确姿势:volatile + DCL
// 正确:volatile 禁止指令重排,保证初始化可见
private static volatile Singleton instance;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
6.3 更简单的替代:静态内部类
// 推荐:利用类加载机制保证线程安全,无需锁与 volatile
public class Singleton {
private Singleton() {}
private static class Holder {
static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return Holder.INSTANCE;
}
}
一句话总结: DCL 必须配
volatile才安全;能用静态内部类(Holder)就不用 DCL——同样懒加载、天然线程安全、代码更简单。
七、其他常用并发模式
7.1 Copy-on-Write 容器
读操作不加锁直接读副本,写操作复制新数组再替换引用。适合读极多、写极少的场景,如事件监听器列表。
// 事件监听器列表:遍历时读者拿到稳定快照
CopyOnWriteArrayList<Listener> listeners = new CopyOnWriteArrayList<>();
listeners.add(listener); // 写:复制数组,成本 O(n)
for (Listener l : listeners) { // 读:无锁,拿的是快照
l.onEvent(event);
}
7.2 Future 与承诺(Promise)
// 用 CompletableFuture 做"异步承诺"
CompletableFuture<Price> priceFuture =
CompletableFuture.supplyAsync(() -> queryPrice("BTC"))
.orTimeout(3, TimeUnit.SECONDS); // 超时即失败
priceFuture.thenAccept(p -> System.out.println("价格=" + p));
7.3 线程封闭与 ThreadLocal
把可变状态封闭在线程内,避免共享。注意与虚拟线程时代的 ScopedValue 演进方向。
| 模式 | 一句话 | 适用 |
|---|---|---|
| Copy-on-Write | 读写分离的极值版 | 读极多写极少 |
| Future/承诺 | 异步结果占位符 | 并行子任务编排 |
| 线程封闭 | 状态不跨线程 | 每线程独立缓冲 |
| 反应式背压 | 速率控制 | 流式处理 |
一句话总结: Copy-on-Write 用「快照读」换写开销;Future/承诺负责异步编排;线程封闭避免共享——模式是工具箱,按数据特征取用。
八、实战陷阱清单
| 陷阱 | 现象 | 对策 |
|---|---|---|
| DCL 缺 volatile | 偶发半初始化 | 加 volatile 或改 Holder |
| 无界队列堆积 | 内存上涨、消费滞后 | 用有界队列 + 拒绝策略 |
| fork 阈值太小 | 性能反降 | 压测设定阈值 |
| 读写锁写饥饿 | 写线程长时间等待 | 用公平锁或 StampedLock |
| Copy-on-Write 滥用 | 写频繁复制爆炸 | 仅用于读极多写极少 |
| ThreadLocal 泄漏 | 内存缓慢上涨 | 用完 remove |
| 子任务共享可变状态 | 数据错乱 | 子任务保持独立 |
九、总结
| 模式 | 一句话 | JUC 落地 |
|---|---|---|
| 生产者-消费者 | 阻塞队列解耦 | BlockingQueue |
| 工作窃取 | 空闲线程偷活 | ForkJoinPool |
| 不可变对象 | 零成本安全 | final + 复制 |
| 读写分离 | 读并发写独占 | ReadWriteLock / StampedLock |
| 双重检查锁定 | 延迟初始化 | volatile + Holder |
| Copy-on-Write | 快照读 | CopyOnWriteArrayList |
一句话记住:并发设计模式的核心是把「共享、协作、保护」做成固定配方。遇到并发问题先问三件事——数据改不改、读写比例、协作方式——再从这张表里挑答案,远比自己发明锁要稳。
延伸阅读
- Java 并发编程与 JUC 包完全指南 — 本文模式对应的 JUC 底层实现
- 虚拟线程与结构化并发实战 — 并发模式的虚拟线程时代演进
- Java 17+ 核心语法深度指南 — 记录类对不可变对象模式的简化
- Java 性能优化:从代码到 JVM 的全链路调优 — 并发性能与锁开销的调优
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。