终止线程的设计模式
思考:在一个线程 T1 中如何正确安全的终止线程 T2?
错误思路1:使用线程对象的 stop() 方法停止线程
stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁 。
错误思路2:使用 System.exit(int) 方法停止线程
目的仅是停止一个线程,但这种做法会让整个程序都停止
正确思路:利用Java线程的中断机制
Two-phase Termination(两阶段终止)模式——优雅的终止线程
将终止过程分成两个阶段,其中第一个阶段主要是线程 T1 向线程 T2发送终止指令,而第二阶段则是线程 T2响应终止指令。
Java 线程进入终止状态的前提是线程进入 RUNNABLE 状态,而利用java线程中断机制的interrupt() 方法,可以让线程从休眠状态转换到RUNNABLE 状态。RUNNABLE 状态转换到终止状态,优雅的方式是让 Java 线程自己执行完 run() 方法,所以一般我们采用的方法是设置一个标志位,然后线程会在合适的时机检查这个标志位,如果发现符合终止条件,则自动退出 run() 方法。
两阶段终止模式是一种应用很广泛的并发设计模式,在 Java 语言中使用两阶段终止模式来优雅地终止线程,需要注意两个关键点:一个是仅检查终止标志位是不够的,因为线程的状态可能处于休眠态;另一个是仅检查线程的中断状态也是不够的,因为我们依赖的第三方类库很可能没有正确处理中断异常,例如第三方类库在捕获到 Thread.sleep() 方法抛出的中断异常后,没有重新设置线程的中断状态,那么就会导致线程不能够正常终止。所以我们可以自定义线程的终止标志位用于终止线程。
public class TwoPhaseTermination {
// 线程控制的运行标志
private volatile boolean running = true; // 终止标志位
private Thread worker; // 工作线程
// 启动工作线程
public void start() {
worker = new Thread(() -> {
while (running) { // 第一阶段:检查终止标志
try {
// 模拟一项耗时任务
Thread.sleep(1000);
// 任务执行逻辑
System.out.println("Thread is running...");
} catch (InterruptedException e) {
// 重要:当捕获中断异常时,需要重新设置中断标志
Thread.currentThread().interrupt();
}
// 第二阶段:检查线程中断标志
if (Thread.interrupted()) {
break; // 如果线程被请求中断,则结束循环,终止运行
}
}
// 清理工作,准备终止线程
System.out.println("Thread is stopping...");
});
worker.start();
}
// 发送终止指令
public void stop() {
running = false; // 设置终止标志位
worker.interrupt(); // 发送中断信号,以确保如果线程处于阻塞状态(如sleep),则能够退出阻塞状态
}
public static void main(String[] args) throws InterruptedException {
TwoPhaseTermination tpt = new TwoPhaseTermination();
tpt.start();
Thread.sleep(5000); // 让线程运行一段时间
tpt.stop(); // 请求终止线程
}
}
使用场景
- 安全地终止线程,比如释放该释放的资源;
- 要确保终止处理逻辑在线程结束之前一定会执行时,可使用该方法;
避免共享的设计模式
Immutability模式,Copy-on-Write模式,Thread-Specific Storage模式本质上都是为了避免共享。
- 使用时需要注意Immutability模式的属性的不可变性
- Copy-on-Write模式需要注意拷贝的性能问题
- Thread-Specific Storage模式需要注意异步执行问题。
Immutability模式——想破坏也破坏不了
“多个线程同时读写同一共享变量存在并发问题”,这里的必要条件之一是读写,如果只有读,而没有写,是没有并发问题的。解决并发问题,其实最简单的办法就是让共享变量只有读操作,而没有写操作。这个办法如此重要,以至于被上升到了一种解决并发问题的设计模式:不变性(Immutability)模式。所谓不变性,简单来讲,就是对象一旦被创建之后,状态就不再发生变化。换句话说,就是变量一旦被赋值,就不允许修改了(没有写操作);没有修改操作,也就是保持了不变性。
如何实现
将一个类所有的属性都设置成 final 的,并且只允许存在只读方法,那么这个类基本上就具备不可变性了。更严格的做法是这个类本身也是 final 的,也就是不允许继承。
jdk中很多类都具备不可变性,例如经常用到的 String 和 Long、Integer、Double 等基础类型的包装类都具备不可变性,这些对象的线程安全性都是靠不可变性来保证的。它们都严格遵守了不可变类的三点要求:类和属性都是 final 的,所有方法均是只读的。
使用 Immutability 模式的注意事项
在使用 Immutability 模式的时候,需要注意以下两点:
- 对象的所有属性都是 final 的,并不能保证不可变性;
- 不可变对象也需要正确发布。
在使用 Immutability 模式的时候一定要确认保持不变性的边界在哪里,是否要求属性对象也具备不可变性。
可变对象虽然是线程安全的,但是并不意味着引用这些不可变对象的对象就是线程安全的。
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 这两个容器实现的读操作是无锁的,由于无锁,所以将读操作的性能发挥到了极致。
Copy-on-Write 在操作系统领域也有广泛的应用。类 Unix 的操作系统中创建进程的 API 是 fork(),传统的 fork() 函数会创建父进程的一个完整副本,例如父进程的地址空间现在用到了 1G 的内存,那么 fork() 子进程的时候要复制父进程整个进程的地址空间(占有 1G 内存)给子进程,这个过程是很耗时的。而 Linux 中fork() 子进程的时候,并不复制整个进程的地址空间,而是让父子进程共享同一个地址空间;只用在父进程或者子进程需要写入的时候才会复制地址空间,从而使父子进程拥有各自的地址空间。
Copy-on-Write 最大的应用领域还是在函数式编程领域。函数式编程的基础是不可变性(Immutability),所以函数式编程里面所有的修改操作都需要 Copy-on-Write 来解决。
像一些RPC框架还有服务注册中心,也会利用Copy-on-Write设计思想维护服务路由表。路由表是典型的读多写少,而且路由表对数据的一致性要求并不高,一个服务提供方从上线到反馈到客户端的路由表里,即便有 5 秒钟延迟,很多时候也都是能接受的。
Thread-Specific Storage 模式——没有共享就没有伤害
Thread-Specific Storage(线程本地存储) 模式是一种即使只有一个入口,也会在内部为每个线程分配特有的存储空间的模式。在 Java 标准类库中,ThreadLocal 类实现了该模式。
线程本地存储模式本质上是一种避免共享的方案,由于没有共享,所以自然也就没有并发问题。如果你需要在并发场景中使用一个线程不安全的工具类,最简单的方案就是避免共享。避免共享有两种方案,一种方案是将这个工具类作为局部变量使用,另外一种方案就是线程本地存储模式。这两种方案,局部变量方案的缺点是在高并发场景下会频繁创建对象,而线程本地存储方案,每个线程只需要创建一个工具类的实例,所以不存在频繁创建对象的问题。
多线程版本的if模式
Guarded Suspension模式和Balking模式属于多线程版本的if模式
- Guarded Suspension模式需要注意性能。
- Balking模式需要注意竞态问题。
Guarded Suspension模式——等我准备好哦
Guarded Suspension 模式是通过让线程等待来保护实例的安全性,即守护-挂起模式。在多线程开发中,常常为了提高应用程序的并发性,会将一个任务分解为多个子任务交给多个线程并行执行,而多个线程之间相互协作时,仍然会存在一个线程需要等待另外的线程完成后继续下一步操作。而Guarded Suspension模式可以帮助我们解决上述的等待问题。
Guarded Suspension 模式允许多个线程对实例资源进行访问,但是实例资源需要对资源的分配做出管理。
Guarded Suspension 模式也常被称作 Guarded Wait 模式、Spin Lock 模式(因为使用了 while 循环去等待),它还有一个更形象的非官方名字:多线程版本的 if。
- 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
- 如果有结果不断从一个线程到另一个线程那么可以使用消息队列
- JDK 中,join 的实现、Future 的实现,采用的就是此模式
- 因为要等待另一方的结果,因此归类到同步模式
- 等待唤醒机制的规范实现。此模式依赖于Java线程的阻塞唤醒机制:
- sychronized+wait/notify/notifyAll
- cas+park/unpark
- reentrantLock+Condition(await/singal/singalAll)
阻塞唤醒机制底层原理: linux pthread_mutex_lock/unlock pthread_cond_wait/singal
解决线程之间的协作不可避免会用到阻塞唤醒机制
在多线程编程中,Guarded Suspension模式和Balking模式确实可以视为多线程环境下的“if”模式的应用,因为它们都涉及到根据某些条件来决定线程的行为。这两种模式的设计目的是在并发环境中有效地管理和控制线程对共享资源的访问,以及如何反应在特定状态下的事件。下面详细解释这两种模式:
Guarded Suspension(守护暂停)模式
Guarded Suspension模式用于处理一个线程等待特定条件满足之前暂停执行的情况。当条件不满足时,线程被暂停;只有当条件满足时,线程才继续执行。这通常涉及到某种形式的循环检查一个条件,以及使用等待/通知机制来管理线程的暂停与唤醒。
实现方式:
- 线程在访问一个资源之前检查一个预设的条件。
- 如果条件不满足,线程通过调用
wait()
方法进入等待状态,直到其他线程改变条件并通过notify()
或notifyAll()
方法唤醒它。
使用场景:
- 在生产者-消费者问题中,消费者线程等待生产者线程提供数据。
- 等待任务队列中存在任务可供执行。
Balking(犹豫)模式
Balking模式用于当线程在检查到某个条件未满足时,选择不执行任何操作并直接返回。这种模式通常用于避免对已经在进行的操作或已处于特定状态的对象进行不必要的调用。
实现方式:
- 线程在执行操作之前检查状态。
- 如果状态不适合进行操作,线程将立即停止并返回,不改变任何状态。
使用场景:
- 当尝试对一个已经初始化的系统进行重复初始化时。
- 在文件保存操作中,如果文件未被修改,则不执行保存操作。
区别和联系:
- Guarded Suspension 是线程等待直到某个条件变为真。这涉及到线程的挂起和后续的唤醒。
- Balking 是当条件不满足时,线程不会等待,而是立即返回。
这两种模式都涉及条件判断,但它们对不满足条件的响应策略不同。Guarded Suspension通过等待-通知机制处理条件的变化,而Balking模式则是决定不进行任何操作。在实际应用中,选择哪种模式取决于特定的业务需求和预期的线程行为。
一个线程发现另一个线程已经做了某一件相同的事,那么本线程就无需再做了,直接结束返回。
Balking模式是一种多个线程执行同一操作A时可以考虑的模式;在某一个线程B被阻塞或者执行其他操作时,其他线程同样可以完成操作A,而当线程B恢复执行或者要执行操作A时,因A已被执行,而无需线程B再执行,从而提高了B的执行效率。
Balking模式和Guarded Suspension模式一样,存在守护条件,如果守护条件不满足,则中断处理;这与Guarded Suspension模式不同,Guarded Suspension模式在守护条件不满足的时候会一直等待至可以运行。
常见的应用场景
- sychronized轻量级锁膨胀逻辑, 只需要一个线程膨胀获取monitor对象
- DCL单例实现
- 服务组件的初始化
如何实现Balking模式
- 锁机制 (synchronized reentrantLock)
- cas
- 对于共享变量不要求原子性的场景,可以使用volatile
需要快速放弃的一个最常见的场景是各种编辑器提供的自动保存功能。自动保存功能的实现逻辑一般都是隔一定时间自动执行存盘操作,存盘操作的前提是文件做过修改,如果文件没有执行过修改操作,就需要快速放弃存盘操作。
多线程分工模式
在多线程编程中,"多线程分工模式"通常指的是将复杂或大型任务分解为多个较小的、可以并行处理的任务单元,并将它们分配给多个线程来提高执行效率和程序性能。这种模式有助于充分利用多核处理器的计算能力,减少执行时间,并提高应用程序的响应性。多线程分工模式主要包括以下几种具体模式:
1. Master-Worker 模式
这是一种常见的多线程分工策略,其中一个主线程(Master)负责分配任务,并管理多个工作线程(Workers)。主线程通常负责接收任务,将任务分解成子任务,并分配给工作线程执行。完成任务后,工作线程可能会将结果返回给主线程进行整合。
优点:
- 分工明确,可以并行处理多个任务,提高效率。
- 主线程可以进行负载均衡,动态调整任务分配策略。
2. Pipeline 模式(流水线模式)
在Pipeline模式中,任务被分解为一系列连续的步骤,每个步骤由不同的线程处理。每个线程完成一个任务步骤后,就将中间结果传递给下一个处理步骤的线程。这种模式类似于工厂流水线,每个线程专注于执行特定的操作。
优点:
- 提高数据处理的效率,因为每个步骤都可以并行进行。
- 适合于处理复杂的数据转换和连续计算任务。
3. Fork-Join 模式
Fork-Join模式是一种特别适用于可递归问题的并行化策略。在这种模式下,主线程将任务分解为更小的子任务,直到子任务足够简单可以直接解决。然后并行执行这些任务,并将结果合并(Join)回主任务。Java中的ForkJoinPool
是这种模式的典型实现。
优点:
- 充分利用处理器核心,特别是在处理复杂或大数据集时。
- 适合处理可以递归分解为更小任务的问题,如排序、搜索等。
4. Producer-Consumer 模式
在Producer-Consumer模式中,一个或多个生产者线程生成数据,放入一个共享的缓冲区中,而一个或多个消费者线程从缓冲区中取出数据进行处理。这种模式通过一个共享的队列或缓冲区来平衡生产者和消费者之间的工作速度差异。
优点:
- 解耦生产数据和消费数据的处理速度。
- 可以通过多个生产者和消费者来提高整体处理能力。
这些多线程分工模式可以根据具体的应用场景和任务类型来选择,以达到最佳的性能提升。通过适当的任务分解和线程管理,可以最大化资源利用率,减少执行时间,提高程序的并发性和响应速度。
Thread-Per-Message 模式
是一种在多线程编程中常用的设计模式,其核心思想是为每个接收到的消息或请求创建一个新的线程来处理,从而实现并行处理。这种模式主要用于提高系统的响应性和并发能力,特别适用于处理大量短暂且独立的请求。
基本概念
在Thread-Per-Message模式中,每当一个任务或消息到达时,系统会立即启动一个新的线程来处理这个任务或消息,而不是等待其他任务完成或使用预先存在的线程。这种方式可以迅速响应每个任务,而不会因为线程正在忙碌或队列中等待而延迟处理。
实现方式
在Java中,可以使用Thread
类或者ExecutorService
来实现Thread-Per-Message模式。使用Thread
类的方式直接创建和启动线程,而使用ExecutorService
可以提供更多的灵活性和控制,比如线程池管理、任务调度等。
生产者 - 消费者模式的优点
支持异步处理
场景:用户注册后,需要发注册邮件和注册短信。传统的做法有两种 1.串行的方式;2.并行方式
引入消息队列,将不是必须的业务逻辑异步处理
解耦
场景:用户下单后,订单系统需要通知库存系统扣减库存。
可以消除生产者生产与消费者消费之间速度差异
在计算机当中,创建的线程越多,CPU进行上下文切换的成本就越大,所以我们在编程的时候创建的线程并不是越多越好,而是适量即可,采用生产者和消费者模式就可以很好的支持我们使用适量的线程来完成任务。
如果在某一段业务高峰期的时间里生产者“生产”任务的速率很快,而消费者“消费”任务速率很慢,由于中间的任务队列的存在,也可以起到缓冲的作用,我们在使用MQ中间件的时候,经常说的削峰填谷也就是这个意思。
过饱问题解决方案
在实际生产项目中会有些极端的情况,导致生产者/消费者模式可能出现过饱的问题。单位时间内,生产者生产的速度大于消费者消费的速度,导致任务不断堆积到阻塞队列中,队列堆满只是时间问题。
思考:是不是只要保证消费者的消费速度一直比生产者生产速度快就可以解决过饱问题?
我们只要在业务可以容忍的最长响应时间内,把堆积的任务处理完,那就不算过饱。
什么是业务容忍的最长响应时间?
比如埋点数据统计前一天的数据生成报表,第二天老板要看的,你前一天的数据第二天还没处理完,那就不行,这样的系统我们就要保证,消费者在24小时内的消费能力要比生产者高才行。
场景一:消费者每天能处理的量比生产者生产的少;如生产者每天1万条,消费者每天只能消费5千条。
解决办法:消费者加机器
原因:生产者没法限流,因为要一天内处理完,只能消费者加机器
场景二:消费者每天能处理的量比生产者生产的多。系统高峰期生产者速度太快,把队列塞爆了
解决办法:适当的加大队列
原因:消费者一天的消费能力已经高于生产者,那说明一天之内肯定能处理完,保证高峰期别把队列塞满就好
场景三:消费者每天能处理的量比生产者生产的多。条件有限或其他原因,队列没法设置特别大。系统高峰期生产者速度太快,把队列塞爆了
解决办法:生产者限流
原因:消费者一天的消费能力高于生产者,说明一天内能处理完,队列又太小,那只能限流生产者,让高峰期塞队列的速度慢点