Java并发编程实战读书笔记——第七章 取消与关闭

要使任务和线程能安全、快速、可靠地停止下来,并不容易。JAVA没有提供任何机制来安全也终止线程,但它提供了中断(Interruption),一种协作机制,使一个线程终止另一个线程的当前工作。

这种协作式的方法是必要的,我们很少希望某个任务、线程立即停止,因为这种立即停止会使共享的数据结构处于不一致的状态。相反,在编写任务和服务时可以使用一种协作的方式:当需要停止时,它们首先会清除当前正在执行的工作,然后再结束。因为任务本身的代码比发出取消请求的代码更清楚如何执行清除工作。

行为良好的软件能够很完善的处理失败、关闭和取消等过程。

本章将给出各种实现取消和中断的机制,以及如何编写任务和服务,使它们能对取消请求做出响应。

7.1 任务取消

如果外部代码能在某个操作正常完成之前将其置入完成状态,那么这个操作就可以称为可取消的。

一个可取消的任务必须拥有取消策略(Cancellation Policy),在这个策略中将详细地定义取消操作的How、When、以及What。即其他代码如何(How)请求取消的任务,任务在何时(When)检查是否已经请求了取消,以及在响应取消请求时应该执行哪些(What)操作。

PrimeGenerator使用了一种简单的取消策略:客户代码通过调用cancel来请求取消,PrimeGenerator在每次搜索素数前首先检查是否存在取消请求,如果存在就退出。

7.1.1 中断

PrimeGenerator中的取消机制最终会使得搜索素数的任务退出,但在退出过程中需要花费一定的时间。然而,如果使用这种方法的任务调用了一个阻塞方法,例如BlockingQueue.put,那么可能会产生一个更严重的问题——任务可能永远不会检查取消标志,因此永远不会结束

在Java的API或语言规范中,并没有将中断与任何取消语义关联起来,但实际上,如果在取消之外的其他操作中使用中断,那么都是不合适的,并且很难支撑起更大的应用。

每个线程都有一个boolean类型的中断状态。当中断线程时,这个线程的中断状态将被设置为true。在Thread中包含了中断线程以及查询线程中断状态的方法。interrupt方法能中断目标线程,而isInterrupted方法能返回目标线程的中断状态。静态的interrupted方法将清除当前线程的中断状态,并返回它之前的值,这也是清除中断状态的唯一方法。

这里写图片描述

​ 阻塞库的方法,例如Thread.sleep和Object.wait等,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应中断时执行的操作包括:清除中断状态,抛出InterruptedException,表示阻塞操作由于中断而提前结束。JVM并不能保证阻塞方法检测到中断的速度,但在实际情况中响应速度还是非常快的。

​ 当线程在非阻塞状态下中断时,它的中断状态将被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,如果不触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。

​ 调用interrupt并不意味着立即停止目标线程正在进行的工作,而只是传递了请求中断的消息

​ 对中断操作的正确理解是:它并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程下一个合适的时刻中断自己。有些方法,例如wait、sleep和join等,将严格地处理中断请求,当它们收到中断请求或者在开始执行时发现某个已被设置好的中断状态时,将抛出一个异常。设计良好的方法可以完全忽略这种请求,只要它们能使调用代码对中断请求进行某种处理。设计不好的方法可能会屏蔽中断请求,从而导致调用栈中的其他代码无法对中断请求作出响应。

​ 在使用静态的interrupted时应该小心,因为它会清除当前线程的中断状态。如果在调用interrupted时返回了true,那么除非你想屏蔽这个中断,否则必须对它进行处理——可以抛出InterruptedException,或者通过再次调用interrupt来恢复中断状态。

​ BrokenPrimeProducer说明了一些自定义的取消机制无法与可阻塞的库函数实现良好交互的原因。如果任务代码能够响应中断,那么可以使用中断作为取消机制,并且利用许多库类中提供的中断支持。

通常,中断是实现取消的最合理方式。

使用中断而不是boolean标志来请求取消,在每次迭代循环中,有两个位置可以检测出中断:在阻塞的put方法调用中,以及在循环开始处查询中断状态时。由于调用了阻塞的put方法,因此这里并不一定需要进行显式的检测,但执行检测会使PrimeProducer对中断具有更高的响应性。

try{
​ while(!Thread.currentThread().isInterrupted()){
}}catch(InterruptedException soncumed){
​ //允许线程退出
}
public void cancel() { interrupt(); }

7.1.2 中断策略

任务包含取消策略,线程同样应该包含中断策略。中断策略规定线程如何解释某个中断请求——当发现中断请求时,应该做哪些工作,哪些工作单元对于中断来说是原子操作,以及以多快的速度来响应中断。

最合理的中断策略是在某种形式的线程级取消操作或服务级取消操作:尽快退出,在必要时进行清理,通知某个所有者该线程已经退出。

区分任务和线程对中断的反应是很重要的。一个中断请求可以有一个或多个接收者——中断线程池中的某个工作者线程,同时意味着取消当前任务和关闭工作者线程。

任务不会在其自己拥有的线程中执行,而是在某个服务拥有的线程中执行。对于非线程所有者的代码来说,在线程池外应该小心地保存中断状态,这样拥有线程的代码才能对中断做出响应。

这就是为什么大多数可阻塞的库函数都只是抛出InterruptedException作为中断响应。它们永远不会在某个自己拥有的线程中运行,因此它们为任务或库代码实现了最合理的取消策略:尽快退出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。

当检查到中断请求时,任务并不需要放弃所有的操作——它可以推迟处理中断的请求,并直到某个更合适的时刻。因此需要记住中断请求,并在完成当前任务后抛出InterruptedException或者表示已收到中断请求。这项技术能够确保在更新过程中发生中断时,数据结构不会被破坏。

任务不应该对执行该任务的线程的中断策略做出任何假设。无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断操作。如果除了将InterruptedException传递给调用者外还需要执行其他操作,那么应该在捕获InterruptedException之后恢复中断状态:Thread.currentThread.interrupte();

正如任务代码不应该对其执行所在的线程的中断策略做出假设,执行取消操作的代码也不应该对线程的中断策略做出假设。**线程应该中能由其所有者中断。**所有者可以将线程的中断策略信息封装到某个合适的取消机制中。

由于每个线程拥有各自的中断策略,因此除非你知道中断对于该线程的含义,否则就不应该中断这个线程。

批评者曾嘲笑Java的中断功能,因为它没有提供抢占式中断机制,而且还强迫开发人员必须处理InterrupteException。然而,通过推迟中断请求的处理,开发人员能定制更灵活的中断策略,从而使应用程序在响应性和健壮性之间实现合理的平衡。

7.1.3 响应中断

在调用可中断的阻塞函数时,例如Thread.sleep或BlockingQueue.put等,有两种实用策略可用于处理InterruptedException1传递异常,可能在执行某个特定于任务的清除操作之后,从而使你的方法也成为可中断的阻塞方法 2恢复中断状态,从而使调用栈的上层代码能够对共进行处理,调用interrupt来恢复中断状态

只有实现了线程中断策略的代码才可以屏蔽中断请求。在常规的任务和库代码中都不应该屏蔽中断请求。

7.1.5 通过Future来实现取消

ExecutorSerivce.submit将返回一个Future来描述任务。Future拥有一个cancel方法,该方法带有一个boolean mayInterruputIfRunning参数,表示取消操作是否成功,它只表示任务是否能够接受中断,而不是表示任务是否能检测并处理中断。如果true并且任务当前正在某个线程中运行,那么这个线程能被中断,如果false,那么意味着若任务还没有启动,就不要运行它,这适用于不处理中断的任务中。

除非你清楚线程的中断策略,否则不要中断线程。那么在什么情况下调用cancel可以将参数指定为true? 1.执行任务的线程是由标准的Executor创建的,它实现了一种中断策略使得任务可以通过中断被取消 2任务在标准Executor中运行,并通过它们的Future来取消任务,而不是中断线程池

这里写图片描述

7.1.6 处理不可中断的阻塞

在Java库中,许多可阻塞的方法都是通过提前返回或者抛出InterruptedException来响应中断请求的,从而使开发人员更容易构建出能响应取消请求的任务。然而,并非所有的可阻塞方法或者机制都能响应中断:如果一个线程由于执行同步的SocketI/O或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态。

Java.io包中的同步Socket I/O。虽然InputStream和OutputStream中的read和write方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行read或write等方法而被阻塞的线程抛出一个SocketException。

Java.io包中的同步I/O。当中断一个正在InterruptibleChannel上等待的线程时,将抛出ClosedByInterruptException并关闭链路。当关闭一个InterruptibleChannel时,将导致所有在链路操作上阻塞的线程都抛出AsynchronousCloseException。

Selector的异步I/O,如果一个线程在调用Selector.select方法时阻塞了,那么调用close或wakeup方法会使线程抛出ClosedSelectorException并提前返回。

Lock类中提供了lockInterruptibly方法,该方法允许在等待一个锁的同时仍能响应中断。

ReaderThread给出了如何封闭非标准的取消操作。改写interrupte方法,使其既能处理标准的中断,也能关闭底层的套接字。因此,无论ReaderThread线程是在read方法中阻塞还是在某个可中断的阻塞方法中阻塞,都可以被中断并保证上执行当前的工作。

7.2 停止基于线程的服务

除非拥有某个线程,否则不能对该线程进行操控。线程有一个相应的所有者,即创建该线程的类。因此线程池是其工作线程的所有者,如果要中断这些线程,那么应该使用线程池。

对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供生命周期方法。

在ExecutorService中提供了shutdown和shutdownNow等方法。

7.2.1 示例:日志服务
7.2.2 关闭ExecutorService

使用shutdown正常关闭和shutdownNow强行关闭。差别在于安全性和响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;而正常关闭虽然速度慢 ,但却更安全,因为ExecutorService会一直等到队列中的所有任务都执行完成后才关闭。

7.2.3 毒丸对象

另一种关闭生产者——消费者服务的方式就是使用毒丸对象:毒丸是指一个放在队列上的对象,其含义是:当得到这个对象时,立即停止。在提交毒丸对象之后,生产都不会提交任务工作 ,消费者保证毒丸对象之前提交的所有工作都会被处理。

只有在生产者和消费者的数量都已知的情况下,才可以使用毒丸对象。然而,当产者和消费者的数量较大时,这种方法将变得难以使用。只有在无界队列中,毒丸对象都能可能地工作。

7.2.4 示例:只执行一次的服务

通过私有的Executor来简化服务的生命周期管理,其中该Executor的生命周期是由这个方法来控制的。在这种情况下,invokeAll和invokeAny等方法通常会起较大的作用。

用AtomicBoolean代替volatile类型的boolean,这样才能从内部的Runnable中访问和修改final的hasNewMail标志。

7.2.5 shutdownNow的局限性

shutdownNow无法通过常规方法找出哪些任务已经开始但尚未结束。

通过封闭ExecutorService并使得execute记录哪些任务是在关闭后取消的,TrackingExecutor可以找出哪些任务已经开始但还没有正常完成。

要使这项技术能发挥作用,任务在返回时必须维持线程的中断状态,在所有设计良好的任务中都会实现这个功能。

7.3 处理非正常的线程终止

典型的线程池工作者线程结构:如果任务抛出了一个未检查异常,那么它将使线程终结,但会首先通知框架该线程已终结。然后框架可能会用新的线程来代替这个工作线程,也可能不会,线程池正在关闭,或者当前已有足够多的线程能满足。

ThreadPoolExecutor和Swing都通过这项技术来确保行为不好的任务不会影响到后续任务的执行。

这里写图片描述

未捕获异常的处理

在Thread API中同样提供了Uncaught-ExceptionHandler,它能检测出某个线程是由于未捕获的异常而终结的情况。这两种方法是互补的,通过将二者结合在一起,就能有效的防止线程泄漏问题。

如果没有提供任务异常处理器,那么默认的行为是将栈追踪信息输出到System.err。

这里写图片描述

这里写图片描述

要为线程池中的所有线程设置一个UncaughtExceptionHandler,需要为ThreadPoolExecutor的构造函数提供一个ThreadFactory。

只有通过Execute提交的任务,才能将它抛出的异常交给未捕获异常处理器,而通过submit提交的任务,无论是抛出的未检查异常还是已检查异常,都将被认为是任务返回状态的一部分,通过Future.get封闭在ExecutionException中重新抛出。

7.4 JVM关闭

JVM正常关闭:当最后一个正常(非守护)线程结束时,或者调用System.exit时,或者通过发送SIGINT信号或者Ctrl+C。

强行关闭:Runtime.halt或者杀死JVM进程发送SIGKILL。

7.4.1 关闭钩子

关闭钩子可以用于实现服务或应用程序的清理工作,例如删除临时文件,或者清除无法由操作系统自动清除的资源。

关闭钩子应该是线程安全的,而且关闭钩子不应该对应用程序的状态或者JVM的关闭原因做出假设,最后关闭钩子必须尽快退出。

在正常关闭中,JVM首先会调用已注册的关闭钩子(Shutdown Hook)。关闭钩子是指通过Runtime.addShutdownHook注册的但尚未开始的线程。当所有的关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结器,然后再停止。JVM并不会停止或中断任何在关闭时仍然运行的应用程序线程。当JVM最终结束时,这些线程将会强行结束。如果关闭钩子或终结器没有执行完成,那么正常关闭会挂起,并且JVM必须被强行关闭。当被强行关闭时,只是关闭JVM,而不会运行钩子。

这里写图片描述

7.4.2 守护线程

有时候你希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍JVM的关闭。在这种情况下需要使用守护线程(Daemon Thread)。

线程分为两种:普通线程和守护线程。在JVM启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程。默认情况下主线程创建的所有线程都是普通线程。

普通线程与守护线程之间的差异仅在于当线程退出时发生的操作。当JVM停止时,所有仍然存在的守护线程都将被抛弃——既不会执行finally代码块,也不会执行回收,而只是直接退出。

守护线程最好用于执行内部任务,例如周期性地从内存的缓存中移除逾期的数据。

此外,守护线程通常不能用来替代应用程序管理程序中各个服务的生命周期。

7.4.3 终结器

对于文件句柄或套接字句柄,当不再需要它们时,必须显示地交还给操作系统。垃圾回收器对于那些定义了finalize方法的对象会进行特殊处理:在回收器释放它们后,调用它们的finalize方法,从而保证一些持久化的资源被释放。

要编写正确的终结器是非常困难的,并且复杂的终结器会产生巨大的性能开销。

在大多数情况下,通过使用finally代码块和显式的close方法,能够比使用终结器更好地管理资源。唯一的例外情况:当需要管理的对象并且持有的资源是通过本地方法获得的。

避免使用终结器。

小结:在任务、线程、服务以及应用程序等模块的生命周期结束问题,JAVA并没有提供某种抢占式的机制来取消操作或者终结线程。相反,它提供了一种协作式的中断机制来实现取消操作,但这需要依赖于如何构建取消操作的协议。通过使用FutureTask和Executor框架,可以帮助我们构建可取消的任务和服务。

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值