不懂优雅停机,搞挂了线上服务该咋办?

陈树义 2022-07-24 09:35:00
公司项目是用 consul 进行注册的,在发布微服务的时候,总是会导致调用方出现一定几率的调用失败。一开始百思不得其解,后来咨询了资深的同事才知道:原来是服务下线的时候没有优雅停机,没有去 consul 将自己下线再停机,导致调用方拿到了旧的调用地址,导致调用失败! 看来优雅停机还是一个蛮重要的知识点,可不能忽略,今天就让我们来盘盘它吧!

 

一、什么是优雅停机?

 

在 Linux 世界里,一切都是资源。当我们启动一个 JVM 的时候,我们就加载了许多的资源。而当我们关闭 JVM 的时候,JVM 只会释放内存这个资源,而其他资源是不会释放的,例如:网络连接、文件句柄等等。

 

Linux 的网络连接数、文件句柄数都是有限的,如果我们没有及时释放,时间久了就会导致一些奇怪的问题。那么如何在 JVM 关闭的时候,释放这些资源呢?答案就是:利用 Java 提供的 ShutdownHook 接口。 我们所说的优雅停机,就是利用 Java 提供的 ShutdownHook 接口注册一个钩子,让 JVM 在关闭之前执行钩子函数的代码,让其关闭对应的资源。

 

二、适用场景

 

在学会怎么使用优雅停机之前,我们需要弄清楚优雅停机适用于哪些场景,那我们就需要先弄清楚 JVM 关闭的几种情况了。JVM 关闭的情况可以分为 3 大类 11 个情况,如下图所示:


JVM 关闭的场景

 

在 JVM 关闭的 3 大类场景中,只有正常关闭与异常关闭是支持优雅停机的,而强制关闭则是不支持的。下面我们通过三个例子来验证一下。

 

 

1、JVM 正常关闭

 

JVM 正常关闭这种情况,我们只需要正常运行一个 main 函数,然后为其注册一个 ShutdownHook 即可,其代码如下所示。

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public class NormalShutdownTest {    public void start() {        Runtime.getRuntime().addShutdownHook(new Thread(() ->                System.out.println("钩子函数被执行,可以在这里关闭资源。")        ));    }
    public static void main(String[] args) {        new NormalShutdownTest().start();        System.out.println("主应用程序在执行,正常关闭。");    }}

 

输出结果为:

 

  •  
  •  
主应用程序在执行,正常关闭。钩子函数被执行,可以在这里关闭资源。

 

可以看到钩子函数的代码正常执行了。如果你在 main 函数增加 System.exit(0) 代码,执行之后的结果也还是一样。这说明 JVM 正常关闭情况下,是支持优雅停机的。

 

 

2、异常关闭

 

JVM 异常关闭这种情况,我们尝试制造内存溢出。只需要声明一个 500 MB 的数组,然后设置 JVM 堆最大为 20 MB 即可(-Xmx20M),其代码如下所示。

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public class OomShutdownTest {    public void start() {        Runtime.getRuntime().addShutdownHook(new Thread(() ->                System.out.println("钩子函数被执行,可以在这里关闭资源")        ));    }
    public static void main(String[] args) throws Exception {        new OomShutdownTest().start();        System.out.println("主应用程序在执行,内存溢出关闭。");        byte[] b = new byte[500 * 1024 * 1024];    }}

 

执行结果为:

 

  •  
  •  
  •  
  •  
主应用程序在执行,内存溢出关闭。Exception in thread "main" java.lang.OutOfMemoryError: Java heap space at tech.shuyi.javacodechip.shutdownhook.OomShutdownTest.main(OomShutdownTest.java:13)钩子函数被执行,可以在这里关闭资源

 

可以看到 JVM 抛出了 OOM 错误,但是钩子函数还是被执行了。如果你在 main 函数中自行抛出 RuntimeException,钩子函数也还是会被执行。感兴趣的朋友可以自行尝试一下。

 

 

3、强制关闭

 

JVM 强制关闭这种情况,我们可以使用 Runtime.getRuntime().halt(1) 进行测试,其代码如下所示。

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public class ForceShutdownTest {    public void start() {        Runtime.getRuntime().addShutdownHook(new Thread(() ->                System.out.println("钩子函数被执行,可以在这里关闭资源。")        ));    }
    public static void main(String[] args) throws Exception {        new ForceShutdownTest().start();        System.out.println("主应用程序在执行,强制关闭。");        Runtime.getRuntime().halt(1);    }}

 

执行结果:

 

  •  
主应用程序在执行,强制关闭。

 

可以看到钩子函数并没有被执行,所以 JVM 强制关闭这种场景不支持优雅停机。

 

三、最佳实践

 

看了上面的例子,看起来优雅停机没那么复杂嘛。实际上,优雅停机用不好,很可能出现一些其他问题。这里给出几个最佳实践原则,帮助大家用好优雅停机!

 

 

1、只注册一个钩子

 

我们都知道 JVM 可以注册多个钩子,而钩子本质上是一个线程,可以并发执行。那么就很可能出现钩子之间相互依赖,这样就会导致依赖死锁了。另外,也可能因为多个钩子操作同一个资源,导致资源竞争出现死锁。因此,较好的一种方式就是只注册一个钩子,所有的资源释放都在这个钩子中操作。

 

 

2、确保线程安全

 

因为钩子本质上也是一个线程,JVM 可能会并发执行多个钩子,JVM 并不保证它们的执行顺序,因此需要保证钩子中的操作是线程安全的。当然了,如果你只有一个钩子的话,那这个提示可以忽略了。

 

 

3、不要做耗时的操作

 

在钩子中,不要做耗时的操作。因为当我们要关闭 JVM 时,用户肯定是希望尽快关闭,因此钩子中主要用于关闭残留资源,不应该再做其他耗时的操作。

 

 

4、不要做注册、移除钩子的操作

 

在关闭钩子中,不能执行注册、移除钩子的操作,否则 JVM 抛出 IllegalStateException。

 

 

5、不要调用 System.exit () 操作

 

也不能调用 System.exit ()  操作,但是调用 Runtime.halt() 操作是可以的。我想,这是因为调用 System.exit () 操作会导致循环进入钩子,导致死循环吧。

 

 

6、需要考虑的资源

 

除了上面一些代码上的操作需要考虑,我们还需要注意下面这些场景的处理:

 

  • 池化资源的释放:数据库连接池、HTTP 连接池、线程池。

     

  • 在处理线程的释放:已经被连接的 HTTP 请求。

     

  • MQ 消费者的处理:正在处理的消息。

     

  • 隐形受影响的资源的处理:Zookeeper、Nacos 实例下线等。

 

四、应用案例

 

Java 提供的优雅停机机制,可以说是许多框架的基础。诸如 Spring、Consul 等中间件框架,都是利用 Java 提供的这个机制进行优雅停机的。

 

 

1、Spring 的优雅停机

 

例如 Spring 是基于 Java 语言开发的框架,那其也势必依赖于 JVM 的 ShutdownHook。Spring 关于优雅停机的代码在 org.springframework.context.support.AbstractApplicationContext#registerShutdownHook 处,代码如下图所示。

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
@Overridepublic 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();    }   }  };  // 增加 ShutdownHook 钩子  Runtime.getRuntime().addShutdownHook(this.shutdownHook); }}

 

可以看到 Spring 在 registerShutdownHook() 函数里,注册了一个关闭的钩子,钩子中调用了 doClose() 方法。

 

 

2、服务治理的优雅停机

 

不论是 Dubbo 还是 Spring Cloud 的分布式服务框架,需要关注的是怎么能在服务停止前,先将提供者在注册中心进行反注册,然后在停止服务提供者,这样才能保证业务系统不会产生各种 503、timeout 等现象。为了实现上述说到的效果,那么我们就必须关注优雅停机这件事情。

 

彩蛋

 

我们都知道通过 kill -15 可以让 JVM 优雅停机,那我们是否可以监听特定的信号量,从而让程序做特定的操作呢?例如:让 JVM 监听第 12 信号量,然后打印一条日志,随后优雅停机。

 

答案是当然可以啦!我们只需要利用 Signal 类,并实现一个 SignHandler 类就可以了。其实现代码如下所示:

 

  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
  •  
public class CustomShutdownTest {    public void start() {        Runtime.getRuntime().addShutdownHook(new Thread(() ->                System.out.println("钩子函数被执行,可以在这里关闭资源。")        ));    }
    public static void main(String[] args) {        // custom signal kill        Signal sg = new Signal("USR2"); // kill -12 pid        Signal.handle(sg, new SignalHandler() {            @Override            public void handle(Signal signal) {                System.out.println("接收到信号量:" + signal.getName());                // 监听信号量,通过System.exit(0)正常关闭JVM,触发关闭钩子执行收尾工作                System.exit(0);            }        });        // other logic        new CustomShutdownTest().start();        System.out.println("主应用程序在执行,正常关闭。");         try {          Thread.sleep(30000);         } catch (InterruptedException e) {          e.printStackTrace();         }    }}

 

我们启动该类后,先让其休眠 30 秒,随后用 jps 命令找到进程 ID,随后运行 kill -USR2 PID 即可,如截图所示。

 

 

随后可以看到控制台打印出如下消息:

 

  •  
  •  
  •  
主应用程序在执行,正常关闭。接收到信号量:USR2钩子函数被执行,可以在这里关闭资源。

 

从上面消息我们知道,JVM 成功接收到了 USR2 信号量,也成功执行了钩子函数。搞定!

 

提示:其实 USR2 是 Linux 第 12 个信号量,是留给用户使用的一个信号量。我们可以通过该信号量做一些定制化操作,从而实现更加复杂的功能。

 

>>>>

参考资料

 

  • 如何优雅地停止 Java 进程 | iBit 程序猿

  • JVM 进程的优雅关闭 - waterystone - 博客园

  • VIP!例子比较不错!如何优雅的关闭JVM?-51CTO.COM

  • 比较深度一些,范围比较广!Spring—— 项目优雅停机 - 曹伟雄 - 博客园

  • RPC 服务治理相关。VIP!研究优雅停机时的一点思考 | 徐靖峰 | 个人博客

  • Spring 的优雅停机 | Yanick's Blog

  • rocketmq 优雅停机往事 - 云 + 社区 - 腾讯云

 

作者丨陈树义
来源丨公众号:陈树义(ID:gh_b6f5025d4a8d)
dbaplus社群欢迎广大技术人员投稿,投稿邮箱:editor@dbaplus.cn
活动预告