Skip to content

《Java并发编程实战》7.取消与关闭(下) 处理非正常的线程终止 & JVM 关闭 #35

@funnycoding

Description

@funnycoding

7.3 处理非正常的线程终止

当「单线程」的控制台程序由于发生了一个未捕获的异常而终止时,程序将停止运行,并产生与程序正常输出截然不同的「栈追踪信息」,这种情况很容易理解。

然而,如果「并发程序」中的某个线程发生故障,那么通常并不会如此明显。

在控制台中可能会输出栈追踪信息,但没有人会观察控制台。

此外,当线程发生故障时,应用程序可能看起来仍然在工作,所以这个失败很可能会被**「忽略」**。

幸运的是,我们有可以监测并防止在程序中"遗漏" 线程的方法。

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

由于这些异常表示出现了某种**「 编程错误 」** 或其他 「不可修复的错误」,因此它们通常不会被 「捕获」。

它们不会在调用栈中逐层传递,而是默认地在控制台中输出追踪栈信息,并终止线程。

线程非正常退出的后果不可预测,可能是良性的,也可能是恶心的,这要取决于线程在应用程序中的作用。

虽然在线程池中丢失一个线程可能会对性能带来一定的影响,但如果程序能在包含 50个 线程的线程池上运行良好,那么在包含 49 个线程的线程池上通常也能运行良好。

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

在 第6章 {% post_link 读书笔记/java编程实战/6/6.任务执行(下) 任务执行(下) %} 的 OutOfTime 中给出了由于遗漏线程造成的严重后果: Timer 表示的服务将永远无法使用。

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

对调用过的代码越不熟悉,就月应该对其代码行为保持怀疑。

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

类似 Swing 事件线程这样的服务可能只是因为某个编写不当的事件处理器抛出的 NullPointerException而失败,这种情况是非常糟糕的。

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

在这种情况下,你或许会考虑捕获 RuntimeException ,即 当通过 Runnable 这样的抽象机制来调用位置的和不可信的代码时。

注:↑【但是这种做法的安全性存在着争议。当线程抛出一个 「未检查异常」时,整个应用程序都可能受到影响,但作为捕获运行时异常的替代方法 ——> 关闭整个应用程序 则显得更加不切实际。】

程序清单 7-23 中给出了如何在线程池内部构件一个工作者线程。 如果任务抛出了一个未检查异常,那么它将使线程终结,但会首先通知内部框架该线程已经终结。

然后,框架可能会用新线程来代替这个工作线程,也可能不会,因为线程池正在关闭,或者当前已有足够多的工作者线程能够满足需要。

THreadPoolExecutorSwing 都通过这项技术来确保行为糟糕的任务不会影响到后续任务的执行

当编写一个向线程池提交任务的工作者线程类时,或者调用 「不可信的外部代码」时,(例如动态加载的插件),使用这些方法中的某一种可以避免某个编写得糟糕的任务或插件不会影响调用它的整个线程。

【↑ 使用场景】

程序清单7-23 典型的线程池工作者线程结构

public void run() {
	Throwable thrown = null;
	try {
		while (isInterrupted()) {
			runTask(getTaskFromWorkQueue);
		}
	} catch (Throwable e) {
		thrown = e;
	} finally {
		//线程退出
		threadExited(this,thrown);
	}
}

未捕获异常的处理

上节介绍了一种「主动方法」 来解决「未检查异常」。

Thread API 中同样提供了 UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。

这两种方法是 互补的,通过将二者结合在一起,就能有效地防止线程泄漏问题。

当一个线程由于未捕获异常而退出时,JVM 会把这个事件报告给应用程序提供的 UncaughtExceptionHandler 异常处理器(程序清单7-24)。

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

程序清单 7-24 UncaughtExceptionHanlder 接口:

public interface UncaughtExceptionHandler {
	void uncaughtException(Thread t,Throwable e);
}

异常处理器如何处理未捕获异常,取决于对服务质量的需求。

最常见的「响应方式」 是将一个错误信息以及相应的栈追踪信息写入 「程序日志」 中,如程序清单 7-25所示。

异常处理器还可以采取更直接的响应:例如「重新启动线程」,「关闭应用程序」,或者执行其他修复或诊断等操作。

程序清单7-25 将异常写入日志的异常处理器 UncaughtExceptionHandler

public class UEHLogger implements Thread.UncaughtExceptionHandler {
    @Override
    public void uncaughtException(Thread t, Throwable e) {
        final Logger logger = Logger.getAnonymousLogger();
        logger.log(Level.SEVERE, "Thread terminated with exception: " + t.getName(), e);
    }
}

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

要为线程池中的所有线程设置一个 UncaughtExceptionHandler,需要为 ThreadPoolExecutor 的构造函数提供一个 ThreadFactory。(与所有的线程操控一样,只有线程所有者能够改变线程的 UncaughtExceptionHandler

「标准线程池」 允许当发生未捕获异常时结束线程,但由于使用了一个 try-finally 代码块来接收通知,因此当线程结束时,将有新的线程来代替它。

如果没有提供捕获异常处理器或者其他的故障通知机制,那么任务会悄悄失败,从而导致极大的混乱。

【↑ 对异常的处理是非常重要的】

如果你希望在任务由于发生异常而失败时获得通知,并且执行一些特定的针对任务的恢复操作,那么可以将任务封装在能捕获异常的 RunnableCallable 中,或者改写 ThreadPoolExecutorafterExecute 方法。

↑【失败时获得通知的做法】

令人「困惑」的是,只有通过 execute 提交的任务,才能将它抛出的异常交给未捕获异常处理器。

而通过 submit 提交的任务,无论是抛出的未检查异常还是已检查异常,都将被认为是任务返回状态的一部分。如果一个由 submit 提交的任务由于抛出了异常而结束,那么这个异常将被 Future.get 封装在 ExecutionException 中重新抛出。

7.4 JVM 关闭

JVM 既可以正常关闭,也可以强行关闭。

正常关闭的触发方式很多:

  • 当最后一个「非守护线程」的线程结束时
  • 调用 System.exit
  • 其他特定于具体平台的方法关闭时(例如发送了 SIGINT 信号 或者按下 Ctrl-C)

虽然可以通过这些「标准」方法来正常关闭 JVM,也可以通过调用 Runtime.halt 或者在操作系统中直接 kill JVM进程 来强行关闭 JVM。

7.4.1 关闭钩子

在正常关闭中,JVM首先调用所有已经注册的关闭钩子**(Shutdown Hook)**。

关闭钩子指的是通过 Runtime.addShutdownHook 注册但尚未开始的线程。

JVM 并不能保证关闭钩子的调用顺序。

在关闭应用程序线程时,如果有守护或非守护线程扔在运行中,那么这些线程接下来将与关闭的进程「并发执行」。

当所有关闭钩子都执行结束时,如果 runFinalizersOnExittrue,那么 JVM 将运行**「终结器」**,。然后再停止。

JVM 并不会停止和中断任何在关闭时仍然运行的应用程序线程。

当 JVM 最终结束时,这些线程将被「强行结束」。 如果关闭钩子或者终结器没有执行完成,那么正常关闭进程"挂起" 并且 JVM 必须被强行关闭

当被强行关闭时,不会运行关闭钩子,只会直接关闭 JVM

关闭钩子应该是线程安全的类:它们在访问「共享数据」时必须使用「同步机制」,并且小心地避免发生「死锁」,这与其他并发代码的要求相同。

并且,关闭钩子不应该对应用程序的「状态」(例如其他服务是否已经关闭,或者所有的线程是否已经执行完成)或者 JVM 关闭的原因做出任何假设。

因此在编写关闭钩子代码时必须「考虑周全」。 最后,关闭钩子必须「尽快退出」,因为它们会延迟 JVM 的结束时间,而用户可能希望 JVM 尽快地被终止。

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

在程序清单 7-26 中给出了如何使 7-16 中的 LogService 中的 start 方法中注册一个关闭钩子,从而确保在退出时关闭日志文件。

由于关闭钩子是「并发执行」的,因此在关闭日志文件时可能导致其他需要日志服务的关闭钩子产生问题。

为了避免这种情况,关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。

实现这种功能的一种方式:是对所有服务使用同一个钩子。而不是每个服务使用一个不同的钩子。并且在该关闭钩子中执行一系列的关闭操作。

这确保了关闭操作在单个线程中的串行执行,从而避免了关闭操作之间出现竞态条件或死锁等问题。

无论是否使用关闭钩子,都可以使用这样技术,通过将各个关闭操作串行执行而不是并行执行,可以消除许多潜在的故障。

【↑ 非常有用的经验】

当应用程序需要维护多个服务之间显示依赖的信息时,这项技术可以确保关闭操作按照「正确的顺序」执行。

7-26 注册一个关闭钩子来停止日志服务:

public void start() {
	Runtime.getRuntime().addShutdownHook (() -> {
		try {
			LogService.this.stop();
		}catch (InterruptedException ignored) {
		}
	}
}

7.4.2 守护线程

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

↑ 【使用场景】

线程可以分为:「普通线程」和「守护线程」两种。 JVM 启动时创建的所有线程中,除了「主线程」以外,其他线程都是守护线程(例如垃圾回收期以及其他执行辅助工作的线程)。

当创建一个新线程时,新线程将继承创建它的线程的守护状态,因此在默认情况下,主线程创建的所有线程都是普通线程。

普通线程与守护线程之间的差异仅在于当线程退出时发生的操作。

当线程退出时,JVM 会检查其他正在运行的线程,如果这些线程都是守护线程,那么 JVM 会正常退出。

JVM 停止时,所有仍然存在的守护线程都将被抛出 ——> 它们既不会执行 finally 代码块,也不会执行「回卷栈」,JVM 将直接退出。

我们应该尽可能地「少用」 守护线程—— 很少有操作能够在不进行清理的情况下被安全地抛弃。 特别是,如果在线程中执行很可能包含 I/O 操作的任务,那么这将是一件「危险」的行为。

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

【↑ 也就是最好 JVM自己使用,开发者少用,尤其不要在可能出现I/O的任务中使用,这样会导致临时数据无法被清理,而产生垃圾数据。】

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

↑ 这句话没看懂,线程替代生命周期是啥意思。

原文:Daemon threads are not a good substitute for properly managing the lifecycle of services within an application.

7.4.3 终结器

当不再需要内存资源时,可以通过垃圾回收器来对垃圾进回收。

但对于其他资源,例如 「文件句柄」 或者 套接字(Socket)「句柄」当不再需要它们时, 必须「显示地」还给 操作系统。

为了实现这个功能,「垃圾回收器」对那些定义了 finalizer 方法的对象会进行特殊处理:在回收器释放它们后,调用它们的 finalizer 方法,从而保证一些持久化的资源被释放。

由于终结器可以在某个由 JVM 管理的线程中运行,因此「终结器」 访问的任何状态都可能被「多个线程」访问,这样就必须怼其访问操作进行同步。

终结器并不能保证它们将何时运行甚至不保证是否运行,并且复杂的 「终结器」通常会在对象上产生巨大的「性能开销」。

要编写正确的「终结器」非常困难,在多数情况下,通过 finally 代码显示的 close 方法能够比使用终结器更好地管理资源。

唯一例外的情况:当需要管理对象,并且该对象持有的资源是通过「本地方法」获取的。 基于这些原因以及其他一些原因,我们要尽量避免编写使用 finalizer 的类。(除非是 JDK 中的类)

小结

在任务、线程、服务以及应用程序等模块中的生命周期结束问题,可能会增加它们在「设计」和「实现」时的复杂性。

Java 并没有提供某种「抢占式」的机制来取消操作或者终结线程。 Java 提供的是一种 「协作式」的中断机制来实现取消操作,但这要依赖如何构建取消操作的「协议」,以及能否始终遵循这些协议。

通过使用 FutureTaskExecutor 框架,可以帮助我们构建可取消的任务和服务。

Metadata

Metadata

Assignees

No one assigned

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions