02 如何优雅地停止线程

恋爱就像一场战争,开始容易,结束难。                                    --《花是爱》
img
​        想要启动线程的话,只需调用Thread类的start()方法,并在run()方法中定义需要执行的内容即可,灰常滴简单,但如何优雅地停止线程呢?就值得好好思考玩味了。

1. 为什么需要停止线程

​        通常情况下,线程被启动后,我们不会手动地将其停止,而是等待其完成任务后自然消亡。但在很多情况下是需要提前终止线程的,比如用户突然关闭了应用程序,再比如程序运行出现异常需要重启服务等。

​        正确优雅地停止线程,在这些特殊的业务场景下就会显得格外有价值了。也正是因此,能够想出一个健壮性足够好,并且能够安全处理好各种业务场景下,正确停止线程的方法程序更是非常的重要。但遗憾的是,Java并没有直接给出简单易用且安全优雅的方式来停止线程,这就需要我们想一些办法来曲线救国了。

2. 正确停止线程的方式

2.1 通知协作而非强制退出

​        在Java中正确停止线程的方式是使用Interrupt,不过Interrupt仅仅起到通知线程其需要被停止,但被通知的线程在收到通知后作出怎样的回应确实有完全的自主权的。既可能会立即响应将线程停止,也可能是等一段时间后再将线程停止,还有可能是根本不作出任何响应或停止线程。既然是如此一种情况,那为何Java还是使用这种通知协作的方式来停止线程,而不推荐强制的方式进行停止呢?

​        事实上,Java的设计者们之所以采用通知协作的方式,是希望程序间能够通过相互通知、互相协作地管理线程。这样的方式可以避免在对其他的线程的工作情况未知的情况下,贸然终止线程而引发的各种安全问题,因此只是通知线程,给工作线程留出一定的时间来进行整理和收尾工作。

​        比如:一个工作线程正在进行文件写入的操作,当其收到终止的信号后,需要根据自身的业务情况判断,是否可以立即终止还是先进行一些其他的比如回退或写入文件结束符和关闭文件等操作。如果贸然立即终止,可能会造成数据不完整或数据文件损坏的情况,而这样的情况无论是终止信号的发出者,还是工作线程本身都不愿出现的。

2.2 Interrupt的使用方式

​        具体看一下使用Interrupt进行线程终止的执行逻辑。

while (!Thread.currentThread().isInterrupted() && sth to do) {
    do sth
}

​        调用一个线程的Interrupt()方法后,该线程的中断状态标记就会被设置为true。这个中断状态标记是每个线程中都有的,在线程执行时应定期检查该标记值,如果为true则说明有程序希望终止该线程。

​        上面的代码可以看出,在while循环的判断条件中,先是通过Thread.curretThread.isInterrupted()判断线程状态,同时还需要判断是否有其他的事务需要处理,只有当两个条件都满足时,才会继续进行后续的操作。

​        下面再通过一个具体的例子来分析一下:

public class MyThread implements Runnable {
    @Override
    public void run() {
        int count = 0;
        while (!Thread.currentThread().isInterrupted() && count < 10000) {
            System.out.println("count = " + count++);
        }
    } 

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new MyThread());
        thread.start();
        Thread.sleep(3);
        thread.interrupt();
    }
}

​        MyThread类的run()方法的主要功能是循环打印1~9999,在每次循环时都会判断一下线程的中断状态标记是否为true。在main方法中启动MyThread之后主线程休眠3ms,就对MyThread线程进行中断。MyThread线程在接收到线程中断信号后,没有打印到9999就停止了循环,这种情况是使用Interrupt进行正常中断的情况。

2.3 休眠时中断

​        进一步考虑一种特殊的情况,如果线程是处于Sleep状态的,是否应该感知到中断信号呢?还是说等到线程的休眠状态结束后再接收中断信号?

​        细想的话就可以得出应当是允许休眠状态中的线程也接收中断信号并作出相应这样的判断的,因为如果需要等到休眠状态结束才能接收中断信号,这样响应中断的时延也未免太不及时了。Java设计者们也是充分考虑了这一问题,在Java中sleep、wait等方法可以使线程进入休眠状态,并且休眠状态下的线程是可以感受到中断信号的,会抛出InterruptedException异常,同时清除中断信号,将线程的中断状态标记设置为false,这样即便线程是处于休眠状态也不会长时间感受不到中断了。

2.4 最佳实践

2.4.1 方法直接抛异常,run()根据业务情况进行处理

​        实际的开发工作中往往是团队开发的模式,个人所编写的业务逻辑方法和模块也常常会被外部的人员使用,从而构建起一个完整的业务逻辑。如果我们所编写的代码方法中调用了sleep或wait等响应中断的方法,则会抛出InterruptedException,因而需要try/catch或throws的操作。在遇到需要抛出异常的情况下,如果只是在自己所写的方法中将异常进行了捕获,但是没有进行任何的处理,是非常不妥的。因为如果线程处于休眠状态时,外部线程向其发出中断信号试图终止线程,线程在抛出异常的同时会清除中断信号,而其抛出的异常被catch后没有进行任何处理,相当于是阻断了中断信号的传递。

​        比较推荐的方式是,每层的业务处理逻辑都遵循统一的异常处理规范,可以选择在catch到中断异常后进行正确的业务处理,或者是直接将异常进行抛出,这样中断信号便可以逐层传递到顶层,最终让run()方法来进行异常的捕获和处理。而run()方法本身是不具备抛出中断异常能力的,只能对异常进行try/catch,并根据业务的不同进行相应的处理。

void subTask() throws InterruptedException {
    Thread.sleep(1000);
}

2.4.2 再次Interrupt

​        除了上述的直接抛出异常的方式,还可以采用在catch语句中再次中断的方式,具体的做法是,在捕获InterruptedException异常后,在异常处理中再次调用Thread.currentThread().interrupt()函数。因为处在休眠状态中的线程,被中断的时候会抛出中断异常,同时会清除中断标识,在捕获异常后再次进行调用中断方法,相当于手动给线程添加了一个中断标识,该终端标识仍然是可以被捕获的。

private void reInterrupt() {
    try {
        Thread.sleep(100);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
        e.printStackTrace();
    }
}

​        这也是给了我们开发者一个警示,在编码过程中一定要对异常的处理格外警觉,避免不恰当的异常处理,从而引发类似中断信号被无意“吞噬”,进而导致线程无法中断的情况发生。

3. 错误停止线程的方式

3.1 常见错误方式

​        在JDK的早年版本中,提供了诸如stop(),suspend()和resume()这样的线程中断方法,但在JDK1.2版本就已经将这些方法标记为@Deprecated,如果强行使用的话,IDE也会给出友好的提示不建议使用。在此简单分析下为什么不建议使用这几个方法。

​        首先是stop()方法,stop()方法会将当前正在运行的线程强制立即中断,无论当前的线程处于什么样的状态,也不会给线程留出一定的时间用于处理在停止线程前必须的一些保存数据逻辑等,往往会造成数据完整性被破坏的问题。

​        suspend()和resume()方法通常是搭配使用的,suspend()方法最大的问题在于,线程并不释放锁就直接进入休眠状态,若是休眠状态还一直持有着锁就极有可能造成死锁问题了,因为这把锁在线程被resume()前是不会被释放的。比如这样一个场景,线程A调用suspend()方法,让线程B挂起,假如线程B在持有一把锁的情况下进入了挂起状态,而线程A若刚巧需要访问线程B所持有的这把锁,必然是会获取失败的,故而线程A会进入阻塞状态。由此线程A和线程B都无法进行后续的操作了,产生了死锁。也正是由于存在着这样的风险,suspend()和resume()方法也都被废弃了。

3.2 volatile修饰标记位

​        volatile关键字,通过禁止指令重排从而保证了有序性,又通过在读取工作内存数据时强制先将主内存的最新数据刷新至工作内存,从而保证了数据修改的可见性。因此在某些场景下会使用volatile修饰的标记位作为线程中断的标记。

3.2.1 适用场景

​        如下代码所示,通过实现 Runnable 接口定义了一个线程,在 run() 中主要实现了这样的功能,通过while循环打印出1000000内所有100的倍数,没执行一次循环都休眠1ms。在这个过程中通过使用了一个volatile修饰的变量cancled来作为线程中断的标记位。在main方法中启动线程后 5 s,就将canceled的值变更为true,从而实现对线程的中断操作。

public class VolatileRunnable implements Runnable {
    private volatile boolean canceled = false;

    @Override
    public void run() {
        int num = 0;
        try {
            while (!canceled && num <= 1_000_000) {
                if (num % 100 == 0) {
                    System.out.println(num + "是100的倍数。");
                }
                num++;
                Thread.sleep(1);
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(new VolatileRunnable());
        thread.start();
        Thread.sleep(5000);
        r.canceled = true;
    }
}

​        但是如上的这种中断线程的方式并不具有普适性,接下来我们就来看看这种方式在哪些情况下并不适合。

3.2.2 不适用场景

​        以下就以一个生产者/消费者模式的实例来演示一个采用volatile修饰的参数作为中断标记位不适用的场景。

​        首先是创建一个生产者,主要的作用是生产1000000以内的50的倍数,放置到一个阻塞队列storage中供消费者进行消费使用。在生产的过程中通过volatile修饰的canceled参数作为中断标记位,用来对生产过程进行中断。具体代码如下:

class Producer implements Runnable {
    public volatile boolean canceled = false;
    BlockingQueue storage
    public Producer(BlockingQueue storage) 
        this.storage = storage;
    }

    @Override
    public void run() {
        int num = 0;
        try {
            while (num <= 1_000_000 && !canceled) {
                if (num % 50 == 0) {
                    storage.put(num);
                    System.out.println(num + "是50的倍数,被放到仓库中了。");
                }
                num++;
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println("生产者结束运行");
        }
    }
}

​        然后是创建一个消费者,主要作用是产生一个随机数,如果该随机数小于或等于0.95,则从阻塞队列storage中消费一条数据,否则的话则说明消费者不需要数据了,此时就需要对生产者进行中断。具体代码如下:

class Consumer {

    BlockingQueue storage;

    public Consumer(BlockingQueue storage) {
        this.storage = storage;
    }

    public boolean needMoreNums() {
        if (Math.random() > 0.95) {
            return false;
        }
        return true;
    }
}

​        接下来就是在main方法中构建整个生产和消费过程,首先是创建一个生产者和消费者公用的存储仓库,设置仓库的容量为16(容量为2的倍数),然后是启动生产者,主线程先进入休眠状态1s钟,1s足够生产者将仓库塞满了。当达到仓库的最大容量后,生产者就不再向仓库中插入数据了,而是进入阻塞状态。1s后主线程苏醒,消费者开始进行消费,每次消费完后休眠100ms,在消费的过程中会进行判断是否需要继续产生数据。一旦消费者不再需要更多数据,则会将终端标记canceled值修改为true,并跳出循环体。具体代码如下:

public static void main(String[] args) throws InterruptedException {
        ArrayBlockingQueue storage = new ArrayBlockingQueue(8);
        Producer producer = new Producer(storage);
        Thread producerThread = new Thread(producer);
        producerThread.start();
        Thread.sleep(1000);
  
        Consumer consumer = new Consumer(storage);
        while (consumer.needMoreNums()) {
            System.out.println(consumer.storage.take() + "被消费了");
            Thread.sleep(100);
        }
        System.out.println("消费者不需要更多数据了。");

        //一旦消费不需要更多数据了,我们应该让生产者也停下来,但是实际情况却停不下来
        producer.canceled = true;
        System.out.println(producer.canceled);
    }
}

​        但实际的运行状况会如我们所预料的那般顺利吗?真实的情况是,在消费者已经不需要数据了,并将canceled的值变更为true之后,生产者仍然没有停止。这是因为生产者在执行storage.put(num);这一步的时候,由于storage已经满了,生产者只能进入阻塞状态,在这种阻塞状态结束前则无法进入下一次的循环判断此时的中断标记canceled的值已经变更为true了,从而无法及时的响应到中断。但如果在此种场景下使用的是Interrupt的话,即便是处于阻塞状态也是能够响应到中断信号的。

4. 总结

​        在上述的内容中依次说明了为什么会有除了正常终止的情况外,对线程进行中断有怎样的意义。然后给出了正确终止线程的方式,即使用Interrupt,同时对比着其他几种错误的中断线程的方式,进一步阐明了使用Interrupt的方式来中断线程的最佳实践,希望能够对各位小伙伴有所帮助。不当之处,欢迎批评指正。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

见贤不思齐

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值