理解 Java 任务的取消与关闭

阅读精选内容,加入我们投稿,点这里!
前言

        近一个月没发文,工作不忙人却有点郁闷,好好颓废了一个月,还是好好学习天天向上吧!努力保持周更吧,哦哈哈哦。

任务取消

       Java 没有提供任何机制来安全地终止线程,但提供了中断(Interruption),这是一种协作机制,能够使一个线程终止另一个线程的当前工作。

       一个在行为良好的软件与勉强运行的软件之间最主要区别是:行为良好的软件能够很完善地处理失败、关闭和取消等过程。

       取消某个操作的原因有很多:

       用户请求取消、有时间限制的操作、应用程序事件、错误、关闭等,在 Java 中没有一种安全的抢占方法来停止线程,因此也就没有安全的抢占方法来停止任务。只有一些协作式机制,使请求取消的任务和代码都遵循一种协商好的协议。

       一种协作机制能设置某个 "已请求取消(Cancellation Requested)"标志,而任务将定期地查看该标志。如果设置了这个标志,那么任务将提前结束。

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

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

 中断:

理解 Java 任务的取消与关闭

       上面的不可靠的取消操作将把生产者置于阻塞的操作中!

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

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

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

理解 Java 任务的取消与关闭

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

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

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

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

       如果任务代码能够响应中断,那么可以使用中断作为取消机制,并且利用许多库类中提供的中断支持。通常,中断是实现取消的最合理方式。

       BrokenPrimeProducer 中问题很容易解决(和简化):使用中断而不是 boolean 标志来请求取消。由于调用了阻塞的 put 方法,因此这里并不一定需要进行显式的检测,但执行检测却会使 PrimeProducer 对中断具有更高的响应性,因为它是在启动寻找素数任务之前检查中断的,而不是在任务完成之后。

理解 Java 任务的取消与关闭

中断策略:

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

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

       一个中断请求可以有一个或多个接收者 —— 中断线程池中的某个工作者线程,同时意味着 "取消当前任务" 和 "关闭工作者线程"。

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

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

       当检查到中断请求时,任务并不需要放弃所有的操作 —— 它可以推迟处理中断请求,并直到某个更适合的时刻。因此需要记住中断请求,并在完成当前任务后抛出 InterruptedException 或者表示已收到中断请求。

       无论任务把中断视为取消,还是其他某个中断响应操作,都应该小心地保存执行线程的中断状态。如果除了将 InterruptedException 传递给调用者外还需执行其他操作,那么应该捕获 InterruptedException 之后恢复中断状态:

理解 Java 任务的取消与关闭

       线程应该只能由其所有者中断,所有者可以将线程的中断策略信息封装到某个合适的取消机制中,例如关闭(showdown)方法。

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

响应中断:

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

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

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

理解 Java 任务的取消与关闭

 

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

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

       对于一些不支持取消但仍可以调用可中断阻塞方法的操作,它们必须在循环中调用这些方法,并在发现中断后重新尝试。在这种情况下,它们应该在本地保存中断状态,并在返回前恢复状态而不是在捕获 InterruptedException 时恢复状态。(通常,可中断的方法会在阻塞或进行重要的工作前首先检查中断,从而尽快地响应中断)。

理解 Java 任务的取消与关闭

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

       中断可以用来获取线程的注意,并且由中断线程保存的信息,可以为中断的线程提供进一步的指示。(当访问这些信息时,要确保使用同步。)

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

示例:计时运行

理解 Java 任务的取消与关闭

       在外部线程中安排中断是不合理的,它在调用线程中运行任务,并安排了一个取消任务,在运行指定的时间间隔后中断它。这解决了从任务中抛出未检查异常的问题,因为该异常会被 timedRun 的调用者捕获。但却破坏了以下规则:

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

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

理解 Java 任务的取消与关闭

       执行任务的线程拥有自己的执行策略,即使任务不响应中断,限时运行的方法仍能返回到它的调用者。在启动任务线程之后,timedRun 将执行一个限时的 join 方法。在 join 返回后,它将检查任务中是否有异常抛出,如果有,在调用 timedRun 的线程中再次抛出该异常。

       但由于它依赖于一个限时的 join,因此存在 join 的不足:无法知道执行控制是因为线程正常退出而返回还是因为 join 超时而返回。(这是 Thread API 一个缺陷,因为无论 join 是否成功地完成,在 Java 内存模型中都会有内存可见性结果,但 join 本身不会返回某个状态来表明它是否成功)

 

通过 Future 来实现取消任务:

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

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

理解 Java 任务的取消与关闭

处理不可中断的阻塞:

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

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

       Java.io 包中的同步 I/O:当中断一个正在 

InterruptibleChannel 上等待的线程时,将抛出 ClosedByInterruptException 并关闭链路(这还会使得其他在这条链路上阻塞的线程同样抛出 ClosedByInterruptException)。当关闭一个 InterruptibleChannel 时,将导致所有在链路操作上阻塞的线程都抛出 AsynchronousCloseException。大多数标准的 Channel 都实现了 InterruptibleChannel。

       Selector 的异步 I/O:如果一个线程在调用 

Selector.select 方法(在 java.nio.channels 中)时阻塞了,那么调用 close 或 wakeup 方法会使线程抛出 ClosedSelectorException 并提前返回。

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

理解 Java 任务的取消与关闭

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

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

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

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

理解 Java 任务的取消与关闭

理解 Java 任务的取消与关闭

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

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

停止基于线程的服务

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

       正确的封装原则是:除非拥有某个线程,否则不能对该线程进行操控。例如,中断线程或者修改线程的优先级等。线程有一个相应的所有者,即创建该线程的类。因此线程池是其工作者线程的所有者,如果要中断这些线程,那么应该使用线程池。

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

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

示例:日志服务

理解 Java 任务的取消与关闭

       通过调用 log 方法将日志消息放入某个队列中,并由其他线程来处理。产生日志消息的线程并不会将消息直接写入输出流,而是由 LogWriter 通过 BlockingQueue 将消息提交给日志线程,并由日志线程写入。这是一种多生产者单消费者的设计方式:每个调用 log 的操作都相当于一个生产者,而后台的日志线程则相当于消费者。如果消费者的处理速度低于生产者的生成速度,那么 BlockingQueue 将阻塞生产者,直到日志线程有能力处理新的日志消息。

       避免使 JVM 无法正常关闭,需要实现一种终止日志线程的方法。要停止日志线程是很容易的,因为它会反复条用 take,而 take 能响应中断。如果将日志线程修改为当捕获到 InterruptedException 时退出,那么只需要中断日志线程就能停止服务。

       如果只是使日志线程退出,那么还不是一种完备的关闭机制。这种直接关闭的做法会丢失那些正在等待被写入到日志的信息,不仅如此,其他线程将在调用 log 时被阻塞,因为日志消息队列是满的,因此这些线程将无法解除阻塞状态。当取消一个生产者-消费者操作时,需要同时取消生产者和消费者。在中断日志线程时会处理消费者,但在这个示例中,由于生产者并不是专门的线程,因此要取消它们将非常困难。

       另一种关闭 LogWriter 的方法是:设置某个 "已请求关闭" 标志,以避免进一步提交日志消息。

理解 Java 任务的取消与关闭

 

       log 的实现是一种 "先判断再运行" 的代码序列:生产者发现该服务还没有关闭,因此在关闭服务后仍然会将日志消息放入队列,这同样会使得生产者可能在调用 log 时阻塞并且无法解除阻塞状态。可以通过一些技巧来降低这种情况的发生概率(例如,在宣布队列被清空之前,让消费者等待数秒钟),但这些都没有解决问题的本质,即使很小的概率也可能导致程序发生故障。

       为 LogWriter 提供可靠关闭操作的方法是解决竞态条件问题,因而要使日志消息的提交操作变成原子操作。不希望在消息加入队列时去持有一个锁,因为 put 方法本身就可以阻塞。采用通过原子方式来检查关闭请求,并且有条件的递增一个计数器来 "保持" 提交信息的权利。

理解 Java 任务的取消与关闭

理解 Java 任务的取消与关闭

关闭 ExecutorService:

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

       强行关闭的速度更快,但风险也更大,因为任务很可能在执行到一半时被结束;而正常关闭虽然速度慢,但却更安全,因为 ExecutorService 会一直等待队列中的所有任务都执行完成后再关闭。

       简单的程序可以直接在 main 函数中启动和关闭全局的 ExecutorService。而在复杂程序中,通常会将 ExecutorService 封装在某个更高级别的服务中,并且该服务能提供其自己的生命周期方法。

理解 Java 任务的取消与关闭

       它将管理线程的工作委托给一个 ExecutorService,而不是由其自行管理。通过封装 ExecutorService,可以将所有权链(Ownership Chain)从应用程序扩展到服务以及线程,所有权链上的各个成员都将管理它所拥有的服务或线程的生命周期。

"毒丸" 对象:

       另一种关闭生产者-消费者服务的方式是使用 "毒丸(Poison Pill)" 对象:一个放在队列上的对象,其含义是 "当得到这个对象时,立即停止"。在 FIFO(先进先出)队列中,"毒丸" 对象将确保消费者在关闭之前首先完成队列中的所有工作,在提交 "毒丸" 对象之前提交的所有工作都会被处理,而生产者在提交了 "毒丸" 对象后,将不会再提交任何工作。

理解 Java 任务的取消与关闭

理解 Java 任务的取消与关闭

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

示例:只执行一次的服务

       某个方法需要处理一批任务,并且当所有任务都处理完成后才返回,那么可通过一个私有的 Executor 来简化服务的生命周期管理,其中该 Executor 的生命周期时由这个方法来控制的(在这种情况下,invokeAll 和 invokeAny 等方法通常会起较大的作用)。

理解 Java 任务的取消与关闭

       创建一个私有的 Executor,并向每台主机提交一个任务。然后当所有邮件检查任务都执行完成后,关闭 Executor 并等待结束(采用 AtomicBoolean 来替代 volatile 类型的 boolean,能从内部的 Runnable 中访问 hasNewMail 标志,因此它必须是 final 类型以免被修改)。

shutdownNow 局限性:

       当通过 shutdownNow 来强行关闭 ExecutorService 时,它会尝试取消正在执行的任务,并返回所有已提交但尚未开始的任务,从而将这些任务写入日志或者保存起来以便之后进行处理(返回的 Runnable 对象可能与提交给 ExecutorService 的 Runnable 对象并不相同:它们可能是被封装过的已提交任务)。

       然而,无法通过常规方法来找出哪些任务已经开始但尚未结束。这意味着我们无法在关闭过程中知道正在执行的任务的状态,除非任务本身会执行某种检查。要知道哪些任务还没有完成,你不仅需要知道哪些任务还没有开始,而且还需要知道当 Executor 关闭时哪些任务正在执行(在关闭过程中只会返回尚未开始的任务,而不会返回正在执行的任务。如果能返回所有这两种类型的任务,那么就不需要这种不确定的中间状态)。

理解 Java 任务的取消与关闭

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

理解 Java 任务的取消与关闭

理解 Java 任务的取消与关闭

       网页爬虫程序在工作通常是无穷尽的,因此当爬虫程序必须关闭时,通常希望保存它的状态,以便稍后重新启动。CrawlTask 提供了一个 getPage 方法,该方法能找出正在处理的页面。。当爬虫程序关闭时,无论是还没有开始的任务,还是那些被取消的任务,都将记录它们的 URL,因此爬虫程序重新启动时,就可以将这些 URL 的页面抓取任务加入到任务队列中。

       在 TrackingExecutor 中存在一个不可避免的竞态条件,从而产生 "误报" 问题:一些被认为已取消的任务实际上已经执行完成。这个问题的原因在于,在任务执行最后一条指令以及线程池将任务记录为 "结束" 的两个时刻之间,线程池可能被关闭。如果任务时幂等的(Idempotent,即使任务执行两次与执行一次会得到相同的结果),那么这不会存在问题,在网页爬虫程序中就是这种情况。否则,在应用程序中必须考虑这种风险,并对 "误报" 问题做好准备。

处理非正常的线程终止

       当线程发生故障时,应用程序可能看起来仍然在工作,所以这个失败很可能会被忽略。幸运的是,可以监测并防止在程序中 "遗漏" 线程的方法。

       导致线程提前死亡的最主要原因是 

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

       如果在 GUI 程序中丢失了事件分派线程,那么造成的影响将非常显著 —— 应用程序将停止处理事件并且 GUI 会因此失去响应。

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

       在任务处理线程(例如线程池中的工作者线程或者 Swing 的事件派发线程等)的生命周期中,将通过某个抽象机制(例如 Runnable)来调用许多未知的代码,我们应该对在这些线程中执行的代码能否表现出正确的行为保持怀疑。

       这些线程应该在 try-catch 代码块中调用这些任务,这样就能捕获那些未检查的异常,或者也可以使用 try-finally 代码块来确保框架能够知道线程非正常退出的情况,并做出正确的响应。

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

理解 Java 任务的取消与关闭

未捕获异常的处理:

       除了主动解决未检查异常,在 Thread API 中同样

提供了 UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。这两种方法时互补的,通过将二者结合在一起,能有效地防止线程泄露问题。

       当一个线程由于未捕获异常而退出时,JVM 会把这个事件报告给应用程序提供的 

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

理解 Java 任务的取消与关闭

       最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中。异常处理器还可以采取更直接的响应,例如尝试重新启动线程,关闭应用程序,或者执行其他修复或诊断等操作。

 

理解 Java 任务的取消与关闭

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

       要为线程池中的所有线程设置一个 

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 的关闭原因作出任何假设,因此在编写关闭钩子的代码时必须考虑周全。最后,关闭钩子必须尽快退出,因为它们会延迟 JVM 的结束时间,而用户可能希望 JVM 能尽快终止。

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

       由于关闭钩子将并发执行,因此在关闭日志文件时可能导致其他需要日志服务的关闭钩子产生问题。为了避免这种情况,关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的访问。实现这种功能的一种方式是对所有服务使用同一个关闭钩子(而不是每个服务使用一个不同的关闭钩子),并且在该关闭钩子中执行一系列的关闭操作。这确保了关闭操作在单个线程中串行执行,从而避免了在关闭操作之间出现的竞态条件或死锁问题。

理解 Java 任务的取消与关闭

 

守护线程:

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

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

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

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

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

 

终结器:

       避免使用终结器。

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

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

 

(完)

仅做读书笔记,参考文献:Java Concurrency in Practice

 

识别二维码,关注我,每周一更。

理解 Java 任务的取消与关闭

 

评论:

2 条评论,访客:2 条,站长:0 条
  1. 飞哥
    飞哥发布于: 

    受教了

  2. ?
    ?发布于: 

    写的很好

发表评论