java多线程编程模式

时间:2021-03-23 15:22:38

前言

区别于java设计模式,下面介绍的是在多线程场景下,如何设计出合理的思路。

不可变对象模式

场景

1. 对象的变化频率不高

每一次变化就是一次深拷贝,会影响cpu以及gc,如果频繁操作会影响性能

2. 作为hashmap的key

key如果是可变的,那么会无法从hashmap中找到原来的数据

3. 单线程写,多线程读或者遍历等场景

这种场景在读或写的任何操作都不需要加锁,如果是多线程场景那么在写的时候需要加锁。

思路

让对象从初始化开始就不能被修改从而满足天然的线程安全条件,也就是说其他任何操作都是读操作,不再有写操作。当该对象遇到需要写操作的场景时,再通过对其深拷贝的方式,创建出一个新的对象来代替。核心特征有下面3个

1. 类用final修饰

2. 所有字段用final修饰

3. 如果用到其他可变的对象,那么再对外提供对象时需要进行深拷贝。

JDK案例

CopyOnWriteArrayList

每一次写操作都会深拷贝其内部的一个数组。只需要在写的时候枷锁,这是为了防止多线程写导致的并发问题,在读取或者遍历的时候不用加锁。所以这个数据结果的场景是多读少写的场景。

保护性暂挂模式

场景

线程a想要执行一个操作,但是需要等待线程b完成另一个操作

思路

抽象出中间类(下面用block代替)来保证线程安全和同步,将线程a需要执行的逻辑传给block,block基于java的Lock和Condition实现通用的await和notify,线程b在操作完后调用block的释放方法。说白了就是把await和notify提取出来,实现和对象无关的等待唤醒。

JDK案例

LinkedBlockingQueue

LinkedBlockingQueue采用了两类锁,put锁和take锁,也就是读锁和写锁。与之对应的衍生出了两个Condition,这个队列的特点是阻塞,当put的时候如果队列满了,那么会阻塞直到队列有空间,take操作也一样,如果队列没数据则会一直等待直到有获取到数据。

两阶段模式

场景

  1. 需要在优雅的关闭某个线程,比如某个sock正在循环监听
  2. 需要在JVM结束前结束某个工作线程(与守护线程相对)

思路

所谓两阶段终止,就是把停止1个线程拆成两步,第一步修改线程中的停止标志位,常见的线程都是自循环的,改变标志位意味着在此次逻辑后不再进入下一循环;第二步是中断线程,每个线程都有自己的中断逻辑,比如在wait的都notify了,在sleep的都interrupt了,从而达到快速停止的效果。

JDK案例

ThreadPoolExecutor

ThreadPoolExecutor.shutdown()的实现思路就是将状态置为SHUTDOWN,然后将没有工作的线程直接中断interrupt,最后等待正在工作的线程执行完最后一段逻辑。

承诺模式

场景

在保护性暂挂模式场景下,a线程需要b线程的执行结果,但是除此之外,a线程还需要其他操作,也就是说需要两个线程一起执行。

思路

a线程先提交b线程,并获取b线程的执行小票,等a线程执行完自己的逻辑后再根据执行小票获取b线程的执行结果。

JDK案例

FutureTask

java自带了promise的库,可以直接使用FutureTask类,再通过线程提交,从而达到异步效果。

生产者消费者模式

场景

生产者消费者模式可能是我们接触的最多的模式了,比如事件分发,任务调度

思路

通过将生产者线程和消费者线程解耦,引入通道的概念,让生产者把数据发到通道中,消费者再从通道中获取数据

JDK案例

ThreadPoolExecutor

ThreadPoolExecutor的整体结构就是生产者和消费者,客户端在submit任务或者execute任务的时候起到生产者的操作,当最大线程数到达阈值后,新进来的任务就会加入队列,而ThreadPoolExecutor本身的构造函数就需要一个阻塞队列,起到管道的作用,最后ThreadPoolExecutor内部有一个线程池来不断的获取管道的任务,从而执行任务。

主动对象模式

场景

这个模式的名称听起来可能有点抽象,其实就是抽象出一个对象来管理和维护异步任务执行,并对外提供任务提交等接口。对这听起来就是一个线程池的功能。

思路

将异步任务的提交和执行解耦,构建一个专门维护所有异步任务的对象,当使用者需要执行异步任务,那么可以将异步任务提交给该对象,并快速返回,不用再关心任务的执行和调度。

JDK案例

ThreadPoolExecutor

ThreadPoolExecutor管理了一个线程池用于执行异步任务(这个模式不关心是线程还是线程池,只是想表达有一个能够独立维护管理异步任务执行的对象),并对外提供了submit和execute两个提交任务的方法,这两个方法原理一样,只是submit会将Runnable对象封装成FutureTask对象,从而可以获取返回值。当客户端调用这两个方法的时候,ThreadPoolExecutor会根据当前的线程数量,队列空间来决定任务的执行,等待和拒绝,这些过程对客户端来说都是无需等待的。

线程池模式

场景

需要周期性的去进行异步操作,要知道创建和销毁线程的代价是很大的,所以需要对零散的线程进行统一管理。

思路

通过构建一个线程池列表,维护所有的线程。为了满足不同的cpu资源使用场景需要,需要能够配置线程池的最大线程数最限制。为了减少线程在空闲时间占用的资源,需要能够配置对空闲线程的回收时间以及常驻线程数量大小。为了提供异步任务排队的概念,需要能够配置待执行任务的队列。为了能自己控制创建线程的属性,需要能够配置线程构建工厂。为了解决异步任务提交失败的场景,需要能够配置任务提交的出错策略。说了这么多,其实就在说ThreadPoolExecutor的构造函数。

JDK案例

ThreadPoolExecutor

ThreadPoolExecutor是JDK1.5之后提供的一个线程池实现,强力推荐使用。下面列一个典型的构建函数实现。

// 创建一个
// 常驻线程数为2,
// 最大线程数量上限为10,
// 空闲线程过60s就回收,
// 任务等待队列为最大容量为10的基于链表的阻塞队列
// 线程的创建为默认线程工厂,
// 任务提交失败则抛出异常
// 线程池 ThreadPoolExecutor threadPoolExecutor
= new ThreadPoolExecutor(
2,
10,
60,
TimeUnit.SECONDS,
new LinkedBlockingQueue<Runnable>(20),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor. AbortPolicy());

线程特有存储模式

场景

在多线程场景下,某个对象需要被共享给多个线程,并且多个线程会对此对象进行修改和读取操作,除此之外,共享的对象占用空间很小,修改的频率很高。最常见的就是利用线程本地存储来共享一些环境配置。

思路

在高频率的多线程修改场景下,需要尽可能的避免锁,否则线程之间会疯狂竞争锁导致性能下降。那么将这个对象在每个线程中都有一个拷贝是很好的选择,每个线程维护各自的对象,不需要加任何锁。

JDK案例

ThreadLocal

ThreadLocal通过Thread中内置的ThreadMap来存储数据,从而实现每个线程拥有各自的对象。ThreadMap中用ThreadLocal作为key,存储的数据作为value。需要注意的是,当该某个线程执行完之后,需要手动把该线程的数据remove,避免内存泄露。

说起来线程特有存储模式和之前讲到的不可变模式的思路有点像,只是前者缓存了对象,后者在需要用对象的时候重新深拷贝一个。可以说是用空间换时间的操作。

串行线程封闭模式

把多个异步任务加入队列,用单工作线程去执行,从而实现串行的效果。感觉这个模式可以简单理解为最大线程数是1的线程池,就不多说了。

主仆模式

思路

将一个复杂的单个任务拆成多个子任务,每个子任务由不同的线程去执行,执行完后再汇总。这就形成了主仆模式

流水线模式

思路

可以理解成串行封闭模式+主仆模式

半同步半异步模式

思路

对异步任务执行进行aop,意思就是说可以自定义异步任务的执行前,执行后进行的相关逻辑,从而实现相关同步的操作。

总结

JDK提供了很多开箱即用的对象,特别是ThreadPoolExecutor,囊括了多种编程模式。

参考

《Java多线程编程实战指南-设计模式篇》