极客时间已完结课程限时免费阅读

35 | 两阶段终止模式:如何优雅地终止线程?

35 | 两阶段终止模式:如何优雅地终止线程?-极客时间

35 | 两阶段终止模式:如何优雅地终止线程?

讲述:王宝令

时长08:20大小7.62M

前面两篇文章我们讲述的内容,从纯技术的角度看,都是启动多线程去执行一个异步任务。既启动,那又该如何终止呢?今天咱们就从技术的角度聊聊如何优雅地终止线程,正所谓有始有终。
《09 | Java 线程(上):Java 线程的生命周期》中,我曾讲过:线程执行完或者出现异常就会进入终止状态。这样看,终止一个线程看上去很简单啊!一个线程执行完自己的任务,自己进入终止状态,这的确很简单。不过我们今天谈到的“优雅地终止线程”,不是自己终止自己,而是在一个线程 T1 中,终止线程 T2;这里所谓的“优雅”,指的是给 T2 一个机会料理后事,而不是被一剑封喉。
Java 语言的 Thread 类中曾经提供了一个 stop() 方法,用来终止线程,可是早已不建议使用了,原因是这个方法用的就是一剑封喉的做法,被终止的线程没有机会料理后事。
既然不建议使用 stop() 方法,那在 Java 领域,我们又该如何优雅地终止线程呢?

如何理解两阶段终止模式

前辈们经过认真对比分析,已经总结出了一套成熟的方案,叫做两阶段终止模式。顾名思义,就是将终止过程分成两个阶段,其中第一个阶段主要是线程 T1 向线程 T2发送终止指令,而第二阶段则是线程 T2响应终止指令
两阶段终止模式示意图
那在 Java 语言里,终止指令是什么呢?这个要从 Java 线程的状态转换过程说起。我们在《09 | Java 线程(上):Java 线程的生命周期》中曾经提到过 Java 线程的状态转换图,如下图所示。
Java 中的线程状态转换图
从这个图里你会发现,Java 线程进入终止状态的前提是线程进入 RUNNABLE 状态,而实际上线程也可能处在休眠状态,也就是说,我们要想终止一个线程,首先要把线程的状态从休眠状态转换到 RUNNABLE 状态。如何做到呢?这个要靠 Java Thread 类提供的 interrupt() 方法,它可以将休眠状态的线程转换到 RUNNABLE 状态。
线程转换到 RUNNABLE 状态之后,我们如何再将其终止呢?RUNNABLE 状态转换到终止状态,优雅的方式是让 Java 线程自己执行完 run() 方法,所以一般我们采用的方法是设置一个标志位,然后线程会在合适的时机检查这个标志位,如果发现符合终止条件,则自动退出 run() 方法。这个过程其实就是我们前面提到的第二阶段:响应终止指令
综合上面这两点,我们能总结出终止指令,其实包括两方面内容:interrupt() 方法线程终止的标志位
理解了两阶段终止模式之后,下面我们看一个实际工作中的案例。

用两阶段终止模式终止监控操作

实际工作中,有些监控系统需要动态地采集一些数据,一般都是监控系统发送采集指令给被监控系统的监控代理,监控代理接收到指令之后,从监控目标收集数据,然后回传给监控系统,详细过程如下图所示。出于对性能的考虑(有些监控项对系统性能影响很大,所以不能一直持续监控),动态采集功能一般都会有终止操作。
动态采集功能示意图
下面的示例代码是监控代理简化之后的实现,start() 方法会启动一个新的线程 rptThread 来执行监控数据采集和回传的功能,stop() 方法需要优雅地终止线程 rptThread,那 stop() 相关功能该如何实现呢?
class Proxy {
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
rptThread = new Thread(()->{
while (true) {
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e) {
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
//如何实现?
}
}
按照两阶段终止模式,我们首先需要做的就是将线程 rptThread 状态转换到 RUNNABLE,做法很简单,只需要在调用 rptThread.interrupt() 就可以了。线程 rptThread 的状态转换到 RUNNABLE 之后,如何优雅地终止呢?下面的示例代码中,我们选择的标志位是线程的中断状态:Thread.currentThread().isInterrupted() ,需要注意的是,我们在捕获 Thread.sleep() 的中断异常之后,通过 Thread.currentThread().interrupt() 重新设置了线程的中断状态,因为 JVM 的异常处理会清除线程的中断状态。
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();
}
}
上面的示例代码的确能够解决当前的问题,但是建议你在实际工作中谨慎使用。原因在于我们很可能在线程的 run() 方法中调用第三方类库提供的方法,而我们没有办法保证第三方类库正确处理了线程的中断异常,例如第三方类库在捕获到 Thread.sleep() 方法抛出的中断异常后,没有重新设置线程的中断状态,那么就会导致线程不能够正常终止。所以强烈建议你设置自己的线程终止标志位,例如在下面的代码中,使用 isTerminated 作为线程终止标志位,此时无论是否正确处理了线程的中断异常,都不会影响线程优雅地终止。
class Proxy {
//线程终止标志位
volatile boolean terminated = false;
boolean started = false;
//采集线程
Thread rptThread;
//启动采集功能
synchronized void start(){
//不允许同时启动多个采集线程
if (started) {
return;
}
started = true;
terminated = false;
rptThread = new Thread(()->{
while (!terminated){
//省略采集、回传实现
report();
//每隔两秒钟采集、回传一次数据
try {
Thread.sleep(2000);
} catch (InterruptedException e){
//重新设置线程中断状态
Thread.currentThread().interrupt();
}
}
//执行到此处说明线程马上终止
started = false;
});
rptThread.start();
}
//终止采集功能
synchronized void stop(){
//设置中断标志位
terminated = true;
//中断线程rptThread
rptThread.interrupt();
}
}

如何优雅地终止线程池

Java 领域用的最多的还是线程池,而不是手动地创建线程。那我们该如何优雅地终止线程池呢?
线程池提供了两个方法:shutdown()shutdownNow()。这两个方法有什么区别呢?要了解它们的区别,就先需要了解线程池的实现原理。
我们曾经讲过,Java 线程池是生产者 - 消费者模式的一种实现,提交给线程池的任务,首先是进入一个阻塞队列中,之后线程池中的线程从阻塞队列中取出任务执行。
shutdown() 方法是一种很保守的关闭线程池的方法。线程池执行 shutdown() 后,就会拒绝接收新的任务,但是会等待线程池中正在执行的任务和已经进入阻塞队列的任务都执行完之后才最终关闭线程池。
而 shutdownNow() 方法,相对就激进一些了,线程池执行 shutdownNow() 后,会拒绝接收新的任务,同时还会中断线程池中正在执行的任务,已经进入阻塞队列的任务也被剥夺了执行的机会,不过这些被剥夺执行机会的任务会作为 shutdownNow() 方法的返回值返回。因为 shutdownNow() 方法会中断正在执行的线程,所以提交到线程池的任务,如果需要优雅地结束,就需要正确地处理线程中断。
如果提交到线程池的任务不允许取消,那就不能使用 shutdownNow() 方法终止线程池。不过,如果提交到线程池的任务允许后续以补偿的方式重新执行,也是可以使用 shutdownNow() 方法终止线程池的。《Java 并发编程实战》这本书第 7 章《取消与关闭》的“shutdownNow 的局限性”一节中,提到一种将已提交但尚未开始执行的任务以及已经取消的正在执行的任务保存起来,以便后续重新执行的方案,你可以参考一下,方案很简单,这里就不详细介绍了。
其实分析完 shutdown() 和 shutdownNow() 方法你会发现,它们实质上使用的也是两阶段终止模式,只是终止指令的范围不同而已,前者只影响阻塞队列接收任务,后者范围扩大到线程池中所有的任务。

总结

两阶段终止模式是一种应用很广泛的并发设计模式,在 Java 语言中使用两阶段终止模式来优雅地终止线程,需要注意两个关键点:一个是仅检查终止标志位是不够的,因为线程的状态可能处于休眠态;另一个是仅检查线程的中断状态也是不够的,因为我们依赖的第三方类库很可能没有正确处理中断异常。
当你使用 Java 的线程池来管理线程的时候,需要依赖线程池提供的 shutdown() 和 shutdownNow() 方法来终止线程池。不过在使用时需要注意它们的应用场景,尤其是在使用 shutdownNow() 的时候,一定要谨慎。

课后思考

本文的示例代码中,线程终止标志位 isTerminated 被声明为 volatile,你觉得是否有必要呢?
class Proxy {
//线程终止标志位
volatile boolean terminated = false;
......
}
欢迎在留言区与我分享你的想法,也欢迎你在留言区记录你的思考过程。感谢阅读,如果你觉得这篇文章对你有帮助的话,也欢迎把它分享给更多的朋友。
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 25

提建议

上一篇
34 | Worker Thread模式:如何避免重复创建线程?
下一篇
36 | 生产者-消费者模式:用流水线思想提高效率
unpreview
 写留言

精选留言(61)

  • 佑儿
    2019-05-20
    stop和start方法对于terminated访问由于syn关键字,线程安全,但是start中新起了一个线程rptthread,导致stop方法中对于terminated存在可见性问题,因此需要volatie,原子性问题对这个代码段没有影响,所以原子性问题无需关注。

    作者回复: 👍

    共 4 条评论
    63
  • echo_陈
    2019-05-18
    我觉得,在本例子中。stop中,设置终止标识位对interupt是可见的。而interrupt对被中断线程检测到中断事件是可见的……根据传递性原则……我觉得本例子不需要volatile关键字。但平时开发中,一般会加上,主要是因为怕后续开发不注意这些事情导致修改破坏了规则,引起可见性问题产生bug,保险起见会加上volatile

    作者回复: 是的,线程不调用wait,sleep等方法,是无法响应中断的,这个时候基于interrupt的可见性就不成立了,所以工程上这类变量都需要加volatile

    共 12 条评论
    39
  • 孙志强
    2019-05-18
    有必要,变量被多个线程访问,需要保证可见性

    作者回复: 👍

    共 3 条评论
    28
  • 遇见阳光
    2019-05-18
    按道理而言,synchronized保证原子性的同时,也能间接的保证可见性啊。感觉可以不加 volatile关键字

    作者回复: 问题是start方法里又启动了一个新的线程,synchronized管不到这个新的线程

    共 3 条评论
    27
  • WL
    2019-05-20
    请问一下老师"JVM 的异常处理会清除线程的中断状态"指的是什么意思, 是指把线程的为true的中断状态改为false吗, JVM是在catch到Interrupt异常的时候重置线程中断状态的吗?

    作者回复: 是的

    16
  • 远东通信-应用软件
    2019-10-12
    在本章节后面一个实例代码中while循环没有使用线程的中断标志位做判断,但是stop里面仍然去调用rptThread.interrupt()有必要吗?只是为了将采集线程从sleep状态唤醒吗?

    作者回复: 是的

    共 2 条评论
    12
  • ZOU志伟
    2019-05-18
    shutdown()调用后,还要再调用awaitTermination方法等待一点时间,线程池里的线程才会终止。
    11
  • jason
    2020-01-11
    按这样说,新线程里会修改start变量的值,为了保证start的最新值能被start()方法看见,是不是也要对start变量加volatile修饰?

    作者回复: 加上更保险,不加数据也不会错,只是可能需要多等一会

    6
  • 青铜5 周群力
    2020-05-05
    什么情况需要手动shutdown线程池呢,我理解只要线程池不再被引用,里面的线程自己就被gc回收掉了,不需要手动调shutdown?

    作者回复: 基本上带池字的,生命周期都和应用同龄

    共 2 条评论
    5
  • 2019-07-16
    老师,想问一个问题如果interrupt()方法只是给线程打一个中断的标签,那么如果我线程本身没有显示的去做这个标的判断,线程还能被中断么,当然线程是runnable的,如果能中断又是谁去识别的呢?

    作者回复: 线程不能被中断,但是很多系统函数如sleep是响应中断的。极端地讲,纯CPU计算一定不会被中断

    共 2 条评论
    5
  • 佑儿
    2019-05-20
    两阶段终止模式:发送终止指令+响应终止指令。 终止指令通常可以定义一个终止标识变量(注意并发问题,需要volatie保证可见性)。 如果线程中调用了可中断方法(wait等),在发送终止指令的同时需要调用Thread.interrupt()。 不建议使用线程自身的中断标识作为终止指令,因为项目中第三方的调用无法保证该标志位。
    展开
    5
  • ban
    2019-05-24
    老师,思考题前的最后一个示例代码,为什么 // 线程终止标志位 volatile boolean terminated = false; boolean started = false; 为什么started可以不加volatile,terminated却要加呢?
    展开

    作者回复: started的读写都在同步方法里面

    4
  • 晓杰
    2019-05-19
    有必要,因为stop方法对isTerminated的修改需要被start方法读取到,保证共享变量的可见性
    4
  • null
    2019-06-08
    ```java // 因为留言超字数:1. 省略未修改的代码片段,2. println 是 System.out.println 的简写 class Proxy { // 变量声明,(留言超字数,此处未做修改,省略) public static void main(String[] args) { Proxy proxy=new Proxy(); for (int i=0; i<100; i++) { new Thread(() -> { proxy.start(); proxy.stop(); }, "外部线程_"+i) .start(); } } // 启动采集功能 synchronized void start() { // 不允许同时启动多个采集线程 String outerName=Thread.currentThread().getName(); println("["+outerName+"]线程是否启动?"+started); if (started) { println("["+outerName+"]线程 return"); return; } started=true; terminated=false; rptThread=new Thread(() -> { while (!terminated) { // 每隔两秒钟采集、回传一次数据(留言超字数,此处未做修改,省略) } // 执行到此处说明线程马上终止 started=false; println("["+outerName+",内部线程:"+Thread.currentThread().getName()+"] started=false 成功执行"); }); rptThread.start(); println("["+outerName+"]线程执行完毕,内部子线程正在执行中..."); } // 终止采集功能(留言超字数,此处未做修改,省略) } ``` ``` 执行结果: [外部线程_77]线程是否启动?false [外部线程_77]线程执行完毕,内部子线程正在执行中... [外部线程_82]线程是否启动?true [外部线程_82]线程 return [外部线程_81]线程是否启动?false [外部线程_77,内部线程:Thread-72] started=false 成功执行 [外部线程_81]线程执行完毕,内部子线程正在执行中... [外部线程_81,内部线程:Thread-73] started=false 成功执行 [外部线程_84]线程是否启动?false [外部线程_84]线程执行完毕,内部子线程正在执行中... [外部线程_80]线程是否启动?true [外部线程_84,内部线程:Thread-74] started=false 成功执行 [外部线程_80]线程执行完毕,内部子线程正在执行中... [外部线程_79]线程是否启动?true [外部线程_80,内部线程:Thread-75] started=false 成功执行 ``` 解释说明: 1. “[外部线程_81]线程是否启动?false” 先于 “[外部线程_77,内部线程:Thread-72] started=false 成功执行”: [外部线程_77,内部线程:Thread-72] 执行完 started=false,还没执行 System.out 输出语句,[外部线程_81] 就已经拿到 started=false 的结果了。 2. “[外部线程_80]线程是否启动?true” 然后又 “[外部线程_80]线程执行完毕,内部子线程正在执行中...”: 这时[外部线程_80]让出了 cpu,等到时间片后再次执行时并没有 return,而是成功执行了内部子线程。 结论:started 在线程之间可以保证可见性的,但是具体原因,自己也没想明白。 ----- 自己套用了下面的 Happens-Before 规则: 0. Happens-Before 的传递性。 1. 管程中锁的规则。 2. 线程启动规则。 3. 线程终止规则。 4. 线程中断规则。 好像也无法推导出:为何在内部线程 rptThread 修改的 started 变量,可以保证可见性。 是根据什么规则,保证了 started 变量的可见性,老师可以帮忙分析一下么?期待您的回复,谢谢老师!!
    展开

    作者回复: println内部有锁,而且还有io操作,所以会让结果不准确

    共 3 条评论
    3
  • 马以
    2021-04-06
    这里加不加volatile其实影响不大,应为程序中的stop()方法中只是把terminated设置为true,即使多个线程同时访问,影响的也只是多上报一段时间,影响不大,个人观点
    2
  • 放牧人
    2020-12-26
    有趣的事,这里的terminate变量加上了volatile修饰,正常来说按照 happen-before的规则,对synchronize的解锁是能够被加锁可见的。 但这个应该是保证在一个线程下的可见,例如对Proxy对象的解锁是在主线程中调用的,加锁也在主线程,那么主线程加锁时可以查看到解锁变量的最新值,但是这个代码的加锁新开了一个线程C,这个时候可见性是传递不到线程C的,只能传递给执行解锁的线程即主线程,所以要加volatile修饰 terminate变量
    展开
    2
  • 蚝不鱿鱼
    2020-10-25
    大龄程序员的周末,必须来点精神食粮,不然就很慌,尽管它可能没啥用,但是谁知道呢,谁让咱输在了起跑线上呢……
    共 1 条评论
    2
  • IF You
    2020-01-16
    本文中的start()方法有问题,使用的sleep()方法,而该方法不会释放锁,导致调用stop的方法阻塞在获取监视器锁的状态,永远不能通过stop方法优雅停止线程
    共 1 条评论
    2
  • 虚竹
    2019-10-12
    王老师好,请教下: 1.自定义标志位终止线程时,是不是这样可以这样写? whlie(!Thread.currentThread()isInterrupted() || !terminated){} 2.线程池关闭时,完整的是这样吧? exec.shutdown(); while(true){ if(exec.isTerminated()){ print("所有的任务都结束了~"); } Thread.sleep(1000); }
    展开

    作者回复: 可以这样写

    1
  • 美美
    2019-07-15
    我看结束线程的示例都有while方法,如果没有while,如果中断呢?同时也没有处于休眠状态的话,是不是只能等程序自然结束了,是不是我的问题有问题。。。

    作者回复: 没有while可以关键点上检查,也可以等待自然结束,看实际需求

    1