Java并发编程学习15-深入探索任务关闭机制(非正常线程终止与JVM关闭详解)

引言

在软件开发中,任务关闭是一个至关重要的环节,它关乎到应用程序的稳定性和资源的有效管理。在之前的篇章中,我们已经初步探讨了任务关闭的一些基本概念和原则。然而,任务关闭的复杂性在于,它不仅仅涉及到正常情况下的资源释放,还需要处理各种异常情况,如非正常的线程终止以及JVM的关闭。这些异常情况如果处理不当,可能会导致资源泄露、数据丢失甚至系统崩溃。因此,本文将深入探索非正常线程终止的处理机制以及JVM关闭时的注意事项,帮助开发者更好地掌握任务关闭的精髓,确保应用程序能够优雅地处理各种关闭场景。

主要内容

1. 处理非正常的线程终止

我们知道,当单线程的控制台程序由于发生了一个未捕获的异常而终止时,程序将停止运行,并在控制台输出该异常的栈追踪信息。

那如果并发程序中某个线程因为发生故障而终止,那应用程序会怎么样呢 ?

实际上虽然某个线程发生了故障了,但我们的应用程序可能仍然正常运行。即便在运行日志中可能会输出栈追踪信息,因为程序正常运行,我们也很难去关注到,从而这种失败很可能会被我们忽略掉。

那通常是什么原因导致线程终止的呢 ?

通常最主要的原因就是运行时异常【RuntimeException】。这一类异常由于表示出现了某种编程错误或者其他不可修复的错误,通常它们不会被程序捕获。它们也不会在调用栈中逐层传递,而是默认地在控制台中输出栈追踪信息,并终止线程。

线程非正常退出的后果可能是良性的,也可能是恶性的,这要取决于线程在应用程序中的作用。例如,如果在 GUI 程序中丢失了事件分派线程,那么应用程序将停止处理事件并且 GUI 程序会因此失去响应。

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

下面我们来看一下如下的示例【典型的线程池工作者线程结构】

1
2
3
4
5
6
7
8
9
10
11
public void run() {
Throwable thrown = null;
try {
while (!isInterrupted())
runTask(getTaskFromWorkQueue());
} catch (Throwable e) {
thrown = e;
} finally {
threadExited(this, thrown);
}
}

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

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

1.1 未捕获异常的处理

上面我们介绍了一种主动方法来解决未检查异常,而在 Thread API 中同样提供了 UncaughtExceptionHandler,它能检测出某个线程由于未捕获的异常而终结的情况。两者结合,能有效地防止线程泄露问题。

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

知识点:

  • Java 5.0 之前,控制 UncaughtExceptionHandler 的唯一方法就是对 ThreadGroup 进行子类化。
  • Java 5.0 及之后 的版本中,可以通过 Thread.setUncaughtExceptionHandler 为每个线程设置一个 UncaughtExceptionHandler,还可以使用 setDefaultUncaughtExceptionHandler 来设置默认的 UncaughtExceptionHandler
  • 在这些异常处理器中,只有其中一个将被调用 — JVM 首先搜索每个线程的异常处理器,然后再搜索一个 ThreadGroup 的异常处理器。ThreadGroup 中的默认异常处理器实现将异常处理工作逐层委托给它的上层 ThreadGroup,直到其中某个 ThreadGroup 的异常处理器能够处理该未捕获异常,否则将一直传递到顶层的 ThreadGroup。顶层的 ThreadGroup 的异常处理器委托给默认的系统处理器(如果存在,在默认情况下为空),否则将把栈追踪信息输出到控制台。

下面我们来看一下 UncaughtExceptionHandler 接口:

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

异常处理器如何处理未捕获异常,取决于对服务质量的需求。最常见的响应方式是将一个错误信息以及相应的栈追踪信息写入应用程序日志中。如下所示:

1
2
3
4
5
6
public class UEHLogger implements Thread.UncaughtExceptionHandler {
public void uncaughtException(Thread t, Throwable e) {
Logger logger = Logger.getAnonymousLogger();
logger.log(Level.SEVERE, "Thread terminated with exception: " + t.getName(), e);
}
}

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

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

实际上很多场景下,我们都是在使用线程池,那么该如何为线程池中的所有线程指定一个异常处理器呢?

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

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

如果没有提供捕获异常处理器或者其他的故障通知机制,那么任务会悄悄失败,从而引起更大的问题。

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

另外需要注意的是:

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

2. JVM关闭

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

正常关闭的触发方法有多种,如下:

  • 当最后一个 “正常(非守护)” 线程结束时
  • 当调用了 System.exit
  • 通过其他特定于平台的方法关闭(例如发送了 SIGINT 信号或键入 Ctrl+C

强行关闭的触发方法,有如下:

  • 调用 Runtime.halt(int status)
  • 在操作系统中 “杀死” JVM 进程(例如发送 SIGKILL

说到 JVM 正常关闭,就不得不提接下来的主角 – 关闭钩子

2.1 关闭钩子

何为关闭钩子 ?

关闭钩子是指通过 Runtime.addShutdownHook 注册的但尚未开始的线程。它只有在 JVM 正常关闭才会执行,在强制关闭时不会执行。

JVM 关闭过程中,有哪些需要注意的呢 ?

  • 在正常关闭中,JVM 首先调用所有已注册的关闭钩子(Shutdown Hook)。不过 JVM 并不能保证关闭钩子的调用顺序。

  • 在关闭应用程序线程时,如果有(守护或非守护)线程仍然在运行,那么这些线程接下来将与关闭进程并发执行。

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

  • JVM 并不会停止或中断任何在关闭时仍然运行的应用程序线程。当 JVM 最终结束时,这些线程将被强行结束。

  • 如果 关闭钩子终结器 没有执行完成,那么正常关闭进程 “挂起” 并且 JVM 必须被强行关闭。当被强行关闭时,只是关闭 JVM,而不会运行关闭钩子。

关闭钩子在编写和使用上应该注意什么 ?

  • 关闭钩子应该是 线程安全 的。它们在访问共享数据时必须使用同步机制,并且小心地避免发生死锁,这与其他并发代码的要求相同。
  • 关闭钩子不应该对应用程序的状态(例如,其他服务是否已经关闭,或者所有的正常线程是否已经执行完成)或者 JVM 的关闭原因做出任何假设,因此在编写关闭钩子的代码时必须考虑周全。
  • 关闭钩子必须 尽快退出,因为它们会延迟 JVM 的结束时间,而用户可能希望 JVM 能尽快终止。

说了这么多,那关闭钩子可以用来做什么呢 ?

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

下面我们再来看一个示例【通过注册一个关闭钩子来停止日志服务】:

1
2
3
4
5
6
7
8
9
10
11
public void start() {
Runtime.getRuntime().addShutdownHook(new Thread(){
public void run() {
try {
LogService.this.stop();
} catch (InterruptedException ignored) {
//
}
}
});
}

上述示例是 LogService 在其 start 方法中注册一个关闭钩子,从而确保在退出时关闭日志文件。

由于关闭钩子将并发执行,因此在关闭日志文件时可能导致其他需要日志服务的关闭钩子产生问题。那为了避免这种情况,关闭钩子不应该依赖那些可能被应用程序或其他关闭钩子关闭的服务。

实现上述功能的一种方式是对所有服务使用同一个关闭钩子(而不是每个服务使用一个不同的关闭钩子),并且在该关闭钩子中执行一系列的关闭操作。这确保了关闭操作在单个线程中串行执行,从而避免了在关闭操作之间出现竞态条件或死锁等问题。

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

当应用程序需要维护多个服务之间的显式依赖信息时,上述可以确保关闭操作按照正确的顺序执行。

2.2 守护线程

何为守护线程?

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

什么情况下,我们需要使用守护线程 ?

有时候,我们希望创建一个线程来执行一些辅助工作,但又不希望这个线程阻碍 JVM 的关闭。

讲到这里,那么在主线程中创建的线程,都是什么线程呢 ?

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

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

当一个线程退出时,JVM 会检查其他正在运行的线程,如果这些线程都是守护线程,那么 JVM 会正常退出操作。当 JVM 停止时,所有仍然存在的守护线程都将被抛弃–即不会执行 finally 代码块,也不会执行回卷栈,而 JVM 只是直接退出。

需要注意的是:

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

2.3 终结器

当不再需要内存资源时,可以通过垃圾回收器来回收它们,但对于其他的一些资源,例如文件句柄或套接字句柄,当不再需要它们时,必须显式地交还给操作系统。

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

由于终结器可以在某个由 JVM 管理的线程中运行,因此终结器访问的任何状态都可能被多个线程访问,这样就必须对其访问操作进行同步。终结器并不能保证它们在何时运行甚至是否会运行,并且复杂的终结器通常还会在对象上产生巨大的性能开销。

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

最后需要注意: 我们应当尽量避免编写和使用包含终结器的类(除非是平台库中的类)

总结

本篇介绍了任务关闭剩下的内容【处理非正常的线程终止JVM 关闭】,那 《任务关闭》 的内容就告一段落了;下一篇博文,我们将开始正式介绍 《线程池的使用》,敬请期待!