并发安全的问题,层出不穷,实际工作中有时一不小心就会遇到。多线程设计模式是前人解决并发问题的经验总结,当我们试图解决一个并发问题时,首选方案往往是使用匹配的设计模式,这样能避免走弯路。同时,由于大家都熟悉设计模式,所以使用设计模式还能提升方案和代码的可理解性。在回顾常用并发设计模式之前,不妨先思考下 导致并发安全问题的根源是什么,或者说,是什么原因导致 我们的并发程序存在安全隐患?线程安全问题,归根到底一句话:在多线程之间修改共享数据引起的。一个是不共享(避免共享),没有数据共享,就没有伤害;一个是共享但加锁,采用互斥锁、加锁方式来保护资源。
从这个两个思路出发,前人总结了许多并发程序的设计“套路”;
近月,梳理介绍了 9 种常见的多线程设计模式。下面我们就对这 9 种设计模式做个分类和总结。避免共享的设计模式
Immutability 不变性模式、Copy-on-Write 写时复制和线程本地存储模式本质上都是为了避免共享,只是实现手段不同而已。这 3 种设计模式的实现都很简单,但是实现过程中有些细节还是需要格外注意的。
例如,Immutability 不变性的实现,并非简单使用final修饰就万事大吉,Immutability 需要注意对象属性的不可变性;
下面的代码,看上去实现了 Immutability 不变性模式,类和属性都被final修饰,实质上这个实现是有问题的,原因在于 StringBuffer 不同于 String,StringBuffer 不具备不可变性,通过 getUser() 方法获取 user 之后,是可以修改 user 的。
public final class Account{
private final
StringBuffer user;
public Account(String user){
this.user =
new StringBuffer(user);
}
//返回的StringBuffer并不具备不可变性
public StringBuffer getUser(){
return this.user;
}
public String toString(){
return "user"+user;
}
}
一个简单的解决方案是让 getUser() 方法返回 String 对象。
Immutability 不变模式的完备实现、和注意事项,可参考 并发设计 | Immutability模式:解决并发安全问题 。
Immutability 对象由于不可变,每次属性改变时,都需要创建一个新的对象,就像 java.lang.String的实现一样。这本质上,就是 Copy-on-Write 写时复制的思想。Copy-on-Write 写时复制的思想,应用广泛;通过对修改的数据,建立副本,来实现读取和修改数据的分离。Copy-on-Write 体现的是一种延时策略,只有在需要修改的时候才复制,然后再替换原数据;对于读取数据的请求来说,是完全无感知的,也没有加锁等待的成本。JDK 中也提供了 CopyOnWrite 容器,它们是CopyOnWriteArrayList和CopyOnWriteArraySet。使用时,需要注意内存开销问题,因为每次修改都需要复制一个新的数组出来,适用于读多写少的场景。线程本地存储,Java SDK中提供的类就是 ThreadLocal,能将变量与线程绑定,使每个线程使用自己的变量“副本”,是避免共享来解决并发问题的体现。
Java 的 ThreadLocal 实现应该称得上深思熟虑了,使用Thread类中的ThreadLocalMap来存储本地变量,不过即便如此深思熟虑,还是不能百分百地避免内存泄露问题,反而 ThreadLocal 使用不当,将会错误百出。
常见的就是,变量副本不能跨线程传递,也就是 ThreadLocal 在线程绑定的变量副本,不能在异步线程获取。并发设计模式 | ThreadLocal线程本地存储模式 思考题:在spring事务中,不能执行异步操作,因为 ThreadLocal 不能跨线程传递。
多线程版本 IF 的设计模式
Guarded Suspension 模式和 Balking 模式都可以简单地理解为“多线程版本的 if”,但它们的区别在于前者会等待 if 条件变为真,而后者则不需要等待。
Guarded Suspension 模式,本质就是在多线程环境,当某个条件不满足时,进入等待wait,实现wait等待,常见方式是object.wait()或利用Lock。
Guarded Suspension 模式的经典实现是使用管程,也可以简单地用线程 sleep 的方式实现,但不推荐,一是耗性能,二是sleep的时长不好控制。
Balking模式中,对象本身拥有状态,该对象只有在自身状态合适时才会执行处理,否则便不会执行处理。我们首先将对象的合适状态表示为守护条件。然后,在执行处理之前,检查守护条件是否成立。只有当守护条件成立时,对象才会继续执行处理;如果守护条件不成立,则不执行处理,立即从方法中返回。Balking 体现的是当不需要执行时,立刻返回的思想。三种简单的分工模式
Thread-Per-Message 模式、Worker Thread 模式和生产者-消费者模式是三种最简单实用的多线程分工方法。虽说简单,但也还是有许多细节需要你多加小心和注意。
Thread-Per-Message 模式,是Tomcat、Jetty等容器的实现方式,每个请求都用一个线程来处理,业界称为“每请求每线程”。该模式,逻辑和代码实现都很简单,但是需要注意创建线程的成本。
Worker Thread 模式
Worker Thread 模式,类比于工厂车间的工人,对于待执行的任务,由若干个 Worker 去完成,每个 Worker 每完成一个任务,就去任务池中取新的任务来执行。这个工作场景,对于Java SDK中的实现,就是线程池。newFixedThreadPool(5); 就相当于初始化了5个干活工人,所有待处理的任务通过submit后加入“任务池”中(BlockingQueue),然后由 Worker 处理。
生产者-消费者模式,在并发场景也经常使用。
我们实现生产者-消费者模式,通常使用一个同步容器来做媒介,实现生产端和消费端的隔离。
并发编程单机环境,同步容器通常选择 BlockingQueue 阻塞队列;在分布式环境,可以借助各类 MQ 消息队列 实现。
Java 线程池本身就是一种生产者-消费者模式的实现,所以大部分场景你都不需要自己实现,直接使用 Java 的线程池就可以了。但有些创建,还需要根据业务特点进一步斟酌,如 Java线程(中):创建多少线程才是合适的?
我们在 并发设计模式 | 两阶段终止模式:如何优雅地终止线程? 有过详细介绍,两阶段终止模式是一种通用的解决方案。
但其实终止生产者-消费者服务还有一种更简单的方案,叫做“毒丸”对象。《Java 并发编程实战》第 7 章的 7.2.3 节对“毒丸”对象有过详细的介绍。简单来讲,“毒丸”对象是生产者生产的一条特殊任务(可以理解为是发给消费者的终止指令),然后当消费者线程读到“毒丸”对象时,会立即终止自身的执行。
下面是用“毒丸”对象终止写日志线程的具体实现,整体的实现过程还是很简单的:类 Logger 中声明了一个“毒丸”对象 poisonPill ,当消费者线程从阻塞队列 bq 中取出一条 LogMsg 后,先判断是否是“毒丸”对象,如果是,则 break while 循环,从而终止自己的执行。
class Logger {
final LogMsg poisonPill =
new LogMsg(LEVEL.ERROR, "");
final BlockingQueue<LogMsg> bq
= new BlockingQueue<>();
ExecutorService es =
Executors.newFixedThreadPool(1);
void start(){
File file=File.createTempFile(
"foo", ".log");
final FileWriter writer=
new FileWriter(file);
this.es.execute(()->{
try {
while (true) {
LogMsg log = bq.poll(
5, TimeUnit.SECONDS);
if(poisonPill.equals(logMsg)){
break;
}
}
} catch(Exception e){
} finally {
try {
writer.flush();
writer.close();
}catch(IOException e){}
}
});
}
public void stop() {
bq.add(poisonPill);
es.shutdown();
}
}
生产者-消费者服务,是通过队列中的消息进行通信的;队列中的消息,除了可以是业务产生的业务数据,还可以是特殊的控制指令;
“毒丸”对象,可以理解为是发给消费者的终止指令,在消费者读取到该指令时,告知消费者进行终止。总结
到此,9个 “并发设计模式” 就告一段落了,多线程的设计模式当然不止我们提到的这 9 种,更全面的内容可阅读《图解 Java 多线程设计模式》。推荐阅读
面试题拷问Java线程池
吃透线程池ThreadPoolExecutor源码
手写简易版Java线程池
FutureTask源码解析
手写FutureTask
AtomicXxxx原子类全解析
ReentrantLock之Condition源码
细聊ReentrantLock之Condition
本文首发于 公众号 架构道与术(ToBeArchitecturer),欢迎关注、学习更多干货~
推荐阅读
并发设计模式 | 生产者-消费者模式,流水线思想提高效率
并发设计模式 | 两阶段终止模式:如何优雅地终止线程?
并发设计模式 | Worker Thread模式:如何避免重复创建线程?
并发设计模式 | Thread-Per-Message每请求每线程
并发设计模式 | Balking模式:"你不需要,就算了"
并发设计模式 | Guarded Suspension模式:等待唤醒机制的规范实现
并发设计模式 | ThreadLocal线程本地存储模式
Copy-on-Write模式:不是延时策略的COW
并发设计 | Immutability模式:解决并发安全问题
27 | 并发工具类-踩坑热点问题盘点
26 | Fork/Join:单机版的MapReduce
25 | CompletionService:批量执行异步任务
24 | CompletableFuture:Java异步编程
23 | Future:获取线程的执行结果
22 | Executor与线程池:如何创建正确的线程池?
21 | 并发工具:无锁原子类
20 | 并发容器:那些需要我们填的“坑”
19 | CountDownLatch和CyclicBarrier让多线程步调一致
18 | 比读写锁更快的StampedLock
17 | ReadWriteLock:如何快速实现一个完备的缓存?
16 | 用Semaphore实现一个限流器
15 | Lock和Condition(下):Dubbo如何用管程实现异步转同步?
14 | 并发编程之Lock和Condition
如果大家觉得这篇文章有所收获, 希望大家帮忙转发,分享和“在看”. 感谢!