当我们使用多线程的时候,往往有一些场景,需要我们将正在执行的线程给停掉,比如说,当我们下载文件的时候,下载到一半不想下载了,这时我们希望可以取消下载操作,该怎么操作呢?

为什么不能用stop

当我们去Thread类里面找相关的接口时,发现有 个stop方法,看上去非常适合用来终止一个线程,但是这个方法上面标了个@Deprecated注解,非常明显,这是一个废弃方法,不建议使用它。主要有两个方面的原因:

  1. 因为这个方法会将线程直接杀掉,没有任何喘息机会,一旦线程被杀死,后面的代码逻辑就再也无法得到执行,而且我们无法确定线程关闭的时机,也就是说线程有可能在任何一行代码突然停止执行,这是非常危险的。
  2. 假如这个线程正持有某个锁,贸然将其杀死,会导致该线程持有的锁马上被释放,而曾经被该锁保护的资源,可能正处于一种非原子的状态中,此时被其他线程访问到,会产生不可预知的风险。

针对于第二种情况,可能不是很好理解,下面通过一个例子,说明一下:

public class StopDemo {

    public static void main(String[] args) {
        new ReadThread().start();
        ChangeThread changeThread = new ChangeThread();
        changeThread.start();
        Sleep.seconds(2);
        changeThread.stop();
    }
    
    private static int num;

    private static class ChangeThread extends Thread {
        public ChangeThread() {
            super("change-thread");
        }
      
        @Override
        public void run() {
            while (true) {
                synchronized (StopDemo.class) {
                    num++;
                    Sleep.seconds(1);
                    num--;
                }
            }
        }
    }

    private static class ReadThread extends Thread{
        public ReadThread() {
            super("read-thread");
        }
      
        @Override
        public void run() {
            while (true){
                synchronized (StopDemo.class){
                    if(num!=0){
                        Debug.debug("num值为:{}",num);
                        break;
                    }
                }
            }
        }
    }
}

首先,在main方法中,我们启动了两个线程,其中一个是change-thread线程,用于修改变量num的值,先加1等待1s后再减1,另一个是read-thread线程用于读取共享变量num的值,如果不为0则打印日志并退出。

由于读写线程用了同一把互斥锁,所以对于共享变量num的读和写是互斥的,正常情况下写线程一定是完成+1再休眠1s再-1这样的原子操作后,才会让释放锁,因此读线程读到的值一定是0,不会打印日志也不会退出。但是,由于我们在第8行代码执行了changeThread.stop(),可能导致写线程将变量加1后就直接退出了,最终读线程读到的值是1而不是0,也退出循环。

两阶段终止

既然stop()不建议使用,那是否有其他办法用来优雅的停止一个线程呢?答案是必须的,那就是两阶段终止(Two-phase Termination)方案。两阶段终止的两个阶段分别是指:

  1. 准备阶段:发出终止指令,通过设置中断标志,并发送中断信号,“通知”目标线程,可以准备停止了。
  2. 执行阶段:响应终止指令,接收到中断信号及标志,在此基础决定线程退出时机,并执行适当清理工作。

<img src="https://gitee.com/thomasChant/drawing-bed/raw/master/image-20210314114324792.png" alt="image-20210314114324792" style="zoom:50%;" />

在准备阶段,我们需要做两件事情:

  1. 设置中断标志:中断标志的作用是标识线程已经中断了,当线程读到这个标识后,就可以执行退出操作了。

  2. 发出中断信号:光设置中断标志还不行,如果线程当前处于阻塞状态,就算设置了中断标志,线程也无法检测到,这时就需要我们调用目标线程的中断方法 interrupt(),将其从阻塞状态中唤醒,而目标线程通过捕获InterruptedException异常,来侦测这个中断信号。需要注意的是,线程类中跟中断相关的api主要有以下三个,特别容易混淆,需要注意区分:

    方法签名作用
    public void interrupt()中断线程
    public static boolean interrupted()判断当前线程是否为中断状态并清除中断状态
    public boolean isInterrupted()判断线程是否为中断状态

可以看出,两阶段终止方案相比于stop()方案要更加优雅,打个比方,如果说stop像是不问青红皂白直接将罪犯就地正法,那两阶段终止方案就像是先将罪犯收押,待事情水落石出之后,再执行相应处罚,相对而言,这种方式更加人性化也很好的避免了冤假错案的发生。

实例

理论知识已经讲了很多,接下来,我们通过一个简单的例子,来看看到底如何通过两阶段终止线程,来实现文章开头所说的取消下载的功能。

public class TwoPhaseTerminationDemo {

    private static int percent;
    private static final int MAX = 100;

    public static void main(String[] args) throws InterruptedException {
        DownloadThread downloadThread = new DownloadThread();
        downloadThread.start();
        Thread.sleep(4000);
        downloadThread.stopMe();
    }

    private static class DownloadThread extends Thread{

        public DownloadThread() {
            super("download-thread");
        }

        @Override
        public void run() {
            while (true){
                if (isTerminated()){
                    Debug.debug("取消下载,退出");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    Debug.debug("已下载: {}%",++percent);
                    if(percent >= MAX){
                        Debug.debug("下载完成");
                    }
                } catch (InterruptedException e) {

                }
            }
        }

        public void stopMe(){
            interrupt();
        }

        public boolean isTerminated(){
            return Thread.currentThread().isInterrupted();
        }
    }
}

为了方便理解,这个例子对真实的下载做了简化模拟,假设下载线程每秒钟下载总进度的1%,在下载了4s后,执行stopMe()调用线程中断方法interrupt(),发出中断信号,第22行,线程检测状态,如果为中断状态,则打印"取消下载"并退出,可以看到,这个程序已经满足了上述所说的两阶段终止条件,但是当我们执行代码后,会发现程序依然我行我素的下载着,没有要停下来的意思。

2021-03-14 13:41:53 [download-thread] 已下载: 1%
2021-03-14 13:41:54 [download-thread] 已下载: 2%
2021-03-14 13:41:55 [download-thread] 已下载: 3%
2021-03-14 13:41:57 [download-thread] 已下载: 4%
2021-03-14 13:41:58 [download-thread] 已下载: 5%
2021-03-14 13:41:59 [download-thread] 已下载: 6%
2021-03-14 13:42:00 [download-thread] 已下载: 7%

原因是线程中断异常被捕获后,它的中断状态已经被jvm给清除了,所以我们需要重新设置一下线程的中断状态,在第34行加上以下代码

Thread.currentThread().interrupt();

这次再执行,会发现下载操作被取消了,达到了我们想要的结果

2021-03-14 13:53:13 [download-thread] 已下载: 1%
2021-03-14 13:53:14 [download-thread] 已下载: 2%
2021-03-14 13:53:15 [download-thread] 已下载: 3%
2021-03-14 13:53:16 [download-thread] 取消下载,退出

但是不要高兴的太早,因为这种写法并不完美,它依赖于线程的中断状态来退出线程,如果目标线程的代码中调用了第三方类库的接口,而这些接口在捕获中断异常后,清空了线程中断状态,但是没有重置,就会导致上面描述的那种错误情况,所以我们需要寻求更可靠的解决方案。

自定义中断标志

上面有提到,我们不能依赖于线程自身的中断状态,那么正确的做法应该怎么处理呢?其实只要自定义一个中断标志就行了。具体做法如下:

public class ImproveTwoPhaseTerminationDemo {

    private static int percent;
    private static final int MAX = 100;
   
    public static void main(String[] args) throws InterruptedException {
        DownloadThread downloadThread = new DownloadThread();
        downloadThread.start();
        Thread.sleep(4000);
        downloadThread.stopMe();
    }

    private static class DownloadThread extends Thread{

        private boolean terminated = false;//自定义中断标志
        
        public DownloadThread() {
            super("download-thread");
        }

        @Override
        public void run() {
            while (true){
                if (isTerminated()){
                    Debug.debug("取消下载,退出");
                    break;
                }
                try {
                    Thread.sleep(1000);
                    Debug.debug("已下载: {}%",++percent);
                    if(percent >= MAX){
                        Debug.debug("下载完成");
                    }
                } catch (InterruptedException e) {
                  Thread.currentThread().interrupt(); 
                }
            }
        }

        public void stopMe(){
            terminated = true;
            interrupt();
        }

        public boolean isTerminated(){
            return terminated;
        }
    }
}

可以看到,第15行加上了一个终止标志terminated,调用stopMe()方法的时候,将terminated设置为true,通过这个标志,我们就可以不依赖于线程自身的中断状态,而将线程进行中断了。

总结

这篇文章主要讲解了如何优雅的关闭一个线程,首先我们应该避免使用stop()方法,这种方法简单粗暴但具有不确定性,容易造成bug,正确的做法是通过两阶段终止方案,先发出中断请求,设置线程为中断状态,当线程侦测到中断状态后,再去执行中断后的清理逻辑。