三种最简单的分工模式
并发编程领域里,解决分工问题也有一系列的设计模式,比较常用的主要有 Thread-Per-Message 模式、Worker Thread 模式、生产者 - 消费者模式等等。
Thread-Per-Message 模式
在编程领域也有很多类似的需求,比如写一个 HTTP Server,很显然只能在主线程中接收请求,而不能处理 HTTP 请求,因为如果在主线程中处理 HTTP 请求的话,那同一时间只能处理一个请求,太慢了!怎么办呢?可以利用代办的思路,创建一个子线程,委托子线程去处理 HTTP 请求。
这种委托他人办理的方式,在并发编程领域被总结为一种设计模式,叫做 Thread-Per-Message 模式,简言之就是为每个任务分配一个独立的线程。这是一种最简单的分工方法,实现起来也非常简单。
但是,Java 语言里的线程是一个重量级的对象,为每一个任务创建一个线程成本太高,尤其是在高并发领域,基本就不具备可行性
所以它在Java 领域并不是那么知名。
Java 语言未来一定会提供轻量级线程,这样基于轻量级线程实现 Thread-Per-Message 模式就是一个非常靠谱的选择。
Worker Thread模式
上面这个问题相信你也看到了。并发多的话会频繁创建销毁线程非常影响性能,同时无限制地创建线程还可能导致 OOM,所以在 Java 领域使用场景就受限了。
是的,Java中的线程池就是Worker Thread模式的实现。这个模式可以避免Thread-Per-Message 模式中重复创建线程。
Worker Thread 模式可以类比的是工厂里车间工人的工作模式:车间里的工人,有活儿了,大家一起干,没活儿了就聊聊天等着。你可以参考下面的示意图来理解,Worker Thread 模式中 Worker Thread 对应到现实世界里,其实指的就是车间里的工人。不过这里需要注意的是,车间里的工人数量往往是确定的。
正确地创建线程池
- 用创建有界的队列来接收任务
- 在创建线程池时,清晰地指明拒绝策略。
- 为了便于调试和诊断问题,我也强烈建议你在实际工作中给线程赋予一个业务相关的名字。
- 提交到相同线程池中的任务一定是相互独立的
- 为不同的任务创建不同的线程池
ExecutorService es = new ThreadPoolExecutor(
50, 500,
60L, TimeUnit.SECONDS,
//注意要创建有界队列
new LinkedBlockingQueue<Runnable>(2000),
//建议根据业务需求实现ThreadFactory
r->{
return new Thread(r, "test-"+ r.hashCode());
},
//建议根据业务需求实现RejectedExecutionHandler
new ThreadPoolExecutor.CallerRunsPolicy());
生产者 - 消费者模式(重要)
其实在现实世界,工厂里还有一种流水线的工作模式 ,类比到编程领域,就是生产者 - 消费者模式。
生产者 - 消费者模式在编程领域的应用也非常广泛,实际上,Java 线程池本质上就是用生产者 - 消费者模式实现的,所以每当使用线程池的时候,其实就是在应用生产者 - 消费者模式。
生产者 - 消费者模式的核心是一个任务队列,生产者线程生产任务,并将任务添加到任务队列中,而消费者线程从任务队列中获取任务并执行。下面是生产者 - 消费者模式的一个示意图,你可以结合它来理解。
优点
-
解耦(非常重要)
-
支持异步,并且能够平衡生产者和消费者的速度差异(任务队列的作用)
-
支持批量执行以提升性能、
//任务队列 BlockingQueue<Task> bq=new LinkedBlockingQueue<>(2000); //启动5个消费者线程 //执行批量任务 void start() { ExecutorService es=executors .newFixedThreadPool(5); for (int i=0; i<5; i++) { es.execute(()->{ try { while (true) { //获取批量任务 List<Task> ts=pollTasks(); //执行批量任务 execTasks(ts); } } catch (Exception e) { e.printStackTrace(); } }); } } //从任务队列中获取批量任务 List<Task> pollTasks() throws InterruptedException{ List<Task> ts=new LinkedList<>(); //阻塞式获取一条任务 Task t = bq.take(); while (t != null) { ts.add(t); //非阻塞式获取一条任务 t = bq.poll(); } return ts; } //批量执行任务 execTasks(List<Task> ts) { //省略具体代码无数 }
需要注意的是,从任务队列中获取批量任务的方法 pollTasks() 中,首先是以阻塞方式获取任务队列中的一条任务,而后则是以非阻塞的方式获取任务;之所以首先采用阻塞方式,是因为如果任务队列中没有任务,这样的方式能够避免无谓的循环。
-
支持分阶段提交应用场景(比如日志组件的异步刷盘操作)
两阶段终止模式
Thread-Per-Message 模式和Worker Thread模式是启动多线程去执行异步任务。既启动,那又该如何终止呢?
我这里说的终止是:一个线程 T1 中,终止线程 T2;这里所谓的“优雅”,指的是给 T2 一个机会料理后事,而不是被一剑封喉。(stop() 方法一剑封喉,不建议使用了)。那如何优雅的终止呢,这里会用到interrupt() 方法,它的原理就是两阶段终止模式。
如何理解两阶段终止模式
顾名思义,就是将终止过程分成两个阶段,其中第一个阶段主要是线程 T1 向线程 T2发送终止指令,而第二阶段则是线程 T2响应终止指令。
interrupt() 方法,它可以
-
将休眠状态(blocked,waiting)的线程转换到 RUNNABLE 状态,并且设置线程的中断状态,同时抛出InterruptedException异常(PS:JVM 的异常处理会清除线程的中断状态);
-
处于RUNNABLE 状态的下的线程被interrupt(),仅仅是设置了线程的中断状态
所以线程 T1调用T2.interrupt() 方法,就相当于提到的第一阶段:发出终止指令
- 如果线程此时处于休眠状态:线程T2首先会抛出异常,在捕获InterruptedException处,通过 Thread.currentThread().interrupt() 重新设置了线程的中断状态,因为 JVM 的异常处理会清除线程的中断状态。线程T2应该在合适的时机检查这个标志位(需要我们手动实现),如果发现符合终止条件,则自动退出 run() 方法
- 如果线程T2此时处于RUNNABLE 状态:线程T2应该在合适的时机检查这个标志位(需要我们手动实现),如果发现符合终止条件,则自动退出 run() 方法
其实,这个过程其实就是我们前面提到的第二阶段:响应终止指令。
看一个例子:
class Proxy {
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
rptThread = new Thread(()->{
while (!Thread.currentThread().isInterrupted()){
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e){
//重新设置线程中断状态
Thread.currentThread().interrupt();
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
rptThread.interrupt();
}
}
极客时间并发编程实战里面,作者强烈建议你设置自己的线程终止标志位(isTerminated),我觉得还是根据实际场景分析吧。
如何优雅地终止线程池
一般情况下,我们都不会去手动终止线程池,因为线程池和应用共生死。但是在一些采集数据任务中,我们希望在某一时刻停止采集,某一时刻启动采集,这里可能就会用到终止线程池了。
那我们该如何优雅地终止线程池呢?
-
shutdown():一种很保守的关闭线程池的方法。线程池执行 shutdown() 后,就会拒绝接收新的任务,但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池。
-
和shutdownNow():相对就激进一些了,线程池执行 shutdownNow() 后,会拒绝接收新的任务,同时还会中断线程池中正在执行的任务,已经进入阻塞队列的任务也被剥夺了执行的机会
其实,你看过线程池shutdown() 和 shutdownNow()源码的话,你会发现实质上使用的也是两阶段终止模式。