如何优雅退出

上节中我们讨论了如何中断一个线程,这节我们讨论如何关闭 JVM 进程。

如何关闭 JVM

关闭分为正常关闭和强制关闭。

触发正常关闭的方式有:当最后一个(非守护)线程退出结束时;调用了 System.exitRuntime.exit 时;以及接收到操作系统的退出信号时(如收到 SIGINT 信号,或按了 Ctrl-C 发送了 SIGTERM 信号等)。

强制关闭可以通过调用 Runtime.halt 方法或通过操作系统发送 SIGKILL 信号(如通过 kill -9 <pid>)实现。

正常关闭与强制关闭的区别在于,正常关闭时,JVM 会调用 Shutdown Hook(关闭钩子),等到所有 hook 执行结束后再退出 JVM。因此在正常关闭的情况下,我们可以通过 Shutdown hook 机制在退出前做一些清理(如清理产生的临时文件,打印所有未打印的日志等),来实现“优雅退出”。

Shutdown Hook

Shutdown hook 需要通过 Runtime.addShutdownHook(Thread hook) 注册,我们把传入的线程称作 hook,addShutdownHook 要求 hook 线程尚未启动,且一个线程只能注册一次。Hook 的执行有两种情况:

  • 正常情况下,需要等所有非守护线程退出,才开始执行
  • 当 JVM 接到关闭信号(如 SIGINT)时执行,此时与非守护线程并发执行。

所有的 hook 线程在 JVM 正常退出时被一起启动,执行顺序没有保证。当所有 hook 线程结束时,JVM 将停止运行1,停止时并不会关闭或中断任何仍然在运行的应用程序线程。

由于 hook 线程在运行时仍然是并发的环境,要保证其中的逻辑是线程安全的。同时, hook 线程不应该对程序是如何结束的有任何假设(如某个服务是否已经关闭),因为任何情况都有可能发生。最后,和线程中断一样,hook 线程应该尽快退出,因为调用方预期 JVM 尽快结束。

通常会在 hook 线程中做一些资源清理的工作,来达到“优雅退出”的目标。例如 Spring 框架中,每个 ApplicationContext 需要实现 registerShutdownHook 方法来注册清理的逻辑,例如 AbstractApplicationContext 的实现如下,调用 doClose 来清理相关资源:

public void registerShutdownHook() {
  if (this.shutdownHook == null) {
    // No shutdown hook registered yet.
    this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
      @Override
      public void run() {
        synchronized (startupShutdownMonitor) {
          doClose();
        }
      }
    };
    Runtime.getRuntime().addShutdownHook(this.shutdownHook);
  }
}

建议用一个 hook 线程来做所有的关闭操作(如上面的代码),这样将所有的关闭操作串行执行,可以减少很多由并发带来的竞争和死锁问题,例如可以防止关闭某个服务时依赖了另一个服务,而它又被另一个 hook 线程关闭了而造成的死锁。

守护线程

守护线程(Daemon Thread)是在后台运行的低优先级的线程,当 JVM 正常退出时,会等待所有守护线程退出后才退出,而不管守护线程的死活。

可以调用 Thread.isDaemon 来判断线程是否为守护线程,可以在线程启动前调用 Thread.setDaemon 来设置是否为守护线程。在一个线程中创建了另一个线程,是否守护的状态会被继承。JVM 启动时只有主线程是普通线程,其它都是守护线程,于是可以推论,默认情况下,主线程创建的所有线程都是普通线程。

一般不建议将线程设置成守护线程,因为守护线程的潜在约定是其它线程结束后,它可以随时被中止。而很少有线程能达到这个条件,例如线程中如果包含 I/O 操作,突然被中止而不做清理可能导致数据没有被正确写入,临时文件没有被清理等。还记得上节中提到语言层面不提供抢占式中断的原因吗?也同样是不建议使用守护线程的原因。

一些没有外部依赖的清理工作可以设置成守护线程,如系统 GC 线程,或是一些清除内内存缓存的线程。

关闭线程池

在系统退出前,需要手工关闭线程池,否则诸如 newFixedThreadPool 线程池会始终保持 N 个在运行的线程,从而阻止 JVM 正常退出。

我们知道 ExecutorService 有两个关闭方法,shutdown 会拒绝新的请求,并等待所有(在运行的和排队中的)任务退出;shutdownNow 会中断正在运行的任务,并返回所有还未运行的任务。当然,如果有任务不能正确响应中断(如在获取锁),那么没有通用的手段能强制它们退出。

shutdownNow 的局限在于没有通用方法处理在运行中的任务,需要从业务角度做处理。例如接收到中断时记录被取消的任务2

static class TrackingExecutor extends AbstractExecutorService {
  private final ExecutorService inner扩展
  private final Set<Runnable> cancelledTasks = Collections.synchronizedSet(new HashSet<>());

  //...

  public List<Runnable> getCancelledTask() {
      if (!isTerminated()) throw new IllegalStateException(...);
      return new ArrayList<>(cancelledTasks);
  }

  @Override
  public void execute(Runnable command) {
      inner.execute(() -> {
        try {
          command.run();
        } finally {
          if (isShutdown() && Thread.currentThread().isInterrupted())
            cancelledTasks.add(command);
        }
      });

  }
}

这种方法可能有“误报”,有些任务可能运行结束,但在设置状态前,线程池被关闭了,于是也可能被包含在被取消任务中。当然,要如何处理被取消的任务需要根据业务情况具体分析,例如爬虫任务可能可以无脑重试(幂等),下定单任务可能要额外判断是否可重试了。

对于无法响应中断的任务,实现时需要准备一些额外中止手段,如任务执行逻辑轮询某个退出标志。

处理异常退出的线程

有些任务可能是同构的,如多个线程消费消息,处理逻辑相同;有些任务可能是异构的,如发送消息和接收消息就是不同的逻辑。

如果由于某些原因,某个线程异常退出了(如调用下游某个 API 时抛异常,且没有被捕获),JVM 会照常执行。如果退出的同构任务中的一个,风险还可控,如果退出的是某个异构任务,程序的整体逻辑就会有问题,如唯一的发送消息的任务异常退出,程序的正确性就有问题)。

最重要的解法还是要求程序正确编码。作为通用性的事后处理,一般至少要记日志,如果严重的还要对接一些监控告警的系统。从语言层面,提供了线程异常退出的通知机制,供用户自定义处理逻辑。

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

调用 Thread.setDefaultUncaughtExceptionHandler 来设置线程的 Exception Handler(异常处理程序),如果是需要为线程池中的 Handler,则需要在构造线程池时指定自定义的工厂函数,如:

ExecutorService pool = Executors.newCachedThreadPool((runnable) -> {
    Thread thread = new Thread(runnable);
    thread.setUncaughtExceptionHandler((t, e) -> System.out.println(...));
    return thread;
});

另外注意一个线程中创建另一个线程,Exception Handler 是不继承的。

小结

何为“优雅”?正确地清理使用的资源即为优雅。JVM 正常退出时提供了 Shutdown hook机制让我们能在退出时执行自定义的清理逻辑,通常线程池的关闭逻辑我们也会放到这里。

正确关闭线程池并不容易,虽然我们有 shutdownshutdownNow 两种关闭语义,过程中还是有一些未定义的任务需要处理:关闭时正在执行的任务。shutdownNow 会发送中断请求,但线程不一定能响应中断。而即使线程响应了中断,线程池也不会有额外的处理。通常后续的处理需要看业务上的需求。

此外,如果线程因为未捕获的异常而退出,根据任务的不同,可能会有严重的影响,Java 提供了 UncaughtExceptionHandler,用于注册异常退出时的处理逻辑,具体逻辑通常也依赖业务需求。


1

严格来说如果设置了 runFinalizersOnExit,还会运行所有对象的finalizer,现在已经不推荐使用这项技术了。

2

例子来源《Java 并发编程实战》