终止线程
错误思路1:使用线程对象的 stop() 方法停止线程 ,stop 方法会真正杀死线程,如果这时线程锁住了共享资源,那么当它被杀死后就再也没有机会释放锁, 其它线程将永远无法获取锁 。
错误思路2:使用 System.exit(int)方法停止线程 ,目的仅是停止一个线程,但这种做法会让整个程序都停止
正确思路:利用Java线程的中断机制
Two-phase Termination(两阶段终止)模式
将终止过程分成两个阶段,其中第一个阶段主要是线程 T1 向线程 T2发送终止指令,而第二阶段则是线程 T2响应终止指令。Java 线程进入终止状态的前提是线程进入 RUNNABLE 状态,而利用java线程中断机的
interrupt() 方法,可以让线程从休眠状态转换到RUNNABLE 状态。RUNNABLE 状态转换到终止状态,优雅的方式是让 Java 线程自己执行完 run() 方法,所以一般我们采用的方法是设置一个标志位,然后线程会在合适的时机检查这个标志位,如果发现符合终止条件,则自动退出run() 方法。
两个关键点:
- 一个是仅检查终止标志位是不够的,因为线程的状态可能处于休眠态;
- 另一个是仅检查线程的中断状态也是不够的,因为我们依赖的第三方类库很可能没有正确处理中断异常,例如第三方类库在捕获到 Thread.sleep() 方法抛出的中断异常后,没有重新设置线程的中断状态,那么就会导致线程不能够正常终止。所以我们可以自定义线程的终止标志位用于终止线程。
使用场景
1. 安全地终止线程,比如释放该释放的资源;
2. 要确保终止处理逻辑在线程结束之前一定会执行时,可使用该方法;
避免共享
Immutability模式,Copy-on-Write模式,Thread-Specific Storage模式本质上都是为了避免共享。
使用时需要注意Immutability模式的属性的不可变性Copy-on-Write模式需要注意拷贝的性能问题
Thread-Specific Storage模式需要注意异步执行问题。
“多个线程同时读写同一共享变量存在并发问题”,这里的必要条件之一是读写,如果只有读,而没有写,是没有并发问题的。解决并发问题,其实最简单的办法就是让共享变量只有读操作,而没有写操作。这个办法如此重要,以至于被上升到了一种解决并发问题的设计模式:不变性(Immutability)模式。所谓不变性,简单来讲,就是对象一旦被创建之后,状态就不再发生变化。换句话说,就是变量一旦被赋值,就不允许修改了(没有写操作);没有修改操作,也就是保持了不变性。
实现
将一个类所有的属性都设置成 final 的,并且只允许存在只读方法,那么这个类基本上就具备不可变性了。更严格的做法是这个类本身也是 final 的,也就是不允许继承。jdk中很多类都具备不可变性,例如经常用到的 String 和 Long、Integer、Double 等基础类型的包装类都具备不可变性,这些对象的线程安全性都是靠不可变性来保证的。它们都严格遵守了不可变类的要求:类和属性都是 final 的,所有方法均是只读的。
注意事项
- 对象的所有属性都是 final 的,并不能保证不可变性
- 不可变对象也需要正确发布
注意:在使用 Immutability 模式的时候一定要确认保持不变性的边界在哪里,是否要求属性对象也具备不可变性,可变对象虽然是线程安全的,但是并不意味着引用这些不可变对象的对象就是线程安全的。
Copy-on-Write
写时复制,和String类型的操作一样,它原有的对象是不可变的,无论是replace或是subString等其他方法调用,返回的都是复制后经过处理的新的对象,Java 中的基本数据类型 String、Integer、Long 等都是基于写时复制来实现的。它适合多读少写的场景,它的缺点在于复制对象导致的内存消耗,但对着gc的不断优化,这部分内存的消耗已经影响甚微。
应用场景
在java中,有两个无锁的写时复制的容器,CopyOnWriteArrayList 和 CopyOnWriteArraySet ,这两个容器通过无锁的读操作来实现对性能的合理利用。
操作系统领域
类 Unix 的操作系统中创建进程的 API是 fork(),该方法会完全复制父进程的副本,包括父进程的地址空间,这个过程是很耗时的。而 Linux 的fork() ,不会复制地址空间,而是将地址空间进行共享;只用在父进程或者子进程需要写入的时候才会复制,从而使父子进程拥有各自的地址空间。
函数式编程领域
函数式编程的基础是不可变性(Immutability),所以函数式编程里面所有的修改操作都需要 Copy-on-Write 来解决。 像一些RPC框架还有服务注册中心,也会利用Copy-on-Write设计思想维护服务路由表。 路由表是典型的读多写少,而且路由表对数据的一致性要求并不高,一个服务提供方从上线到反馈到客户端的路由表里,即便有 5 秒钟延迟,很多时候也都是能接受的。
Thread-Specific Storage
线程本地存储模式,它会在内部为每一个线程分配特有的存储空间,java标准类库中,ThreadLocal就实现了该模式。
它本质上是一种避免共享的方案,因为不存在共享就不会有竞争问题,自然也没有并发问题。避免共享的方式有两种,一是将工具类作为局部变量,成为每个线程的私有数据区域,随着栈帧出栈销毁;二是线程本地存储。区别在于局部变量在高并发场景下会频繁的创建对象,而线程本地存储只需要创建一个工具类的实例即可
应用场景
使用ThreadLocal来处理非线程安全的SimpleDateFormat
static class SafeDateFormat {
//定义ThreadLocal变量
static final ThreadLocal<DateFormat> tl=ThreadLocal.withInitial(
()‐> new SimpleDateFormat("yyyy‐MM‐dd HH:mm:ss"));
static DateFormat get(){
return tl.get();
}
}
//不同线程执行下面代码,返回的df是不同的
DateFormat df = SafeDateFormat.get();
注意:在线程池中使用ThreadLocal 需要避免内存泄漏和线程安全的问题
ExecutorService es;
ThreadLocal tl;
es.execute(()‐>{
//ThreadLocal增加变量
tl.set(obj);
try {
// 省略业务逻辑代码
}finally {
//手动清理ThreadLocal
tl.remove();
}
});
多线程版本的if
分类
Guarded Suspension模式
是线程等待的一种实现,通过等待来保证实例对象的安全性,多线程并发的过程中存在同步机制,即一个线程的执行需要依赖另一个线程的执行结果。该模式允许多个线程对实例资源进行访问,但是实例资源需要对资源的分配做出管理。 Guarded Suspension 模式也常被称作 Guarded Wait 模式、Spin Lock 模式(因为使用了 while 循环去等待,具体的等待场景如下:
- 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
- 如果有结果不断从一个线程到另一个线程那么可以使用消息队列JDK 中,
- join 的实现、Future 的实现,采用的就是此模式
- 因为要等待另一方的结果,因此归类到同步模式
- 等待唤醒机制的规范实现。
此模式依赖于Java线程的阻塞唤醒机制
- sychronized+wait/notify/notifyAll
- reentrantLock+Condition(await/singal/singalAll)
- cas+park/unpark
应用场景
- 多线程环境下多个线程访问相同实例资源,从实例资源中获得资源并处理;
- 实例资源需要管理自身拥有的资源,并对请求线程的请求作出允许与否的判断;
实现案例
public class GuardedObject<T> {
//结果
private T obj;
//获取结果
public T get(){
synchronized (this){
//没有结果等待 防止虚假唤醒
while (obj==null){
try {
this.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return obj;
}
}
//产生结果
public void complete(T obj){
synchronized (this){
//获取到结果,给obj赋值
this.obj = obj;
//唤醒等待结果的线程
this.notifyAll();
}
}
}
注意:该模式需要考虑性能问题
Balking模式
Balking是“退缩不前”的意思,它的实现机制是,先检查是否有其他先线程做了相同的业务逻辑处理,若是,则本线程直接结束。
Balking模式是一种多个线程执行同一操作A时可以考虑的模式;在某一个线程B被阻塞或者执行其他操作时,其他线程同样可以完成操作A,而当线程B恢复执行或者要执行操作A时,因A已被执行,而无需线程B再执行,从而提高了B的执行效率。
Balking模式和Guarded Suspension模式一样,存在守护条件,如果守护条件不满足,则中断处理;这与Guarded Suspension模式不同,Guarded Suspension模式在守护条件不满足的时候会一直等待至可以运行。
常见的应用场景
- sychronized轻量级锁膨胀逻辑, 只需要一个线程膨胀获取monitor对象
- DCL单例实现
- 服务组件的初始化
实现方法
- 锁机制 (synchronized reentrantLock)
- cas
- 对于共享变量不要求原子性的场景,可以使用volatile
适用的业务场景
- 编辑器的自动存盘功能:文件发生修改则周期性存盘,若未修改则放弃存盘操作。
- 单次初始化
注意:该模式要考虑竞态问题
多线程分工
Thread-Per-Message 模式
为每个任务分配一个独立的线程,这是一种最简单的分工方法
应用场景
最经典的应用场景是网络编程里服务端的实现,服务端为每个客户端请求创建一个独立的线程,当线程处理完请求后,自动销毁,这是一种最简单的并发处理网络请求的方法。
final ServerSocketChannel ssc= ServerSocketChannel.open().bind(new InetSocket Address(8080));
//处理请求
try {
while (true) {
// 接收请求
SocketChannel sc = ssc.accept();
// 每个请求都创建一个线程
new Thread(()‐>{
try {
// 读Socket
ByteBuffer rb = ByteBuffer.allocateDirect(1024);
sc.read(rb);
//模拟处理请求
Thread.sleep(2000);
// 写Socket
ByteBuffer wb = (ByteBuffer)rb.flip();
sc.write(wb);
// 关闭Socket
sc.close();
}catch(Exception e){
throw new UncheckedIOException(e);
}
}).start();
}
} finally {
ssc.close();
}
缺点
线程是一个重量级的对象,创建成本很高,一方面创建线程比较耗时,另一方面线程占用的内存也比较大。所以为每个请求创建一个新的线程并不适合高并发场景。
解决方式
为了解决这个缺点,Java 并发包里提供了线程池等工具类。
在其他编程语言里,例如 Go 语言,基于轻量级线程实现 Thread-Per-Message 模式就完全没有问题。
对于一些并发度没那么高的异步场景,例如定时任务,采用 Thread-Per-Message 模式是完全没有问题的
Worker Thread 模式
避免重复创建线程的一种模式,可以类比现实世界里车间的工作模式:车间里的工人(worker Thread),有活儿了,大家一起干,没活儿了就聊聊天等着
应用场景
Worker Thread 模式能避免线程频繁创建、销毁的问题,而且能够限制线程的最大数量。 Java 语言里可以直接使用线程池来实现 Worker Thread 模式,线程池是一个非常基础和优秀的工具类。
生产者 - 消费者模式
核心是一个工作队列,生产者线程生产任务,并将任务添加到队列中,而消费者线程从任务队列中获取任务并执行任务。
优点
- 异步:各个模块可以由串行变为并行
- 解耦:各个模块直接相互独立,互不影响
- 均衡:消除生产者生产与消费者消费之间速度差异
在计算机当中,创建的线程越多,CPU进行上下文切换的成本就越大,所以我们在编程的时候创建的线程并不是越多越好,而是适量即可,采用生产者和消费者模式就可以很好的支持我们使用适量的线程来完成任务。如果在某一段业务高峰期的时间里生产者“生产”任务的速率很快,而消费者“消费”任务速率很慢,由于中间的任务队列的存在,也可以起到缓冲的作用,我们在使用MQ中间件的时候,经常说的削峰填谷也就是这个意思。
缺点
过饱问题
单位时间内,生产者生产的速度大于消费者消费的速度,导致任务不断堆积到阻塞队列中,导致队列堆满。
解决方式
在业务容忍最长响应时间(比如埋点数据统计前一天的数据生成报表,第二天老板要看的,你前一天的数据第二天还没处理完,那就不行,这样的系统我们就要保证,消费者在24小时内的消费能力要比生产者高才行)内消费完毕即可。
场景一:消费者每天能处理的量比生产者生产的少;如生产者每天1万条,消费者每天只能消费5千条。
解决办法:消费者加机器
原因:生产者没法限流,因为要一天内处理完,只能消费者加机器
场景二:消费者每天能处理的量比生产者生产的多。系统高峰期生产者速度太快,把队列塞爆了
解决办法:适当的加大队列
原因:消费者一天的消费能力已经高于生产者,那说明一天之内肯定能处理完,保证高峰期别把队列塞满就好
场景三:消费者每天能处理的量比生产者生产的多。条件有限或其他原因,队列没法设置特别大。系统高峰期生产者速度太快,把队列塞爆了
解决办法:生产者限流
原因:消费者一天的消费能力高于生产者,说明一天内能处理完,队列又太小,那只能限流生产者,让高峰期塞队列的速度慢点。
小结
- Thread-Per-Message 模式需要注意线程的创建,销毁以及是否会导致OOM。
- Worker Thread 模式需要注意死锁问题,提交的任务之间不要有依赖性。
- 生产者 - 消费者模式可以直接使用线程池来实现