取消与关闭——《java并发编程实战》
概述
任务和线程的启动很容易。在大多数时候,我们都会让它们运行直到结束,或者让它们自行停止。然而,有时候我们希望提前结束任务或者线程,或许是因为用户取消了操作,或者应用程序需要被快速关闭。
要使任务和线程能安全、快速、可靠地停止下来,并不是一件容易的事。java没有提供任何机制来安全地终止线程。但它提供了中断,这是一种协作机制,能够使一个线程终止另一个线程的当前工作。
生命周期结束的问题会使任务、服务以及程序的设计和实现等过程变得复杂,而这个在程序设计中非常重要的要素却经常被忽略。一个在行为良好的软件与勉强运行的软件之间的最主要的区别就是,行为良好的软件能很完善地处理失败、关闭和取消等过程。
任务取消
概述
如果外部代码能在某个操作正常完成之前将其置入“完成”状态,那么这个操作就可以称为可取消的。取消某个操作的原因有很多:用户请求取消、有时间限制的操作、应用程序事件、错误、关闭。
一种协作机制能设置某个“已请求取消”标志,而任务将定期地查看该标志。如果设置了这个标志,那么任务将提前结束。
一个可取消的任务必须拥有取消策略,在这个策略中将详细地定义取消操作的“How”、“When”以及“What”。即其他代码如何请求取消该任务,任务在何时检查是否已经请求了取消,以及在响应取消请求时应该执行哪些操作。
中断
PrimeGenerator中的取消机制最终会使得搜索素数的任务退出,但在退出过程中需要花费一定的时间。然而,如果使用这种方法的任务调用了一个阻塞方法,例如BlockingQueue.put,那将产生一个更严重的问题——任务可能永远不会检查取消标志,因此永远不会结束。
一些特殊的阻塞库的方法支持终端。线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在何时的或者可能的情况下停止当前工作,并转而执行其他的工作。
Thread库中的中断方法:
public void interrupt() {...}
public boolean isInterrupted() {...}
public static boolean interrupted() {....}
阻塞库方法,例如Thread.sleep和Object.wait等,都会检查线程何时中断,并且在发现中断时提前返回。它们在响应终端时执行的操作包括:清除终端状态,抛出InterruptedException,表示阻塞操作由于终端而提前结束。
当线程在非阻塞状态下中断时,它的中断状态将被设置,然后根据将被取消的操作来检查中断状态以判断发生了中断。通过这样的方法,中断操作将变得“有黏性”——如果不触发InterruptedException,那么中断状态将一直保持,直到明确地清除中断状态。
对中断操作的正确理解是:它并不会真正地中断一个正在运行的线程,而只是发出中断请求,然后由线程在下一个合适的时刻中断自己(这些时刻也被称为取消点)。有些方法,例如wait、sleep和join等,将严格的处理这种请求,当它们收到中断请求或者在开始执行时发现某个已经被设置好的中断状态时,将抛出一个异常。在使用静态的interrupted时应该小心,因为它会清除当前线程的中断状态。如果在调用interrupted时返回了true,除非你想屏蔽这个中断,否则必须对它进行处理——可以抛出InterruptedException,或者通过再次调用Interrupt来恢复中断状态。
如果任务代码能响应中断,那么可以使用中断作为取消机制,并且利用许多库类中提供的中断支持。
中断策略
中断策略规定线程如何解释某个中断请求——当发现中断请求时,应该做哪些工作(如果需要的话),哪些工作单元对于中断来说是原子操作,以及以多快的速度来响应终端。
最合理的中断策略是某种形式的线程级取消操作或者服务级取消操作:尽快逸出,在必要时进行清理,通知某个所有者该线程已经退出。此外还可以简历其他的中断策略,例如暂停服务或者重新开始服务,但对于那些包含非标准中断策略的线程或者线程池,只能用于知道这些策略的任务中。
任务不会在其自己拥有的线程中执行,而是在某个服务(例如线程池)拥有的线程中执行。对于非线程所有者的代码来说,应该小心地保存中断状态,这样拥有线程的代码才能对中断做出响应,及时“非所有者”代码也可以做出响应。这就是为什么大多数可阻塞的库函数都只是抛出InterruptedException作为中断响应。它们永远不会再某个由自己拥有的线程中运行,因为它们为任务或者库代码实现了最合理的取消策略:尽快退出执行流程,并把中断信息传递给调用者,从而使调用栈中的上层代码可以采取进一步的操作。当检查到中断请求时,任务并不需要放弃所有的操作——它可以推迟处理中断请求,并直某个更合适的时刻。因此需要机制中断请求,并在完成当前任务后抛出InterruptedException或者表示已收到中断请求。这项技术能确保在更新过程中发生中断时,数据结构不会被破坏。任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行并且在这些服务中包含特定的中断策略。五路任务把中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断状态。如果将InterruptedException传递给调用者外还需要其他操作,那么应该在捕获InterruptedException之后恢复中断状态:Thread.currentThread().interrupt();
正如任务代码不应该对其执行所在的线程的中断策略做出假设,执行取消的代码也不应该对线程的中断策略做出假设。线程应该只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,例如关闭方法。
响应中断
在调用可中断的阻塞函数时,例如Thread.sleep或BlockingQueue.put等,有两种实用策略可用于处理InterruptedException:
传递异常,从而使得你的方法也称为可终端的阻塞方法
恢复中断状态,从而使得调用栈中的上层代码能够对其进行处理
计时运行
在中断线程之前,应该了解它的中断策略
与join结合使用,缺点,不知道是执行流程是因为线程正常退出而返回还是因为join超时而返回。
通过Future实现取消
我们已经使用了一种抽象机制来管理任务的生命周期,处理异常,以及实现取消,即Future。通常,使用现有库中的类比自行编写更好。ExecutorService.submit将返回一个Future来描述任务。Future拥有一个cancel方法。该方法带有一个boolean类型的参数mayInterruptIfRunning,表示取消操作是否成功。(这只是表示任务是否能够接收中断,而不是表示任务是否能检测并处理中断。)如果mayInterruptIfRunning为true并且任务当前正在某个线程中运行,那么这个线程能被中断。如果这个参数为false,那么意味着“若任务还没有启动,就不要运行它”,这种方式应该用于那些不处理中断的任务中。
除非你清楚线程的中断策略,否则不要中断线程,那么在什么情况下调用cancel可以将参数指定为true?执行任务的线程是由标准的Executor创建的,它实现了一种中断策略使得任务可以通过中断被取消,所以如果任务在标准Executor中运行,并通过它们的Future来取消任务,那么可以设置mayInterruptIfRunning。当尝试取消某个任务时,不宜直接中断线程池,因为你并不知道当中断请求到达时正在运行什么任务——只能通过任务的Future来实现取消。这也是在编写任务时要将中断视为一个取消请求的另一个理由:可以通过任务的Future来取消它们。
处理不可中断的阻塞
在java库中,许多可阻塞方法都是通过提前返回或者抛出InterruptedException来响应中断请求的,从而使开发人员更容易构建出能响应取消请求的任务。然而,并非所有的可阻塞方法或者阻塞机制都能响应中断;如果一个线程由于执行同步的Socket I/O或者等待获得内置锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。对于那些由于执行不可终端操作而被阻塞的线程,可以使用类似中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因:
java.io中的同步Socket I/O。通过关闭底层的套接字,可以使得由于执行read或者write等方法而被阻塞的线程抛出一个SocketException。
java.io包中的同步I/O。当中断一个正在InterruptibleChannel上等待的线程,将抛出ClosedByInterruptException并关闭链路。当关闭一个InterruptibleChannel时,将导致所有在链路操作上阻塞的线程都抛出AsynchronousCloseException。大多数标准的Channel都实现了InterruptibleChannel
Selector的异步I/O。如果一个线程在调用Selector.select方法(在java.nio.channels中)时阻塞了,那么调用close或wakeup方法会使线程抛出ClosedSelectorException并提前返回
获取某个锁。如果一个线程由于等待某个内置锁而等待,那将无法响应终端,因为线程认为它肯定会获得锁,所以将不会理会中断请求。但是,在Lock类中提供了lockInterruptibly方法,该方法允许在等待一个锁的同时仍能响应中断
采用newTaskFor封装非标准的取消
newTaskFor是java 6在ThreadPoolExecutor中的新增功能。当把一个Callable提交给ExecutorService时,submit方法会返回一个Future,我们可以通过这个Future来取消任务。newTaskFor是一个工厂方法,它将创建Future来代表任务。newTaskFor还能返回一个RunnableFuture接口,该接口扩展了Future和Runnable(并由FutureTask实现)。
通过定制表示任务的Future可以改变Future.cancel的行为。
停止基于线程的服务
概述
应用程序通常会创建拥有多个线程的服务,例如线程池,并且这些服务的生命周期通常比创建它们的方法的生命周期更长。如果应用程序准备退出,那么这些服务所拥有的线程也需要结束。由于无法通过抢占式的方式来停止线程,因此它们需要自行结束。
正确的封装原则是:除非拥有某个线程,否则不能对该线程进行操控。线程有一个对应的所有者,即创建该线程的类。因此线程池是其工作者线程的所有者,如果要中断这些线程,那么应该使用线程池。与其他封装对象一样,线程的所有权是不可传递的:应用程序可以拥有服务,服务也可以拥有工作者线程,但应用程序并不能拥有工作者线程,因此应用程序不能直接停止工作者线程。相反,服务应该提供生命周期方法来关闭它自己以及它所拥有的线程。这样,当应用程序关闭该服务时,服务就可以关闭所有的线程了。在ExecutorService中提供了shutdown和shutdownNow等方法。同样,在其他拥有线程的服务中也应该提供类似的关闭机制。
对于持有线程的服务,只要服务的存在时间大于创建线程的方法的存在时间,那么就应该提供生命周期方法。
日志服务
为了使像LogWriter这样的服务在软件产品中能发挥实际的作用,还需要实现一种终止日志线程的方法,从而避免使JVM无法正常关闭。要停止日志线程是很容易的,因为它会反复调用take,而take能响应终端。如果将日志线程修改为当捕获到InterruptedException时退出,那么只需终端日志线程就能停止服务。
然而,如果只是使日志线程退出,那还不是一种完备的关闭机智。这种直接关闭的做法会丢失那些正在等待被写入到日志的信息。不仅如此,其他线程在将调用log时被阻塞,因为日志消息队列是满的,因此这些线程无法解除阻塞状态。当取消一个生产者-消费者操作时,需要同时取消生产者和消费者。
另一种关闭LogWriter的方法是:设置某个“已请求关闭”标签,以避免进一步提交日志消息。在收到关闭请求之后,消费者会吧队列中的所有消息写入日志,并解除所有在调用log时阻塞的生产者。然而,这个方法存在竞态条件问题,使得方法不可靠。
关闭ExecutorService
ExecutorService提供了两种关闭方法:使用shutdown正常关闭,以及使用shutdownNow强行关闭。在进行强行关闭时,shutdownNow首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。这两种关闭方式的差别在于各自的安全性和响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;而正常关闭虽然速度慢,但却更安全,因为ExecutorService会一直等到队列中的所有任务都被执行完成后才关闭。
“毒丸”对象
另一种关闭生产者-消费者服务的方式就是使用“毒丸”对象:“毒丸”是指一个放在队列上的对象,其含义是:"当得到这个对象时,立即停止"。
只执行一次的服务
如果某个方法需要处理一批任务,并且当所有任务都处理完成后才返回,那么可以通过一个私有的Executor来简化服务的生命周期管理,其中该Executor的生命周期是由这个方法控制的。(在这种情况下,invokeAll和invokeAny等方法通常会起比较大的作用)
shutdownNow的局限性
当通过shutdownNow来强行关闭ExecutorService时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务,从而将这些任务写入日志或者保存起来以便之后进行处理。
然而,我们无法通过常规方法来找出哪些任务已经开始但尚未结束,可以扩展ExecutorService实现。
处理非正常的线程终止
主动解决未检查异常,在可能异常代码区try-catch处理。
未捕获异常的处理器:Thread API中提供了UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。这两种方法是互补的,通过将两者结合在一起,就能有效地防止线程泄露问题。当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器。如归没有提供任何异常处理器,那么默认的行为是将栈追踪信息输出到System.err.
令人困惑的是,只有通过execute提交的任务,才能将它抛出的异常交给未捕获异常处理器,而通过submit提交的任务,无论是抛出的未检查异常还是已检查异常,都将被认为是任务返回状态的一部分。
jvm关闭
概述
JVM既可以正常关闭,也可以强行关闭。正常关闭的触发方式有很多,包括:当最后一个“正常(非守护)”线程结束时,或者当调用了System.exit时,或者通过其他特定平台的方法关闭时。虽然可以通过这些标准方法来正常关闭JVM,但也可以通过调用Runtime.halt或者在操作系统中“杀死”JVM进程来强行关闭JVM。
关闭钩子
在正常关闭中,JVN首先调用所有已注册的关闭钩子。关闭钩子是指通过Runtime.addShutdownHook注册的但尚未开始的线程。JVM并不能保证关闭钩子的调用顺序。在关闭应用程序时,如果有线程仍然在运行,那么这些线程接下来将与关闭进程并发执行。当所有的关闭钩子都执行结束时,如果runFinalizersOnExit为true,那么JVM将运行终结期,然后再停止。JVM并不会停止或中断任何在关闭时仍然运行的应用程序线程。当JVM最终结束时,这些线程将被强行结束。如果关闭钩子或终结器没有执行完成,那么正常关闭进程“挂起”并且JVM必须被强行关闭,当强行关闭时,只是关闭JVM,而不会运行关闭钩子。
关闭钩子应该是线程安全的:它们在访问共享数据时必须使用同步机制,并且小心地避免发生思索,这与其他并发代码的要求相同。而且,关闭钩子不应该对应用程序的状态或者JVM的关闭原因做出任何假设,因此在编写关闭钩子的代码时必须考虑周全。最后,关闭钩子必须尽快推出,因为它们会延迟JVM的结束时间,而用户可能希望JVM尽快终止。
关闭钩子可以用于实现服务或应用程序的清理工作,例如删除临时文件,或者清除无法由操作系统自动清除的资源。
守护线程
有时候,你希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍JVM的关闭。在这种情况下就需要使用守护线程。
线程可分为两种:普通线程和守护线程。在JVM启动时创建的所有线程中,除了主线程以外,其他的线程都是守护线程(例如垃圾回收器以及其他执行辅助工作的线程)。当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此在默认情况下,主线程创建的所有线程都是普通线程。
普通线程与守护线程之间的差异仅在于当线程退出时发生的操作。当一个线程退出时,JVM会检查其他正在运行的线程,如果这些线程都是守护线程,那么JVM会正常退出操作。当JVM停止时,所有仍然存在的守护线程都将被抛弃——即不会执行finally代码块,也不会执行回卷券,而JVM只是直接退出。
守护线程最好用于执行“内部”任务,例如周期性从内存的缓存中移除逾期的数据
终结器
当不再需要内存资源时,可以通过垃圾回收器来回收它们,但对于其他一些资源,例如文件句柄或套接字句柄,当不再需要它们时,必须显式地交还给操作系统。为了实现这个功能,垃圾回收器对那些定义了finalize方法的对象会进行特殊处理:在回收释放它们后,调用它们的finalize方法,从而保证一些持久化的资源被释放。由于终结器可以在某个由JVM管理的线程中运行,因此终结器访问的任何状态都可能被多个线程访问,这样就必须对其访问操作进行同步。终结器并不能保证它们将在何时运行甚至是否会运行,而且复杂的终结器通常还在对象上产生巨大的性能开销。要编写正确的终结器是非常困难的。在带动书情况下,通过finally代码块和显式的close方法,能够比使用终结器更好地管理资源。唯一的例外情况在于:当需要管理对象,并且该对象持有的资源是通过本地方法获得的。

浙公网安备 33010602011771号