多线程设计模式是前人解决并发问题的经验总结,当我们试图解决一个并发问题时,首选方案往往是使用匹配的设计模式,这样能避免走弯路。同时,由于大家都熟悉设计模式,所以使用设计模式还能提升方案和代码的可理解性。

在这个模块,我们总共介绍了 9 种常见的多线程设计模式。下面我们就对这 9 种设计模式做个分类和总结,同时也对前面各章的课后思考题做个答疑。

避免共享的设计模式

Immutability 模式Copy-on-Write 模式线程本地存储模式本质上都是为了避免共享,只是实现手段不同而已。这 3 种设计模式的实现都很简单,但是实现过程中有些细节还是需要格外注意的。例如,使用 Immutability 模式需要注意对象属性的不可变性,使用 Copy-on-Write 模式需要注意性能问题,使用线程本地存储模式需要注意异步执行问题。所以,每篇文章最后我设置的课后思考题的目的就是提醒你注意这些细节。

《28 | Immutability 模式:如何利用不变性解决并发问题?》的课后思考题是讨论 Account 这个类是不是具备不可变性。这个类初看上去属于不可变对象的中规中矩实现,而实质上这个实现是有问题的,原因在于 StringBuffer 不同于 String,StringBuffer 不具备不可变性,通过 getUser() 方法获取 user 之后,是可以修改 user 的。一个简单的解决方案是让 getUser() 方法返回 String 对象。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

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》的课后思考题是讨论 Java SDK 中为什么没有提供 CopyOnWriteLinkedList。这是一个开放性的问题,没有标准答案,但是性能问题一定是其中一个很重要的原因,毕竟完整地复制 LinkedList 性能开销太大了。

《30 | 线程本地存储模式:没有共享,就没有伤害》的课后思考题是在异步场景中,是否可以使用 Spring 的事务管理器。答案显然是不能的,Spring 使用 ThreadLocal 来传递事务信息,因此这个事务信息是不能跨线程共享的。实际工作中有很多类库都是用 ThreadLocal 传递上下文信息的,这种场景下如果有异步操作,一定要注意上下文信息是不能跨线程共享的。

多线程版本 IF 的设计模式

Guarded Suspension 模式Balking 模式都可以简单地理解为“多线程版本的 if”,但它们的区别在于前者会等待 if 条件变为真,而后者则不需要等待。

Guarded Suspension 模式的经典实现是使用管程,很多初学者会简单地用线程 sleep 的方式实现,比如《31 | Guarded Suspension 模式:等待唤醒机制的规范实现》的思考题就是用线程 sleep 方式实现的。但不推荐你使用这种方式,最重要的原因是性能,如果 sleep 的时间太长,会影响响应时间;sleep 的时间太短,会导致线程频繁地被唤醒,消耗系统资源。

同时,示例代码的实现也有问题:由于 obj 不是 volatile 变量,所以即便 obj 被设置了正确的值,执行 while(!p.test(obj)) 的线程也有可能看不到,从而导致更长时间的 sleep。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36

// 获取受保护对象  

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 模式:再谈线程安全的单例模式》的思考题就存在竞态条件问题。因此,在多线程场景中使用 if 语句时,一定要多问自己一遍:是否存在竞态条件。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28

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 模式:最简单实用的分工方法》文章中,最后的思考题就是关于如何快速解决 OOM 问题的。在高并发场景中,最简单的办法其实是限流。当然,限流方案也并不局限于解决 Thread-Per-Message 模式中的 OOM 问题。

Worker Thread 模式的实现,需要注意潜在的线程死锁问题《34 | Worker Thread 模式:如何避免重复创建线程?》思考题中的示例代码就存在线程死锁。有名叫 vector 的同学关于这道思考题的留言,我觉得描述得很贴切和形象:“工厂里只有一个工人,他的工作就是同步地等待工厂里其他人给他提供东西,然而并没有其他人,他将等到天荒地老,海枯石烂!”因此,共享线程池虽然能够提供线程池的使用效率,但一定要保证一个前提,那就是:任务之间没有依赖关系

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

ExecutorService pool = Executors

  .newSingleThreadExecutor();

// 提交主任务

pool.submit(() -> {

  try {

    // 提交子任务并等待其完成,

    // 会导致线程死锁

    String qq=pool.submit(()->"QQ").get();

    System.out.println(qq);

  } catch (Exception e) {

  }

});

Java 线程池本身就是一种生产者 - 消费者模式的实现,所以大部分场景你都不需要自己实现,直接使用 Java 的线程池就可以了。但若能自己灵活地实现生产者 - 消费者模式会更好,比如可以实现批量执行和分阶段提交,不过这过程中还需要注意如何优雅地终止线程,《36 | 生产者 - 消费者模式:用流水线思想提高效率》的思考题就是关于此的。

如何优雅地终止线程?我们在《35 | 两阶段终止模式:如何优雅地终止线程?》有过详细介绍,两阶段终止模式是一种通用的解决方案。但其实终止生产者 - 消费者服务还有一种更简单的方案,叫做**“毒丸”对象**。《Java 并发编程实战》第 7 章的 7.2.3 节对“毒丸”对象有过详细的介绍。简单来讲,“毒丸”对象是生产者生产的一条特殊任务,然后当消费者线程读到“毒丸”对象时,会立即终止自身的执行。

下面是用“毒丸”对象终止写日志线程的具体实现,整体的实现过程还是很简单的:类 Logger 中声明了一个“毒丸”对象 poisonPill,当消费者线程从阻塞队列 bq 中取出一条 LogMsg 后,先判断是否是“毒丸”对象,如果是,则 break while 循环,从而终止自己的执行。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

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 多线程设计模式》这本书来深入学习这个模块,这是一本不错的并发编程入门书籍,虽然重点是讲解设计模式,但是也详细讲解了设计模式中涉及到的方方面面的基础知识,而且深入浅出,非常推荐入门的同学认真学习一下。

欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。