前两篇文章讲述的内容,一个是通过给每个任务启动一个线程执行,一个使用线程池,从纯技术角度看,都是启动多线程去执行一个异步任务。

既然启动了,那么任务终止相关的部分该如何处理。本篇从技术角度聊如何优雅地终止线程。

{% post_link 读书笔记/极客时间/Java并发编程实战/第一部分—基础/09|线程生命周期 09|线程生命周期 %} 中提到过,线程执行完成或者出现异常时就会进入终止状态。这样看终止很简单,而本篇讨论的不是自己终止自己,而是在一个线程 T1 中,终止 T2 线程,而这里的"优雅"指的是不用简单粗暴的方式去终止 T2 ,而是给 T2 充足的时间处理没有完成的工作。

Thread 类曾经提供了一个 stop方法,用来终止线程,但是看源码会发现,这个方法已经被标注为过期方法不建议使用了,原因就是调用这个方法会立即杀死线程,被终止的线程没有机会处理当前未完成的工作。

下面介绍 Java 领域中优雅地终止线程的方法。

【关于这部分内容,《Java并发编程实战》中第七章:取消与关闭做了更加详细,深入的介绍,因为任务的终止这部分真的非常重要。】

如何理解两阶段终止模式

经过认真对比分析,前人已经总结了一套成熟的方案:两阶段终止模式。 顾名思义,将终止过程分成两个阶段:

  • 第一阶段是线程 T1 向要终止的线程 T2 发送终止命令
  • 第二阶段是 线程T2 响应终止命令

对应到 Java 中的终止命令,首先看线程状态的转换:

图中可以看到,一个线程进入终止状态的前提是线程进入 RUNNABLE 状态,而线程也可能处于休眠状态,处于休眠状态的线程想要终止首先要唤醒后进入 RUNNABLE 状态。

该如何做到呢? JavaThread 类提供了 interrupt() 方法,它可以将休眠状态的线程转换到 RUNNABLE 状态。

当线程转换到 RUNNABLE 状态之后,如何进行下一步的终止操作呢?Java 中没有强制终止线程的操作,优雅的方法是让线程自己找一个合适的机会执行完任务后退出,对应到代码里也就是 run() 方法执行完毕后退出。所以一般我们会设置一个是否被中断的标志位 boolean 变量,线程会在合适的时机检查这个标志位,一旦发现符合终止条件,则自动退出 run() 方法。

上面的过程就是之前提到的第二阶段:响应终止指令

综合以上两点,总结 Java 中的终止指令:包括两方面内容 interrupt() 方法线程终止的标志位

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

实际工作中,有些监控系统需要动态地采集一些数据,一般都是监控系统发送采集指令给被监控系统的监控代理,监控代理接收到指令之后,从监控目标收集数据,然后回传给监控系统。

详细过程如下图:

出于性能方面的考虑(有些监控系统对性能影响很大,所以不能持续监控),动态采集功能一般都会提供终止操作。

下面的示例代码是监控代理简化之后的实现,start() 方法启动一个新的线程 rptThread 来执行监控数据采集和回传的功能,stop() 方法需要优雅地终止 rptThread

stop() 方法该如何实现呢?

public class Proxy {
    boolean started = false;

    // 采集线程
    Thread rptThread;
    // 启动采集功能
    synchronized void start() {
        if (started) {
            return;
        }
        started = true;
        rptThread = new Thread(() -> {
            while (true) {
                // 具体的采集,回传业务逻辑的实现
                report();

                // 每隔2秒采集回传一次数据
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {

                }
            }
            // 执行到这里时说明线程马上要终止了,run 方法执行完毕
            started = false;
        });
        rptThread.start();
    }

    // 终止采集数据功能
    synchronized void stop() {
        // 如何实现?
    }
}

按照两阶段终止模式,首先要将线程 rptThread 状态转换到 RUNNABLE,方法很简单,调用 rptThread.interrupt() 就可以。线程 rptThread 的状态转换到 RUNNABLE 之后,如何优雅地终止呢?

下面的示例代码中,我们选择判断线程是否需要终止的标志位是线程自己的中断状态

Thread.currentThread.isInterrupted()

需要注意的是,捕获 Thread.sleep() 的中断异常之后,通过 Thread.currentThread().interrupt() 重新设置了线程的中断状态,因为 JVM 的异常处理会清楚线程的中断状态

public class Proxy {
    boolean started = false;

    // 采集线程
    Thread rptThread;
    // 启动采集功能
    synchronized void start() {
        if (started) {
            return;
        }
        started = true;
        rptThread = new Thread(() -> {
            // 通过判断线程的中断状态来决定是否执行采集逻辑
            while (!Thread.currentThread().isInterrupted()) {
                // 具体的采集,回传业务逻辑的实现
                report();
                // 每隔2秒采集回传一次数据
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    // 异常处理会清除线程的中断状态,所以这里重新将线程状态设置为 true
                    Thread.currentThread().interrupt();

                }
            }
            // 执行到这里时说明线程马上要终止了,run 方法执行完毕
            started = false;
        });
        rptThread.start();
    }

    // 终止采集数据功能
    synchronized void stop() {
        rptThread.interrupt();
    }
}

上面的示例代码可以解决当前的问题,但是在实际工作中应该谨慎使用。因为在实际中我们很可能在线程的 run() 方法中调用第三方库提供的方法,而我们无法保证第三方类库正确处理线程的中断异常。

例如在第三方库捕捉到了 Thread.sleep() 方法抛出的中断异常后,没有设置线程的中断状态,就会导致线程不能正常终止。所以在实际工作中强烈建议设置自己的线程终止标志位

例如在下面代码中,使用 isTerminated 作为线程终止标志,此时无论是否正确处理了线程的中断异常,都不会影响线程地终止:

public class Proxy {
    boolean started = false;
    // 线程是否被中断的状态
    boolean terminated = false;

    // 采集线程
    Thread rptThread;
    // 启动采集功能
    synchronized void start() {
        if (started) {
            return;
        }
        started = true;
        terminated = false;
        rptThread = new Thread(() -> {
            // 通过判断线程的中断状态来决定是否执行采集逻辑
            while (!terminated) {
                // 具体的采集,回传业务逻辑的实现
                report();
                // 每隔2秒采集回传一次数据
                try {
                    Thread.sleep(2000);
                } catch (InterruptedException e) {
                    // 异常处理会清除线程的中断状态,所以这里重新将线程状态设置为 true
                    Thread.currentThread().interrupt();
                }
            }
            // 执行到这里时说明线程马上要终止了,run 方法执行完毕
            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 语言中使用两阶段模式终止线程需要注意两个关键点:

  1. 只检查终止标志位是不够的,因为线程很可能处于休眠状态
  2. 只检查线程中断状态也是不够的,因为依赖的第三方类库很可能没有正确处理中断

当使用 Java 线程池管理线程时,需要依赖线程池提供的 shutdonwNow 和 shutdown 方法终止线程池。使用时需要注意它们的适用场景,尤其是 shutdownNow 方法,使用的时候一定要谨慎。

Q.E.D.

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

最是人间留不住,曾是惊鸿照影来。