一、 终止线程的设计模式
1、stop()方法
调用线程的 stop() 方法,可以真正的杀死一个线程,使线程即时停止,如果当线程正在持有锁资源的情况下,如果线程停止会立即释放锁,那么会后续的逻辑不会继续执行,其他线程会获取到锁,从而导致数据不一致的问题。
2、System.exit(int)
3、Two-phase Termination
两阶段的终止,是一种优雅的终止线程方式,将终止过程分成两个阶段,其中第一个阶段主要是线程 T1 向线程 T2发送终止指令,而第 二阶段则是线程 T2响应终止指令。
Java 线程进入终止状态的前提是线程进入 RUNNABLE 状态,而利用java线程中断机制的 interrupt() 方法,可以让线程从休眠状态转换到 RUNNABLE 状态。RUNNABLE 状态转换到终止状态,优雅的方式是让 Java 线程自己执行完 run() 方法,所以一般我们采用的方法是设置一个标志位,然后线程会在合适的时机检查这个标志位,如果发现符合终止条件,则自动退出 run() 方法。
当调用线程的 interrupt()方法后,会改变线程的中断标志位,例如 T1 正在执行方法逻辑,T2 此调用 T1 的interrupt() 方法,修改 T1 的中断标志位,由 T1 线程自行来判断,何时检查中断标志位,何时退出执逻辑,终止线程,这种方法不会强制停止 T1 线程,而是由 T1 线程自行控制是否停止。
调用 interrupt()方法要注意,isInterrupted() 会判断当前线程的中断标志位,但是不会清除终端标志位的状态,interrupted() 在返回中断状态的同时会清空中断标志位,Thread.sleep() 在线程休眠时,如果中断标志位被修改了,那么也将线程唤醒,并清除中断标志位,所以如果在第三方逻辑处理 sleep 时没有重新设置中断标志位,那么就会出现问题,比较稳妥的方式可以手动设置一个全局变量作为中断标记,如果需要跳出执行逻辑,那么就修改这个变量的值,执行逻辑的线程在合适的位置来判断这个标记。
二、避免共享的设计模式
Immutability模式,Copy-on-Write模式,Thread-Specific Storage模式本质上都是为了避免共享。使用时需要注意Immutability模式的属性的不可变性。Copy-on-Write模式需要注意拷贝的性能问题。Thread-Specific Storage模式需要注意异步执行问题。
1、Immutability模式
不变性模式,多个线程同时读写同一共享变量存在并发问题,这里的必要条件之一是读写,如果只有读,而没有写,是没有并发问题的。解决并发问题,其实最简单的办法就是让共享变量只有读操作,而没有写操作。这个办法如此重要,以至于被上升到了一种解决并发问题的设计模式:不变性(Immutability)模式。所谓不变性,简单来讲,就是对象一旦被创建之后,状态就不再发生变化。换句话说,就是变量一旦被赋值,就不允许修改了(没有写操作);没有修改操作,也就是保持了不变性。
将一个类所有的属性都设置成 final 的,并且只允许存在只读方法,那么这个类基本上就具备不可变性了。更严格的做法是这个类本身也是 final 的,也就是不允许继承。
jdk中很多类都具备不可变性,例如经常用到的 String 和 Long、Integer、Double 等基础类型的包装类都具备不可变性,这些对象的线程安全性都是靠不可变性来保证的。它们都严格遵守了不可变类的三点要求:类和属性都是 final 的,所有方法均是只读的。
对象的所有属性都是 final 的,并不能保证不可变性,例如在一个类 A 中有一个 final B 对象属性,如果 B 类的所有属性不是 final 的,那么在 A 中依然可以调用对象 B 的 set() 方法来修改设置对象 B 的成员属性。如果 B 类的所有属性是 final 的,而 A 类中的成员属性 B 不是 final 的,如果 A 类不是线程安全的,那么在设置 A 类中 B 属性的值时仍然有不可见性和原子性问题。
2、Copy-on-Write模式
写时复制模式,Java 里 String 在实现 replace() 方法的时候,并没有更改原字符串里面 value[] 数组的内容,而是创建了一个新字符串,这种方法在解决不可变对象的修改问题时经常用到。它本质上是一种 Copy-on-Write 方法。所谓 Copy-on-Write,经常被缩写为 COW 或者 CoW,顾名思义就是写时复制。
不可变对象的写操作往往都是使用 Copy-on-Write 方法解决的,当然 Copy-on-Write 的应用领域并不局限于 Immutability 模式。
Copy-on-Write 才是最简单的并发解决方案,很多人都在无意中把它忽视了。它是如此简单,以至于 Java 中的基本数据类型 String、Integer、Long 等都是基于 Copy-on-Write 方案实现的。
Copy-on-Write 缺点就是消耗内存,每次修改都需要复制一个新的对象出来,好在随着自动垃圾回收(GC)算法的成熟以及硬件的发展,这种内存消耗已经渐渐可以接受了。所以在实际工作中,如果写操作非常少(读多写少的场景),可以尝试使用 Copy-on-Write。
在Java中,CopyOnWriteArrayList 和 CopyOnWriteArraySet 这两个 Copy-on-Write 容器,它们背后的设计思想就是 Copy-on-Write;通过 Copy-on-Write 这两个容器实现的读操作是无锁的,由于无锁,所以将读操作的性能发挥到了极致。
在 nacos 中,注册中心的注册列表的更新,也是使用写时复制这种设计来维护的。
3、Thread-Specific Storage 模式
线程本地存储模式,是一种即使只有一个入口,也会在内部为每个线程分配特有的存储空间的模式。在 Java 标准类库中,ThreadLocal 类实现了该模式。
在并发场景中使用一个线程不安全的工具类,最简单的方案就是避免共享。 避免共享有两种方案,一种方案是将这个工具类作为局部变量使用,另外一种方案就是线程本地存储模式。
ThreadLocal 是将一个对象的副本保存到当前线程的 map 中,这样每个线程都会有一个副本,优点是线程可以携带这个副本对象到任何方法中使用,如果副本对象会被频繁的用到,可以避免频繁的创建副本对象,或每个方法都传递这个副本对象的参数,但是如果副本变量本身是线程不安全的,那么如果对这个副本变量进行修改也是线程不安全的。
在线程池中,因为线程会进行复用,所以在线程执行完逻辑之后,要删除掉。
三、多线程版本的if模式
1、Guarded Suspension模式
守护-挂起模式,通过让线程等待来保护实例的安全性,在多线程开发中,常常为了提高应用程序的并发性,会将一个任务分解为多个子任务交给多个线程并行执行,而多个线程之间相互协作时,仍然会存在一个线程需要等待另外的线程完成后继续下一步操作。而 Guarded Suspension 模式可以帮助我们解决上述的等待问题。
Guarded Suspension 模式允许多个线程对实例资源进行访问,但是实例资源需要对资源的分配做出管理。 也常被称作 Guarded Wait 模式、Spin Lock 模式(因为使用 了 while 循环去等待),它还有一个更形象的非官方名字:多线程版本的 if。
JDK 中,join 的实现、Future 的实现,采用的就是此模式,阻塞唤醒机制底层原理:在 linux 中pthread_mutex_lock/unlock pthread_cond_wait/singal 解决线程之间的协作不可避免会用到阻塞唤醒机制。
sychronized+wait/notify/notifyAll。 reentrantLock+Condition(await/singal/singalAll) 。 cas+park/unpark。
2、Balking模式
如果现在不适合执行这个操作,或者没必要执行这个操 作,就停止处理,直接返回。当流程的执行顺序依赖于某个共享变量的场景,可以归纳为多线程if模式。Balking 模式常用于一个线程发现另一个线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回。
例如 sychronized轻量级锁膨胀逻辑, 只需要一个线程膨胀获取monitor对象,或服务组件的初始化。
四、多线程分工模式
Thread-Per-Message 模式需要注意线程的创建,销毁以及是否会导致OOM。Worker Thread 模式需要注意死锁问题,提交的任务之间不要有依赖性。生产者-消费者模式属于多线程分工模式,可以直接使用线程池来实现。
1、Thread-Per-Message 模式
为每个任务分配一个独立的线程,这是一种最简单的分工方法。例如 BIO,每个链接都会有一个线程负责处理,当处理完成后自行销毁。但是这种方式在 java 中会存在很大的性能消耗,因为 java 创建线程是需要内核态来创建,用户态切换到内核态是一个比较重量级的操作。在并发度不高的情况是比较适用的。
2、Worker Thread模式
要想有效避免线程的频繁创建、销毁以及 OOM 问题,就不得不提 Java 领域使用最多的 Worker Thread 模式。Worker Thread 模式可以类比现实世界里车间的工作模式:车间里的工人,有活儿了,大家一起干,没活儿了就聊聊天等着。Worker Thread 模式中 Worker Thread 对应到现实世界里,其实指的就是车间里的工人。
在 java 中的线程池就是这种模式。
3、生产者 - 消费者模式
Worker Thread 模式类比的是工厂里车间工人的工作模式。但其实在现实世界,工厂里还有一种流水线的工作模式,类比到编程领域,就是生产者 - 消费者模式。
生产者 - 消费者模式的核心是一个任务队列,生产者线程生产任务,并将任务添加到任务队列中,而消费者线程从任务队列中获取任务并执行。
在计算机当中,创建的线程越多,CPU进行上下文切换的成本就越大,所以我们在编程的时候创建的线程并不是越多越好,而是适量即可,采用生产者和消费者模式就可以很好的支持我们使用适量的线程来完成任务。
如果在某一段业务高峰期的时间里生产者“生产”任务的速率很快,而消费者“消费”任 务速率很慢,由于中间的任务队列的存在,也可以起到缓冲的作用,我们在使用MQ中间件的时候,经常说的削峰填谷也就是这个意思。
过饱问题解决方案:
在实际生产项目中会有些极端的情况,导致生产者/消费者模式可能出现过饱的问题。单位时间内,生产者生产的速度大于消费者消费的速度,导致任务不断堆积到阻塞队列中,队列堆满只是时间问题。我们只要在业务可以容忍的最长响应时间内,把堆积的任务处理完,那就不算过饱。
解决办法1:生产者没法限流,因为要一天内处理完,只能消费者加机器,消费者加机器。
解决办法2:消费者一天的消费能力已经高于生产者,那说明一天之内肯定能处理完,保证高峰期别把队列塞满就好,适当的加大队列。
解决办法3:消费者一天的消费能力高于生产者,说明一天内能处理完,队列又太小,那只能限流生产者,让高峰期塞队列的速度慢点,生产者限流。
