公司项目在consul注册。在发布微服务时,总是会以一定的概率导致调用方失败。一开始我很疑惑,后来请教了前辈同事才知道:原来服务下线的时候,没有优雅关机,我没有去consul自己登录下线再关机再次。结果,调用者得到了旧的调用地址,导致失败!看来优雅宕机还是一个很重要的知识点,不容忽视。今天就让我们盘点一下吧!1、什么是优雅关机?在Linux世界中,一切都是资源。当我们启动JVM时,我们会加载大量资源。当我们关闭JVM时,JVM只会释放内存资源,而不会释放其他资源,比如网络连接、文件句柄等。Linux中的网络连接数和文件句柄数是有限的。如果我们不及时释放它们,时间长了就会出现一些奇怪的问题。那么JVM关闭时如何释放这些资源呢?答案是:使用Java提供的ShutdownHook接口。我们所说的优雅关机就是利用Java提供的ShutdownHook接口注册一个钩子,让JVM在关机前执行钩子函数的代码,让它关闭相应的资源。2.适用场景在学习如何使用优雅关机之前,我们需要弄清楚优雅关机适用于哪些场景。那么我们需要弄清楚JVM关闭的几种情况。JVM关闭分为3类11种情况,如下图所示:JVM关闭场景JVM关闭的3种场景中,只有正常关闭和异常关闭支持优雅关闭,不支持强制关闭。让我们用三个例子来验证一下。1、JVM正常关闭在JVM正常关闭的情况下,我们只需要正常运行一个main函数,然后为其注册一个ShutdownHook即可。代码如下。publicclassNormalShutdownTest{publicvoidstart(){Runtime.getRuntime().addShutdownHook(newThread(()->System.out.println("钩子函数执行完毕,可以在这里关闭资源。")));}publicstaticvoidmain(String[]args){newNormalShutdownTest().start();System.out.println("主应用程序正在执行,正常关闭。");}}输出resultis:主应用程序正常执行关闭,钩子函数执行完毕,这里可以关闭资源,可以看到钩子函数的代码执行正常,如果加上System.exit(0)代码到main函数,执行后的结果还是一样,这说明JVM在正常关闭的情况下,是支持优雅关闭的。2.异常关闭在JVM异常关闭的情况下,我们尝试创建一个内存溢出。只需声明一个500MB的数组,然后将最大JVM堆设置为20MB(-Xmx20M),代码如下。newThread(()->System.out.println("钩子函数是Execute,可以在这里关闭资源")));}publicstaticvoidmain(String[]args)抛出异常{newOomShutdownTest().start();System.out.println("主应用程序正在执行,内存不足关闭。");字节[]b=新字节[500*1024*1024];}}执行结果为:主应用程序正在执行,内存溢出关闭。Exceptioninthread"main"java.lang.OutOfMemoryError:Javaheapspaceattech.shuyi.javacodechip.shutdownhook.OomShutdownTest.main(OomShutdownTest.java:13)钩子函数被执行,这里可以关闭资源,可以看到即JVM抛出OOM错误,但钩子函数仍然执行。如果在main函数中自己抛出RuntimeException,钩子函数还是会执行。有兴趣的朋友可以自己尝试一下。3.强行关闭JVM这种情况,我们可以使用Runtime.getRuntime().halt(1)来测试,代码如下。publicclassForceShutdownTest{publicvoidstart(){Runtime.getRuntime().addShutdownHook(newThread(()->System.out.println("钩子函数执行完毕,这里可以关闭资源。")));}publicstaticvoidmain(String[]args)throwsException{newForceShutdownTest().start();System.out.println("主应用程序正在执行,强制关闭。");Runtime.getRuntime().halt(1);}}执行结果:主应用正在执行中,强制关闭。可以看到hook函数并没有执行,所以JVM强制关闭的场景是不支持优雅关闭的。3.Bestpractice看完上面的例子,似乎优雅关机并没有那么复杂。其实优雅关机也不好,还容易出现其他一些问题。这里有一些最佳实践原则,可以帮助您用好优雅关机!1.只注册一个钩子我们都知道JVM可以注册多个钩子,而一个钩子本质上就是一个可以并发执行的线程。那么很可能hook之间会相互依赖,从而导致依赖死锁。另外,也有可能多个hook操作同一个资源,造成资源竞争死锁。因此,更好的办法是只注册一个钩子,所有的资源释放都在这个钩子中进行。2.保证线程安全因为一个hook本质上就是一个线程,JVM可能会同时执行多个hook。JVM不保证它们的执行顺序,所以需要保证hooks中的操作是线程安全的。当然,如果你只有一个钩子,那么这个提示可以忽略。3、不要做耗时操作在钩子里,不要做耗时操作。因为当我们要关闭JVM的时候,用户肯定希望尽快关闭,所以钩子主要是用来关闭剩余资源的,不要做其他耗时的操作。4.不要挂钩或取下挂钩。在关闭钩子中,不能注册或移除钩子,否则JVM会抛出IllegalStateException。5、不调用System.exit()操作也不能调用System.exit()操作,但可以调用Runtime.halt()操作。我想这是因为调用System.exit()操作会导致循环进入hook,导致死循环。6.需要考虑的资源除了上面一些需要考虑的代码的操作之外,我们还需要注意以下场景的处理:池化资源的释放:数据库连接池,HTTP连接池,线程池。在释放处理线程时:已连接的HTTP请求。MQ消费者的处理:正在处理的消息。受影响资源的隐形处理:Zookeeper、Nacos实例下线等。4.应用案例Java提供的优雅关机机制可以说是很多框架的基础。Spring、Consul等中间件框架就是利用Java提供的这种机制来进行优雅关闭的。1、Spring的优雅关闭。比如Spring是基于Java语言开发的框架,那么它也必然依赖于JVM的ShutdownHook。Spring关于优雅关机的代码在org.springframework.context.support.AbstractApplicationContext#registerShutdownHook,代码如下图所示。@OverridepublicvoidregisterShutdownHook(){if(this.shutdownHook==null){//还没有注册关闭钩子。this.shutdownHook=newThread(SHUTDOWN_HOOK_THREAD_NAME){@Overridepublicvoidrun(){synchronized(startupShutdownMonitor){doClose();}}};//添加ShutdownHook钩子Runtime.getRuntime().addShutdownHook(this.shutdownHook);}}可以看到Spring在registerShutdownHook()函数和doClose()方法中注册了一个shutdownhook。2、服务治理的优雅关闭无论是Dubbo还是SpringCloud的分布式服务框架,需要注意的是如何在服务停止前在注册中心注销provider,然后停止服务provider,从而保证业务系统不会产生各种503,超时等现象。为了达到上面所说的效果,那么我们就要注意优雅关机这件事了。彩蛋我们都知道JVM可以通过kill-15来优雅的关闭,那么是否可以监听一个特定的信号量让程序进行特定的操作呢?比如:让JVM监听第12个信号量,然后打印一条日志,然后优雅的停止。答案是当然可以!我们只需要使用Signal类并实现一个SignHandler类。实现代码如下:publicclassCustomShutdownTest{publicvoidstart(){Runtime.getRuntime().addShutdownHook(newThread(()->System.out.println("钩子函数执行完毕,可以关闭资源这里。")));}publicstaticvoidmain(String[]args){//自定义信号killSignalsg=newSignal("USR2");//kill-12pidSignal.handle(sg,newSignalHandler(){@Overridepublicvoidhandle(Signalsignal){System.out.println("Receivedsemaphore:"+signal.getName());//监听信号量,通过System.exit(0)正常关闭JVM,触发shutdown钩子执行收尾工作System.exit(0);}});//其他逻辑newCustomShutdownTest().start();System.out.println("主应用正常执行并关闭。");try{Thread.sleep(30000);}catch(InterruptedExceptione){e.printStackTrace();}}}我们启动这个类之后,让它休眠30秒,然后使用jps命令找到进程ID,然后运行kill-USR2PID即为Yes,如截图所示。然后可以看到控制台打印出如下信息:主应用程序正在执行并正常关闭。收到信号量:USR2钩子函数被执行ed,资源可以在这里关闭。从上面的消息我们知道,JVM成功接收到USR2信号量,并成功执行了钩子函数。完毕!提示:其实USR2是Linux的第12个信号量,是为用户保留的信号量。我们可以通过这个信号量做一些自定义的操作,从而实现更复杂的功能。
