取消与关闭 Java并发编程实战总结


        任务和线程的启动很容易。 在大多数时候, 我们都会让它们运行直到结束,或者让它们自行停止。然而,有时候我们希望提前结束任务或线程, 或许是因为用户取消了操作, 或者应用程序需要被快速关闭。

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

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

        生命周期结束(End-of-Lifecycle) 的问题会使任务、服务以及程序的设计和实现等过程变得复杂, 而这个在程序设计中非常重要的要素却经常被忽略。一个在行为良好的软件与勉强运行的软件之间的最主要区别就是, 行为良好的软件能很完善地处理失败、关闭和取消等过程。 本章将给出各种实现取消和中断的机制,以及如何编写任务和服务, 使它们能对取消请求做出响应。

任务取消

        如果外部代码能在某个操作正常完成之前将其置入 “完成” 状态, 那么这个操作就可以称为可取消的(Cancellable)。取消某个操作的原因很多:

a.用户请求取消。用户点击图形界面程序中的 “取消” 按钮, 或者通过管理接口来发出取消 请求,例如JMX(Java Management Extensions)。

b.有时间限制的操作。 例如, 某个应用程序需要在有限时间内搜索问题空间, 并在这个时间内选择最佳的解决方案。当计时器超时时,需要取消所有正在搜索的任务。

c.应用程序事件。例如,应用程序对某个问题空间进行分解井搜索,从而使不同的任务可以搜索问题空间中的不同区域。当其中一个任务找到了解决方案时, 所有其他仍在搜索的任务都将被取消。

d.错误。 网页爬虫程序搜索相关的页面, 并将页面或摘要数据保存到硬盘。 当一个爬虫任务发生错误时(例如,磁盘空间已满), 那么所有搜索任务都会取消,此时可能会记录它们的前状态,以便稍后重新启动。

e.关闭。当一个程序或服务关闭时, 必须对正在处理和等待处理的工作执行某种操作。在平缓的关闭过程中,当前正在执行的任务将继续执行直到完成,而在立即关闭过程中,当前的任 务则可能取消。

        在Java中没有一种安全的抢占式方法来停止线程, 因此也就没有安全的抢占式方法来停止任务。 只有一些协作式的机制, 使请求取消的任务和代码都遵循一种协商好的协议。

        其中一种协作机制能设置某个 “已请求取消(Cancellation Requested)"标志, 而任务将定期地查看该标志。如果设置了这个标志,那么任务将提前结束。程序清单7-1中就使用了这项技术, 其中的PrimeGenerator持续地枚举素数, 直到它被取消。cancel方法将设置 cancelled标志,并且主循环在搜索下一个素数之前会首先检查这个标志。(为了使这个过程能可靠地工作,标志 cancelled必须为volatile类型。)

        程序清单7-2给出了这个类的使用示例, 即让素数生成器运行1秒钟后取消。素数生成器通常井不会刚好在运行1秒钟后停止, 因为在请求取消的时刻和run方法中循环执行下一次检查之间可能存在延迟。cancel方法由finally块调用, 从而确保即使在调用sleep时被中断也能取消素数生成器的执行。如果cancel没有被调用, 那么搜索素数的线程将永远运行下去, 不断消耗CPU的时钟周期, 井使得JVM不能正常退出。

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

        考虑现实世界中停止支付(Stop-Payment)支票的示例。 银行通常都会规定如何提交一个停止支付的请求,在处理这些请求时需要做出哪些响应性保证, 以及当支付中断后需要遵守哪些流程(例如通知该事务中涉及的其他银行,以及对付款人的账户进行费用评估)。这些流程和保证放在一起就构成了支票支付的取消策略。

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

中断

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

        在程序清单7-3中的BrokenPrimeProducer就说明了这个问题。生产者线程生成素数, 并将它们放入一个阻塞队列。如果生产者的速度超过了消费者的处理速度, 队列将被填满,put方法也会阻塞。当生产者在put方法中阻塞时, 如果消费者希望取消生产者任务, 那么将发生什么情况?它可以调用cancel方法来设置cancelled标志, 但此时生产者却永远不能检查这个标志,因为它无法从阻塞的put方法中恢复过来(因为消费者此时已经停止从队列中取出素数,所以put方法将一直保持阻塞状态)。

        一些特殊的阻塞库的方法支持中断。线程中断是一种协作机制,线程可以通过这种机制来通知另一个线程,告诉它在合适的或者可能的情况下停止当前工作,并转而执行其他的工作。

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

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

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

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

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

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

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

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

        如程序清单7-5所示。在每次迭代循环中, 有两个位置可以检测出中断:在阻塞的put方法调用中, 以及在循环开始处查询中断状态时。由于调用了阻塞的put 方法, 因此这里并不一定需要进行显式的检测, 但执行检测却会使PrimeProducer 对中断具有更高的响应性,因为它是在启动寻找素数任务之前检查中断的, 而不是在任务完成之后。如果可中断的阻塞方法的调用频率并不高, 不足以获得足够的响应性, 那么显式地检测中断状态能起到一定的帮助作用。

中断策略

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

        最合理的中断策略是某种形式的线程级(Thread-Level)取消操作或服务级(Service-Level) 取消操作:尽快退出,在必要时进行清理, 通知某个所有者该线程已经退出。其他的中断策略, 例如暂停服务或重新开始服务,但对于那些包含非标准中断策略的线程或线程池, 只能用于能知道这些策略的任务中。

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

        任务不会在其自己拥有的线程中执行,而是在某个服务(例如线程池)拥有的线程中执行。 对于非线程所有者的代码来说(例如,对于线程池而言,任何在线程池实现以外的代码),应该小心地保存中断状态, 这样拥有线程的代码才能对中断做出响应,即使 “非所有者” 代码也可以做出响应。(当你为一户人家打扫房屋时,即使主人不在,也不应该把在这段时间内收到的邮件扔掉,而广该把邮件收起来,等主人回来以后再交给他们处理,尽管你可以阅读他们 的杂志。)

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

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

        任务不应该对执行该任务的线程的中断策略做出任何假设,除非该任务被专门设计为在服务中运行,并且在这些服务中包含特定的中断策略。 无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断状态。 如果除了将InterruptedException传递给调用者外还需要执行其他操作,那么应该在捕获InterruptedException之后恢复中断状态:

Thread.currentThread () . interrupt () ;

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

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

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

响应中断

        在5.4 节中, 当调用可中断的阻塞函数时, 例如Thread.sleep 或BlockingQueue.put 等, 有两种实用策略可用于处理InterruptedException:

·传递异常(可能在执行某个特定于任务的清除操作之后), 从而使你的方法也成为可中断的阻塞方法。

·恢复中断状态, 从而使调用栈中的上层代码能够对其进行处理。

        传递InterruptedExcepti on 与将InterruptedException 添加到throws 子句中一样容易, 如程序清单7-6 中的getNextTask 所示。


        如果不想或无法传递InterruptedException(或许通过Runnable 来定义任务), 那么需要寻找另一种方式来保存中断请求。一种标准的方法就是通过再次调用interrupt 来恢复中断状态。你不能屏蔽InterruptedException, 例如在catch 块中捕获到异常却不做任何处理, 除非在你的代码中实现了线程的中断策略。虽然PrimeProducer 屏蔽了中断, 但这是因为它已经知道线程将要结束, 因此在调用栈中已经没有上层代码需要知道中断信息。由千大多数代码并不知道它们将在哪个线程中运行, 因此应该保存中断状态。

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

        对于一些不支持取消但仍可以调用可中断阻塞方法的操作, 它们必须在循环中调用这些方法, 并在发现中断后重新尝试。 在这种情况下, 它们应该在本地保存中断状态, 并在返回前恢复状态而不是在捕获 InterruptedException 时恢复状态, 如程序清单 7-7 所示。 如果过早地设置中断状态, 就可能引起无限循环, 因为大多数可中断的阻塞方法都会在入口处检查中断状态, 并且当发现该状态已被设置时会立即抛出 lnterruptedException。(通常,可中断的方法会在阻 塞或进行重要的工作前首先检查中断, 从而尽快地响应中断)。

        如果代码不会调用可中断的阻塞方法,那么仍然可以通过在任务代码中轮询当前线程的中断状态来响应中断。要选择合适的轮询频率, 就需要在效率和响应性之间进行权衡。如果响应性要求较高, 那么不应该调用那些执行时间较长井且不响应中断的方法, 从而对可调用的库代码进行一些限制。

        在取消过程中可能涉及除了中断状态之外的其他状态。中断可以用来获得线程的注意, 并且由中断线程保存的信息, 可以为中断的线程提供进一步的指示。(当访问这些信息时, 要确保使用同步。)

        例如, 当一个由ThreadPoolExecutor 拥有的工作者线程检测到中断时, 它会检查线程池是否正在关闭。如果是, 它会在结束之前执行一些线程池清理工作, 否则它可能创建一个新线程将线程池恢复到合理的规模。

示例:计时运行

        许多问题永远也无法解决(例如, 枚举所有的素数), 而某些问题, 能很快得到答案, 也可能永远得不到答案。在这些情况下, 如果能够指定“ 最多花10 分钟搜索答案” 或者“ 枚举出在10 分钟内能找到的答案”, 那么将是非常有用的。

        程序清单7-2 中的aSecondOfPrimes 方法将启动一个PrimeGenerator, 并在1 秒钟后中断。尽管PrimeGenerator 可能需要超过1 秒的时间才能停止, 但它最终会发现中断, 然后停止,并使线程结束。在执行任务时的另一个方面是, 你希望知道在任务执行过程中是否会抛出异常。如果PrimeGenerator在指定时限内抛出了一个未检查的异常, 那么这个异常可能会被忽略,为素数生成器在另一个独立的线程中运行, 而这个线程并不会显式地处理异常。

        在程序清单中给出了在指定时间内运行一个任意的Runnable的示例。它在调用线程中运行任务, 并安排了一个取消任务, 在运行指定的时间间隔后中断它。这解决了从任务中抛出未检查异常的问题, 因为该异常会被timedRun的调用者捕获。

        这是一种非常简单的方法, 但却破坏了以下规则:在中断线程之前, 应该了解它的中断策略。由千timedRun可以从任意一个线程中调用, 因此它无法知道这个调用线程的中断策略。如果任务在超时之前完成, 那么中断timedRun所在线程的取消任务将在timedRun返回到调用者之后启动。我们不知道在这种情况下将运行什么代码, 但结果一定是不好的。(可以使用schedule返回的ScheduledFuture来取消这个取消任务以避免这种风险, 这种做法虽然可行,但却非常复杂。)

        而且, 如果任务不响应中断, 那么timedRun会在任务结束时才返回, 此时可能已经超过了指定的时限(或者还没有超过时限)。如果某个限时运行的服务没有在指定的时间内返回,那么将对调用者带来负面影响。

        在程序清单7-9中解决了aSecondOfPrimes的异常处理问题以及之前解决方案中的问题。执行任务的线程拥有自己的执行策略, 即使任务不响应中断, 限时运行的方法仍能返回到它的调用者。在启动任务线程之后,timedRun将执行一个限时的join方法。在join返回后, 它将检查任务中是否有异常抛出, 如果有的话,则会在调用timedRun的线程中再次抛出该异常。由于Throwable将在两个线程之间共享, 因此该变最被声明为volatile类型, 从而确保安全地将其从任务线程发布到timedRun线程。

        在这个示例的代码中解决了前面示例中的问题, 但由于它依赖于一个限时的join, 因此存在着join 的不足: 无法知道执行控制是因为线程正常退出而返回还是因为join 超时而返回。

通过Future 来实现取消

        我们已经使用了一种抽象机制来管理任务的生命周期, 处理异常, 以及实现取消, 即Future。通常, 使用现有库中的类比自行编写更好, 因此我们将继续使用Future 和任务执行框架来构建timedRun。

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

        除非你清楚线程的中断策略, 否则不要中断线程, 那么在什么情况下调用cancel 可以将参数指定为true? 执行任务的线程是由标准的Executor 创建的, 它实现了一种中断策略使得任务可以通过中断被取消, 所以如果任务在标准Executor 中运行, 井通过它们的Future 来取消任务, 那么可以设置maylnterruptl仅unning。当尝试取消某个任务时, 不宜直接中断线程池, 因为你井不知道当中断请求到达时正在运行什么任务一一只能通过任务的Future 来实现取消。这也是在编写任务时要将中断视为一个取消请求的另一个理由: 可以通过任务的Future 来取消它们。

        程序清单7-10给出了另一个版本的timedRun: 将任务提交给一个ExecutorService, 井通过一个定时的Future.get 来获得结果。如果get 在返回时抛出了一个TimeoutException, 那么任务将通过它的Future 来取消。(为了简化代码, 这个版本的timedRun 在finally 块中将直接调用Future.cancel, 因为取消一个已完成的任务不会带来任何影响。)如果任务在被取消前就抛出一个异常 ,那么该异常将被重新抛出以便由调用者来处理异常。在程序清单7-10中还给出了另种良好的编程习惯:取消那些不再需要结果的任务。(在程序清单6-13和程序清单6-16中使用 了相同的技术。)

        当Future.get抛出InterruptedException或TimeoutException时,如果你知道不再需要结果,那么就可以调用Future.cancel来取消任务。

处理不可中断的阻塞

        在Java库中,许多可阻塞的方法都是通过提前返回或者抛出InterruptedException来响应中断请求的,从而使开发人员更容易构建出能响应取消请求的任务。然而,并非所有的可阻塞方法或者阻塞机制都能响应中断;如果一个线程由于执行同步的Socket I/O或者等待获得内锁而阻塞,那么中断请求只能设置线程的中断状态,除此之外没有其他任何作用。对于那些由于执行不可中断操作而被阻塞的线程,可以使用类似于中断的手段来停止这些线程,但这要求我们必须知道线程阻塞的原因。

        Java.io包中的同步Socket I/O。在服务器应用程序中,最常见的阻塞I/O形式就是对套接字进行读取和写人。虽然InputStream和OutputStream中的read和write等方法都不会响应中断,但通过关闭底层的套接字,可以使得由于执行read或write 等方法而被阻塞的线程抛出一个SocketException。

        Java.io包中的同步 I/O。当中断一个正在InterruptibleChannel上等待的线程肘,将抛出ClosedByinterruptException 并关闭链路(这还会使得其他在这条链路上阻塞的线程同样抛出ClosedByinterruptException)。当关闭一个InterruptibleC􀇭annel时,将导致所有在链路操作上阻塞的线程都抛出AsynchronousC!oseException。大多数标准的Channel都实现了InterruptibleChannel。

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

        获取某个锁。如果一个线程由于等待某个内置锁而阻寒, 那么将无法响应中断, 因为线程认为它肯定会获得锁, 所以将不会理会中断请求。但是, 在Lock类中提供了lock:Interruptibly方法, 该方法允许在等待一个锁的同时仍能响应中断, 请参见第13

        程序清单7-11 的ReaderThread给出了如何封装非标准的取消操作。ReaderThread 管理了一个套接字连接, 它采用同步方式从该套接字中读取数据, 并将接收到的数据传递给processBuffer。为了结束某个用户的连接或者关闭服务器, ReaderThread改写了interrupt方法,使其既能处理标准的中断, 也能关闭底层的套接字。因此, 无论ReaderThread线程是在read方法中阻塞还是在某个可中断的阻塞方法中阻塞, 都可以被中断并停止执行当前的工作。

采用newTaskFor来封装非标准的取消

        我们可以通过newTaskFor方法来进一步优化ReaderThread中封装非标准取消的技术, 这是Java 6 在ThreadPoolExecutor 中的新增功能。当把一个Callable 提交给ExecutorService 时,submit 方法会返回一个Future, 我们可以通过这个Future 来取消任务。newTaskFor是一个方法, 它将创建Future 来代表任务。newTaskFor 还能返回一个RunnableFuture 接口, 该接口扩展了Future 和Runnable (并由FutureTask 实现)。

       通过定制表示任务的Future 可以改变Future.cancel 的行为。例如, 定制的取消代码可以实现日志记录或者收集取消操作的统计信息, 以及取消一些不响应中断的操作。通过改写interrupt 方法, ReaderThread 可以取消基于套接字的线程。同样, 通过改写任务的Future.cancel 方法也可以实现类似的功能。

        在程序清单7-12的CancellableTask中定义了一个CancellableTask 接口, 该接口扩展了Callable,并增加了一个cancel 方法和一个newTask 工厂方法来构造RunnableFuture 。CancellingExecutor扩展了ThreadPoolExecutor, 并通过改写newTaskFor 使得CancellableTask 可以创建自己的Future 。

        SocketUsingTask实现了CancellableTask,并定义了Future.cancel来关闭套接字和调用 super.cancel。如果SocketUsingTask通过其自己的Future来取消,那么底层的套接字将被关闭 井且线程将被中断。因此它提高了任务对取消操作的响应性:不仅能够在调用可中断方法的同 时确保响应取消操作,而且还能调用可阻调的套接字I/O方法。

停止基于线程的服务

        应用程序通常会创建拥有多个线程的服务, 例如线程池, 并且这些服务的生命周期通常比 创建它们的方法的生命周期更长。 如果应用程序准备退出, 那么这些服务所拥有的线程也需要 结束。 由于无法通过抢占式的方法来停止线程, 因此它们需要自行结束。

        正确的封装原则是:除非拥有某个线程, 否则不能对该线程进行操控。 例如, 中断线程 或者修改线程的优先级等。 在线程API中, 并没有对线程所有权给出正式的定义: 线程由 Thread对象表示, 并且像其他对象一样可以被自由共享。 然而, 线程有一个相应的所有者, 即创建该线程的类。 因此线程池是其工作者线程的所有者, 如果要中断这些线程, 那么应该使用线程池。

        与其他封装对象一样, 线程的所有权是不可传递的: 应用程序可以拥有服务, 服务也可 以拥有工作者线程, 但应用程序并不能拥有工作者线程, 因此应用程序不能直接停止工作者线 程。 相反,服务应该提供生命周期方法(Lifecycle Method)来关闭它自己以及它所拥有的线 程。 这样, 当应用程序关闭该服务时, 服务就可以关闭所有的线程了。在ExecutorSeivice 中 提供了shutdown 和shutdownNow等方法。 同样, 在其他拥有线程的服务中也应该提供类似 的关闭机制。

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

关闭ExecutorService

        在6.2.4节中,我们看到ExecutorService提供了两种关闭方法:使用shutdown正常关闭,以及使用shutdownNow强行关闭。在进行强行关闭时,shutdownNow首先关闭当前正在执行的任务,然后返回所有尚未启动的任务清单。

        这两种关闭方式的差别在于各自的安全性和响应性:强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;而正常关闭虽然速度慢,但却更安全,因为ExecutorService会一直等到队列中的所有任务都执行完成后才关闭。在其他拥有线程的服务中也应该考虑提供类似的关闭方式以供选择。

        简单的程序可以直接在main函数中启动和关闭全局的ExecutorService。而在复杂程序中,通常会将ExecutorService封装在某个更高级别的服务中,并且该服务能提供其自己的生命周期方法,例如程序清单7-16中LogService的一种变化形式,它将管理线程的工作委托给一个ExecutorService, 而不是由其自行管理。通过封装ExecutorService, 可以将所有权链(Ownership Chain)从应用程序扩展到服务以及线程,所有权链上的各个成员都将管理它所拥有的服务或线程的生命周期。

"毒丸” 对象

        另一种关闭生产者-消费者服务的方式就是使用 “ 毒丸(Poison Pill)"对象: “毒丸”一个放在队列上的对象, 其含义是: “ 当得到这个对象时, 立即停止。 ” 在FIFO(先进先出)队列中, “毒丸” 对象将确保消费者在关闭之前首先完成队列中的所有工作, 在提交 “ 毒丸 ” 对象之前提交的所有工作都会被处理, 而生产者在提交了 “ 毒丸 ” 对象后, 将不会再提交任何工作。在程序清单7-17、 程序清单7-18和程序清单7-19中给出一个单生产者-单消费者的桌面搜索示例(来自程序清单5-8), 在这个示例中使用了 “毒丸” 对象来关闭服务。



        只有在生产者和消费者的数量都已知的情况下,才可以使用 “毒丸 ” 对象。在 Indexing­Service 中采用的解决方案可以扩展到多个生产者:只需每个生产者都向队列中放入一个 “毒丸” 对象,并且消费者仅当在接收到 N(poducers)个 “毒丸 ” 对象时才停止。这种方法也可以扩展到多个消费者的情况,只需生产者将 N(consumers)个 “毒丸 ” 对象放入队列。然而,当生产者和消费者的数最较大时,这种方法将变得难以使用。只有在无界队列中,“毒丸 ” 对象才能可靠地工作。

shutdownNow的局限性

        当通过shutdownNow来强行关闭ExecutorService时,它会尝试取消正在执行任务,并返回所有已提交但尚未开始的任务从而将这些任务写入日志或者保存起来以便之后进行处理。        

        然而,我们无法通过常规方法来找出哪些任务已经开始但尚未结束。这意味着我们无法在关闭过程中知道正在执行的任务的状态,除非任务本身会执行某种检查。要知道哪些任务还没有完成,你不仅需要知道哪些任务还没有开始,而且还需要知道当Executor关闭时哪些任务正在执行。

        在程序清单7-21的TrackingExecutor中给出了如何在关闭过程中判断正在执行的任务。通过封装ExecutorService井使得execute (类似地还有submit, 在这里没有给出)记录哪些任务是在关闭后取消的,Trackin gExecutor可以找出哪些任务已经开始但还没有正常完成。在Executor结束后,getCancelled T asks 返回被取消的任务清单。要使这项技术能发挥作用,任务在返回时必须维持线程的中断状态,在所有设计良好的任务中都会实现这个功能。


        
处理非正常的线程终止

        当单线程的控制台程序由于发生了一个未捕获的异常而终止时,程序将停止运行, 并产生 与程序正常输出非常不同的栈追踪信息, 这种情况是很容易理解的。然而, 如果并发程序中的某个线程发生故障, 那么通常井不会如此明显。 在控制台中可能会输出栈追踪信息, 但没有人会观察控制台。 此外, 当线程发生故障时, 应用程序可能看起来仍然在工作, 所以这个失败很可能会被忽略。幸运的是, 我们有可以监测并防止在程序中 “遗漏” 线程的方法。

        导致线程提前死亡的最主要原因就是 RuntimeException。由于这些异常表示出现了某种编程错误或者其他不可修复的错误, 因此它们通常不会被捕获。 它们不会在调用栈中逐层传递, 而是默认地在控制台中输出栈追踪信息, 并终止线程。

        线程非正常退出的后果可能是良性的, 也可能是恶性的, 这要取决于线程在应用程序中的作用。 虽然在线程池中丢失一个线程可能会对性能带来一定影响, 但如果程序能在包含50个线程的线程池上运行良好, 那么在包含49个线程的线程池上通常也能运行良好。然而,如果在GUI程序中丢失了事件分派线程, 那么造成的影响将非常显著一一应用程序将停止处理事件并且GUI会因此失去响应。在第6章的OutOITime中给出了由于遗漏线程而造成的严重后果: Timer表示的服务将永远无法使用。

        任何代码都可能抛出一个RuntimeException 。每当调用另一个方法时, 都要对它的行为保持怀疑, 不要盲目地认为它一定会正常返回, 或者一定会抛出在方法原型中声明的某个已检查异常。对调用的代码越不熟悉,就越应该对其代码行为保持怀疑。

        在任务处理线程(例如线程池中的工作者线程或者Swing的事件派发线程等)的生命周期中, 将通过某种抽象机制(例如Runnable)来调用许多未知的代码, 我们应该对在这些线程中 执行的代码能否表现出正确的行为保持怀疑。像Swing事件线程这样的服务可能只是因为某个 编写不当的事件处理器抛出NullPointerException 而失败, 这种情况是非常糟糕的。因此,这些 线程应该在try-catch 代码块中调用这些任务, 这样就能捕获那些未检查的异常了, 或者也可以使用try-finally代码块 来确保框架能够知道线程非正常退出的情况,并做出正确的响应。在这种情况下, 你或许会考虑捕获RuntimeException, 即当通过Runnable 这样的抽象机制来调用未知的和不可信的代码时。


        在程序清单7-23中给出了如何在线程池内部构建一个工作者线程。如果任务抛出了一个未 检查异常, 那么它将使线程终结, 但会首先通知框架该线程已经终结。然后,框架可能会用新 的线程来代替这个工作线程, 也可能不会,因为线程池 正在关闭,或者当前已有足够多的线程能满足需要。ThreadPoolExecutor 和Swing都通过这项技术来确保行为糟糕的任务不会影响到后续任务的执行。当编写一个向线程池提交任务的工作者线程类时,或者调用不可信的外部代码时(例如动态加载的插件),使用这些方法中的某一种可以避免某个编写得糟糕的任务或插件不会影响调用它的整个线程。

未捕获异常的处理

        上节介绍了一种主动方法来解决未检查异常。在ThreadAPI中同样提供了UncaughtExceptionHandler, 它能检测出某个线程由于未捕获的异常而终结的情况。这两种方法是互补 的,通过将二者结合在一起,就能有效地防止线程泄漏问题。

        当一个线程由于未捕获异常而退出时,JVM会把这个事件报告给应用程序提供的UncaughtExceptionHandler异常处理器(见程序清单7-24)。如果没有提供任何异常处理器, 那么默认的行为是将栈追踪信息输出到System.error

        异常处理器如何处理未捕获异常, 取决于对服务质量的需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中, 如程序清单7-25 所示。异常处理器还可以采取更直接的响应, 例如尝试重新启动线程, 关闭应用程序, 或者执行其他修复或诊断等操作。


        在运行时间较长的应用程序中, 通常会为所有线程的未捕获异常指定同一个异常处理器, 并且该处理器至少会将异常信息记录到日志中

        要为线程池中的所有线程设置一个UncaughtExceptionHandler, 需要为ThreadPoolExecutor的构造函数提供一个ThreadFactory。(与所有的线程操控一样, 只有线程的所有者能够改变线程的 UncaughtExceptionHandler。)标准线程池允许当发生未捕获异常时结束线 程,但由于使用了一个 try-finally 代码块来接收通知, 因此当线程结束时, 将有新的线程来 代替它。如果没有提供捕获异常处理器或者其他的故障通知机制, 那么任务会悄悄失败,从而导致极大的混乱。如果你希望在任务由于发生异常而失败时获得通知, 并且执行一些特定于任务的恢复操作, 那么可以将任务封装在能捕获异常的 Runnable 或 Callable 中,或者改 ThreadPoolExecutor 的 afterExecute 方法。

        令人困惑的是, 只有通过 execute 提交的任务,才能将它抛出的异常交给未捕获异常处理 器, 而通过submit 提交的任务, 无论是抛出的未检查异常还是巳检查异常, 都将被认为是任务 返回状态的一部分。如果一个由 submit 提交的任务由于抛出了异常而结束, 那么这个异常将被 Future.get 封装在 ExecutionException 中重新抛出。

JVM关闭

        JVM 既可以正常关闭, 也可以强行关闭。正常关闭的触发方式有多种, 包括: 当最后一个“正常(非守护)“ 线程结束时, 或者当调用了System.exit 时, 或者通过其他特定于平台的方法关闭时(例如发送了SIGINT 信号或键入Ctrl-C)。虽然可以通过这些标准方法来正常闭JVM, 但也可以通过调用Runtime. halt 或者在操作系统中“ 杀死"JVM进程(例如发送SIGKILL) 来强行关闭JVM。

关闭钩子

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

        关闭钩子应该是线程安全的: 它们在访问共享数据时必须使用同步机制, 并且小心地避免发生死锁, 这与其他并发代码的要求相同。而且, 关闭钩子不应该对应用程序的状态(例如,其他服务是否已经关闭, 或者所有的正常线程是否已经执行完成)或者JVM 的关闭原因做出任何假设, 因此在编写关闭钩子的代码时必须考虑周全。最后, 关闭钩子必须尽快退出, 因为它们会延迟NM的结束时间, 而用户可能希望NM能尽快终止。关闭钩子可以用千实现服务或应用程序的清理工作, 例如删除临时文件, 或者清除无法由操作系统自动清除的资源。在程序清单7-26 中给出了如何使程序清单7-16 中的LogService 在start 方法中注册一个关闭钩子,从而确保在退出时关闭日志文件。

        由于关闭钓子将并发执行, 因此在关闭日志文件时可能导致其他需要日志服务的关闭钩子产生问题。 为了避免这种情况, 关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。 实现这种功能的一 种方式是对所有服务使用同一个关闭钩子(而不是每个服务使用一 个不同的关闭钩子), 并且在该关闭钩子中执行一系列的关闭操作。 这确保了关闭操作在单个线程中串行执行, 从而避免了在关闭操作之间出现竞态条件或死锁等问题。 无论是否使用关闭钓子, 都可以使用这项技术, 通过将各个关闭操作串行执行而不是并行执行, 可以消除许多潜在的故障。 当应用程序需要维护多个服务之间的显式依赖信息时, 这项技术可以确保关闭操作按照正确的顺序执行。

守护线程

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

        线程可分为两种:普通线程和守护线程。 在JVM启动时创建的所有线程中, 除了主线程 以外, 其他的线程都是守护线程(例如垃圾回收器以及其他执行辅助工作的线程)。 当创建一个新线程时, 新线程将继承创建它的线程的守护状态, 因此在默认情况下,主线程创建的所有线程都是普通线程。

        普通线程与守护线程之间的差异仅在于当线程退出时发生的操作。 当一 个线程退出时, JVM会检查其他正在运行的线程, 如果这些线程都是守护线程, 那么JVM会正常退出操作。 当JVM停止时, 所有仍然存在的守护线程都将被抛弃——既不会执行finally代码块, 也不会执行回卷栈, 而JVM只是直接退出。

        我们应尽可能少地使用守护线程——很少有操作能够在不进行清理的情况下被安全地抛弃。 特别是, 如果在守护线程中执行可能包含1/0操作的任务, 那么将是一 种危险的行为。护线程最好用于执行 “内部” 任务, 例如周期性地从内存的缓存中移除逾期的数据。

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

终结器

        当不再需要内存资源时, 可以通过垃圾回收器来回收它们, 但对于其他一 些资源, 例如文件句柄或套接字句柄, 当不再需要它们时, 必须显式地交还给操作系统。 为了实现这个功能,垃圾回收器对那些定义了 finalize 方法的对象会进行特殊处理:在回收器释放它们后, 调用它们的 finalize 方法,从而保证一些持久化的资源被释放。

        由于终结器可以在某个由 JVM 管理的线程中运行, 因此终结器访问的任何状态都可能被 多个线程访问, 这样就必须对其访问操作进行伺步。终结器井不能保证它们将在何时运行甚至是否会运行, 并且复杂的终结器通常还会在对象上产生巨大的性能开销。要编写正确的终结器 是非常困难的e。在大多数情况下, 通过使用 finally 代码块和显式的 close 方法, 能够比使用 终结器更好地管理资源。唯一的例外情况在千:当需要管理对象, 井且该对象持有的资源是通 过本地方法获得的。基于这些原因以及其他一些原因, 我们要尽量避免编写或使用包含终结器 的类(除非是平台库中的类)。

   避免使用终结器。

小结

        在任务、线程、服务以及应用程序等模块中的生命周期结束问题, 可能会增加它们在设计 和实现时的复杂性。Java 井没有提供某种抢占式的机制来取消操作或者终结线程。相反, 它提 供了一种协作式的中断机制来实现取消操作,但这要依赖千如何构建取消操作的协议, 以及能 否始终遵循这些协议。通过使用 FutureTask和 Executor 框架,可以帮助我们构建可取消的任务和服务。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容