gitbook/Java并发编程实战/docs/96736.md
2022-09-03 22:05:03 +08:00

168 lines
9.6 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 37 | 设计模式模块热点问题答疑
多线程设计模式是前人解决并发问题的经验总结,当我们试图解决一个并发问题时,首选方案往往是使用匹配的设计模式,这样能避免走弯路。同时,由于大家都熟悉设计模式,所以使用设计模式还能提升方案和代码的可理解性。
在这个模块我们总共介绍了9种常见的多线程设计模式。下面我们就对这9种设计模式做个分类和总结同时也对前面各章的课后思考题做个答疑。
## 避免共享的设计模式
**Immutability模式**、**Copy-on-Write模式**和**线程本地存储模式**本质上都是**为了避免共享**只是实现手段不同而已。这3种设计模式的实现都很简单但是实现过程中有些细节还是需要格外注意的。例如**使用Immutability模式需要注意对象属性的不可变性使用Copy-on-Write模式需要注意性能问题使用线程本地存储模式需要注意异步执行问题**。所以,每篇文章最后我设置的课后思考题的目的就是提醒你注意这些细节。
[《28 | Immutability模式如何利用不变性解决并发问题](https://time.geekbang.org/column/article/92856)的课后思考题是讨论Account这个类是不是具备不可变性。这个类初看上去属于不可变对象的中规中矩实现而实质上这个实现是有问题的原因在于StringBuffer不同于StringStringBuffer不具备不可变性通过getUser()方法获取user之后是可以修改user的。一个简单的解决方案是让getUser()方法返回String对象。
```
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;
}
}
```
[《29 | Copy-on-Write模式不是延时策略的COW》](https://time.geekbang.org/column/article/93154)的课后思考题是讨论Java SDK中为什么没有提供 CopyOnWriteLinkedList。这是一个开放性的问题没有标准答案但是性能问题一定是其中一个很重要的原因毕竟完整地复制LinkedList性能开销太大了。
[《30 | 线程本地存储模式:没有共享,就没有伤害》](https://time.geekbang.org/column/article/93745)的课后思考题是在异步场景中,是否可以使用 Spring 的事务管理器。答案显然是不能的Spring 使用 ThreadLocal 来传递事务信息,因此这个事务信息是不能跨线程共享的。实际工作中有很多类库都是用 ThreadLocal 传递上下文信息的,这种场景下如果有异步操作,一定要注意上下文信息是不能跨线程共享的。
## 多线程版本IF的设计模式
**Guarded Suspension模式**和**Balking模式**都可以简单地理解为“多线程版本的if”但它们的区别在于前者会等待if条件变为真而后者则不需要等待。
Guarded Suspension模式的经典实现是使用**管程**很多初学者会简单地用线程sleep的方式实现比如[《31 | Guarded Suspension模式等待唤醒机制的规范实现》](https://time.geekbang.org/column/article/94097)的思考题就是用线程sleep方式实现的。但不推荐你使用这种方式最重要的原因是性能如果sleep的时间太长会影响响应时间sleep的时间太短会导致线程频繁地被唤醒消耗系统资源。
同时示例代码的实现也有问题由于obj不是volatile变量所以即便obj被设置了正确的值执行 `while(!p.test(obj))` 的线程也有可能看不到从而导致更长时间的sleep。
```
//获取受保护对象
T get(Predicate<T> p) {
try {
//obj的可见性无法保证
while(!p.test(obj)){
TimeUnit.SECONDS
.sleep(timeout);
}
}catch(InterruptedException e){
throw new RuntimeException(e);
}
//返回非空的受保护对象
return obj;
}
//事件通知方法
void onChanged(T obj) {
this.obj = obj;
}
```
实现Balking模式最容易忽视的就是**竞态条件问题**。比如,[《32 | Balking模式再谈线程安全的单例模式》](https://time.geekbang.org/column/article/94604)的思考题就存在竞态条件问题。因此在多线程场景中使用if语句时一定要多问自己一遍是否存在竞态条件。
```
class Test{
volatile boolean inited = false;
int count = 0;
void init(){
//存在竞态条件
if(inited){
return;
}
//有可能多个线程执行到这里
inited = true;
//计算count的值
count = calc();
}
}
```
## 三种最简单的分工模式
**Thread-Per-Message模式**、**Worker Thread模式**和**生产者-消费者模式**是三种**最简单实用的多线程分工方法**。虽说简单,但也还是有许多细节需要你多加小心和注意。
Thread-Per-Message模式在实现的时候需要注意是否存在线程的频繁创建、销毁以及是否可能导致OOM。在[《33 | Thread-Per-Message模式最简单实用的分工方法》](https://time.geekbang.org/column/article/95098)文章中最后的思考题就是关于如何快速解决OOM问题的。在高并发场景中最简单的办法其实是**限流**。当然限流方案也并不局限于解决Thread-Per-Message模式中的OOM问题。
Worker Thread模式的实现需要注意潜在的线程**死锁问题**。[《34 | Worker Thread模式如何避免重复创建线程》](https://time.geekbang.org/column/article/95525)思考题中的示例代码就存在线程死锁。有名叫vector的同学关于这道思考题的留言我觉得描述得很贴切和形象“工厂里只有一个工人他的工作就是同步地等待工厂里其他人给他提供东西然而并没有其他人他将等到天荒地老海枯石烂”因此共享线程池虽然能够提供线程池的使用效率但一定要保证一个前提那就是**任务之间没有依赖关系**。
```
ExecutorService pool = Executors
.newSingleThreadExecutor();
//提交主任务
pool.submit(() -> {
try {
//提交子任务并等待其完成,
//会导致线程死锁
String qq=pool.submit(()->"QQ").get();
System.out.println(qq);
} catch (Exception e) {
}
});
```
Java线程池本身就是一种生产者-消费者模式的实现所以大部分场景你都不需要自己实现直接使用Java的线程池就可以了。但若能自己灵活地实现生产者-消费者模式会更好,比如可以实现批量执行和分阶段提交,不过这过程中还需要注意如何优雅地终止线程,[《36 | 生产者-消费者模式:用流水线思想提高效率》](https://time.geekbang.org/column/article/96168)的思考题就是关于此的。
如何优雅地终止线程?我们在[《35 | 两阶段终止模式:如何优雅地终止线程?》](https://time.geekbang.org/column/article/95847)有过详细介绍,两阶段终止模式是一种通用的解决方案。但其实终止生产者-消费者服务还有一种更简单的方案,叫做**“毒丸”对象**。[《Java并发编程实战》](time://mall?url=https%3A%2F%2Fh5.youzan.com%2Fv2%2Fgoods%2F2758xqdzr6uuw)第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多线程设计模式》这本书来深入学习这个模块这是一本不错的并发编程入门书籍虽然重点是讲解设计模式但是也详细讲解了设计模式中涉及到的方方面面的基础知识而且深入浅出非常推荐入门的同学认真学习一下。
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。