当前位置: 首页 > news >正文

优雅的退出

优雅的退出程序

如何退出

​ 我们希望任何程序的优雅退出,都需要在退出前做好收尾工作,比如我们在关机时,一般都是会按部就班的关机,如果长时间没响应了才会直接去按关机键强制关机。所以我们希望在退出任何程序前,都要先做好相应的收尾工作,那么退出程序一般会分为这几步:

  • 切断上游流量入口,确保不再有流量进入到当前节点
  • 向应用发送kill 命令,在设定的时间内待应用正常关闭
  • 若超时后应用仍然存活,则使用kill -9命令强制关闭

不同层面の优雅关闭

  • 操作系统层面,提供了 kill -9 (SIGKILL)和 kill -15(SIGTERM) 两种停机策略;
  • 语言层面,Java 应用有 JVM shutdown hook,让我们有能力在JVM关闭前做一些收尾处理工作;
  • 框架层面,Spring的spring-context模块提供了ContextClosedEvent事件,Spring Boot 的 actuator组件提供了Endpoint;
  • 容器层面,docker:在执行docker stop 命令时,容器内的进程会收到 SIGTERM 信号,那么 Docker Daemon 会在 10s 后,发出 SIGKILL 信号;k8s 在管理容器生命周期阶段中提供了 prestop 钩子方法
  • 系统层面,系统层面就更复杂了,尤其在分布式框架中:将服务节点从注册中心摘除,订阅者接收通知,移除节点,从而优雅停机
  • 数据库,可以使用事务的 ACID 特性,需要通过日志和一套完善的崩溃恢复机制来保证即使 crash 停机也能保证不出现异常数据
  • 消息队列, 副本机制 +持久化

linuxの优雅退出

​ 我们知道在linux中,想有两个关闭进程的命令是:

kill -9 [PID]
kill [PID]

​ 那kill背后是什么呢?

​ 实际上,kill命令,其实就是在Linux中发送一个信号,而信号(Signal)其实就是 Linux 进程收到的一个通知。信号有很多种,比如:

  • 如果我们按下键盘“Ctrl+C”,当前运行的进程就会收到一个信号 SIGINT 而退出;
  • 如果我们的代码写得有问题,导致内存访问出错了,当前的进程就会收到另一个信号 SIGSEGV;
  • 我们也可以通过命令 kill ,直接向一个进程发送一个信号,缺省情况下不指定信号的类型,那么这个信号就是 SIGTERM。也可以指定信号类型,比如命令 “kill -9 ”, 这里的 9,就是编号为 9 的信号,SIGKILL 信号。

​ 事实上,Linux 有 31 个基本信号,进程在处理大部分信号时有三个选 择:忽略、捕获和缺省行为。其中两个特权信号 SIGKILL 和 SIGSTOP 不能被忽略或者捕获。那么进程一旦收到 SIGKILL,就要退出。

​ 而kill -9是立刻退出,甚至没办法处理后续事宜,更优雅的做法显然是kill+sleep一定的超时时间,如果还没退出,这时候再强制kill -9。

容器の优雅退出

​ Docker容器有个很神奇的现象:你没办法通过kill pid 1的方式来重启容器。

​ 对于Dokcer容器,1号进程是它的init 进程,而Linux 内核针对每个 Nnamespace 里的 init 进程,把只有 default handler(就是上面说的Linux在处理信号的时候,捕获(Catch),就是指让用户进程可以注册自己针对这个信号的 handler) 的信号都给忽略了。如果我们自己注册了信号的 handler(应用程序注册信号 handler 被称作"Catch the Signal"),那么这个信号 handler 就不再是 SIG_DFL ,即使是 init 进程在接收到 SIGTERM 之后也是可以退出的。不过,SIGKILL又 是不允许被注册用户 handler 的(还有一 个不允许注册用户 handler 的信号是 SIGSTOP),所以 init 进程是永远不能被 SIGKILL 所杀,但是可以被 SIGTERM 杀死,当然你要先注册 SIGTERM 的 handler。

​ 不过,docker的docker stop 命令显然更加友好,不需要我们自己去给SIGTERM 信号注册handler。当执行这个命令,容器内的进程会收到 SIGTERM 信号,Docker Daemon 会等待10s ,10s后如果docker还没退出,就直接发出 SIGKILL 信号强制关闭。

JVMの优雅退出

java.lang.System#exit

​ java.lang.System#exit 方法是Java提供的能够停止JVM进程的方法,该方法被触发时,JVM会去调用Shutdown Hook(关闭钩子)方法,直到所有勾子方法执行完毕,才会关闭JVM进程。exit源码如下:

 public static void exit(int status) {
        Runtime.getRuntime().exit(status);
    }

​ 这个方法的入参status代表终止状态,如果是0代表正常终止,此外都是非正常终止。一旦调用该方法,永远也不会从该方法正常返回:执行完该方法后JVM进程就直接关闭了。

​ 而这个方法又继续调用了Shutdown.exit(status):

	static void exit(int status) {
    	...
		synchronized (Shutdown.class) {
            sequence();
            //关闭JVM
            halt(status);
        }

​ 而sequence方法中就是去调用java.lang.Shutdown#runHooks:

  /* 运行所有注册的钩子方法
     */
    private static void runHooks() {
        for (int i=0; i < MAX_SYSTEM_HOOKS; i++) {
            try {
                Runnable hook;
                synchronized (lock) {
                    // 这里加锁的目的是为了保证运行钩子期间,hooks的可见性
                    currentRunningHook = i;
                    hook = hooks[i];
                }
                //获取到了钩子类,然后就会去执行钩子的run方法
                if (hook != null) hook.run();
            } catch(Throwable t) {
                if (t instanceof ThreadDeath) {
                    ThreadDeath td = (ThreadDeath)t;
                    throw td;
                }
            }
        }
    }

ShutdownHook源码解读

​ Spring提供了钩子的机制,如果我们的程序不是web应用程序,可以通过向Spring注册关闭的钩子,来实现在关闭JVM之前,对我们想要回收的资源进行关闭前的收尾工作。Spring的钩子在JDK的基础类库包rt.jar中,全类路径是:java.lang.Shutdown,这个类中持有了钩子的数组,主要代码如下:

class Shutdown {  
  // hooks数组是一种插槽式的注册方式,目前共有3类:0是Console restore hook,1是应用级的钩子:Application hooks;2是DeleteOnExit hook,是HotSpot VM上的shutdown钩子
    private static final int MAX_SYSTEM_HOOKS = 10;
    private static final Runnable[] hooks = new Runnable[MAX_SYSTEM_HOOKS];
	static void add(int slot, boolean registerShutdownInProgress, Runnable hook) 
	static void exit(int status) 
	static void shutdown() 
	private static void sequence() 
	private static void runHooks()
	 ...
	 
	}

接下来看下ApplicationShutdownHooks,也就是应用级关闭钩子类,这个类的主要代码:

class ApplicationShutdownHooks {
    /*应用级的钩子map,实际上是被当成了set在用 */
    private static IdentityHashMap<Thread, Thread> hooks;
    static {
        //在静态代码块中,就往Shutdown的hooks数组中添加本类,run方法就是调用本类注册的hooks中的钩子方法;注意这个时候只是添加,线程并没有跑起来;
        try {
            Shutdown.add(1 ,false ,
                new Runnable() {
                    public void run() {
                        runHooks();
                    }
                }
            );
            hooks = new IdentityHashMap<>();
        } catch (IllegalStateException e) {
            hooks = null;
        }
    }
 	//添加钩子,把钩子添加到本类的hooksMap中
    static synchronized void add(Thread hook)
	//移除钩子,从本类的Map中移除
    static synchronized boolean remove(Thread hook)
	//遍历本类的hooksMap,运行它们的start方法,等待它们执行完
    static void runHooks() 
}

当然,在Shutdown类的add方法给hooks赋值的时候也加了锁:

    static void add(int slot, boolean registerShutdownInProgress, Runnable hook) {
        synchronized (lock) {
           ...
            hooks[slot] = hook;
        }
    }

​ 在上面java.lang.Shutdown#runHooks方法中我们看到它实际上是调用了钩子类的run方法,就会遍历到ApplicationShutdownHooks,所以实际上是执行java.lang.ApplicationShutdownHooks#runHooks方法:

static void runHooks() {
        Collection<Thread> threads;
        synchronized(ApplicationShutdownHooks.class) {
            threads = hooks.keySet();
            hooks = null;
        }
		//并发执行我们注册到ApplicationShutdownHooks中的钩子方法,每个钩子都是个线程
        for (Thread hook : threads) {
            hook.start();
        }
    	//阻塞直到所有的钩子方法都执行结束,如果抛出异常也忽略掉
        for (Thread hook : threads) {
            while (true) {
                try {
                    hook.join();
                    break;
                } catch (InterruptedException ignored) {
                }
            }
        }
    }
}

​ 而在Shutdown.exit(status)方法就会等所有的钩子方法执行完毕再真正的关闭JVM。

ShutdownHook注册方式

ConfigurableApplicationContext.registerShutdownHook

​ 在程序刚启动,初始化上下文的时候,就会通过ConfigurableApplicationContext注册ShutdownHook

@Override
	public void registerShutdownHook() {
		if (this.shutdownHook == null) {
			// 这个时候shutdownHook==null,所以会创建name是SpringContextShutdownHook的线程,到时候由这个线程调用doClose()方法:发布ContextClosedEvent,执行bean的 destroyBeans(),销毁bean等等。
			this.shutdownHook = new Thread(SHUTDOWN_HOOK_THREAD_NAME) {
				@Override
				public void run() {
					synchronized (startupShutdownMonitor) {
						doClose();
					}
				}
			};
            //调用Runtime的addShutdownHook将钩子注册进去
			Runtime.getRuntime().addShutdownHook(this.shutdownHook);
		}
	}
Runtime.getRuntime().addShutdownHook

测试代码:

 public static void main(String[] args) throws InterruptedException {

        System.out.println(Thread.currentThread().getName()+"==========begin==========");
        Thread shunDownThread = new Thread(() -> {
            System.out.println(Thread.currentThread().getName() +
                    "==========我是ShutdownHook" +
                    ",我被执行了==========");
        });

        Runtime.getRuntime().addShutdownHook(shunDownThread);
        System.out.println(Thread.currentThread().getName()+"==========end==========");

    }

​ 打断点可以发现,在main方法结束后,JVM会调用DestroyJavaVM来调用钩子方法,然后DestroyJavaVM会一直阻塞直到所有的钩子方法都运行完毕,然后就会关闭JVM。

在这里插入图片描述

关于DestroyJavaVM

以下内容摘自《Java性能优化权威指南》

如果HotSpot VM启动过程中发生错误,启动器则调用DestroyJavaVM方法关闭HotSpot VM。如果HotSpot VM启动后执行过程中发生很严重的错误,也会调用DestroyJavaVM方法。如果main方法结束,也会调用DestroyJavaVM方法。
DestroyJavaVM按以下步骤停止HotSpot VM。
(1)一直等待,直到只有一个非守护的线程执行,注意此时HotSpot VM仍然可用。
(2)调用java.lang.Shutdown.shutdown(),它会调用Java上的shutdown钩子方法,如果finalization-on-exit为true,则运行Java对象的finalizer。
(3)运行HotSpot VM上的shutdown钩子(通过JVM_OnExit()注册),停止以下线程:性能分线器、统计数据抽样器、监控线程及垃圾收集器线程。发出状态事件通知JVMTI,然后关闭JVMTI、停止信号线程。
(4)调用HotSpot的JavaThread::exit()释放JNI处理块,移除保护页,并将当前线程从已知线程队列中移除。从这时起,HotSpot VM就无法执行任何Java代码了。
(5)停止HotSpot VM线程,将遗留的HotSpot VM线程带到安全点并停止JIT编译器线程。
(6)停止追踪JNI,HotSpot VM及JVMTI屏障。
(7)为哪些仍然以本地代码运行的线程设置标记“vm exited”。
(8)删除当前线程。
(9)删除或移除所有的输入/输出流,释放PerfMemory(性能统计内存)资源。
(10)最后返回到调用者。

线程池の优雅关闭

ThreadPoolExecutor

shutdown+awaitTermination

ThreadPoolExecutor类中有这样两个方法:

//关闭线程池,拒绝接收新任务,执行正在运行的任务。
public void shutdown() 
//通过 Thread.interrupt,去停止正在执行的方法,如果线程不响应中断就不会被停止;停止正在等待的任务的处理;并返回正在等待执行的任务的列表。
public List<Runnable> shutdownNow()

​ 相对而言,shutdown显然是更优雅的关闭线程池的方法,但这有一个问题,如果任务空跑导致没办法结束怎么办?这就需要我们配合awaitTermination方法,给它设置超时时间。

ThreadPoolTaskExecutor

​ 使用Spring提供的线程池ThreadPoolTaskExecutor,先将线程池作为Bean给Spring管理:

@Configuration
public class BeanConfiguration {
    @Bean
    ThreadPoolTaskExecutor threadPool(){
        ThreadPoolTaskExecutor threadPoolTaskExecutor = new ThreadPoolTaskExecutor();
        //省略配置
        //线程池销毁前调用shutDown方法
        threadPoolTaskExecutor.setWaitForTasksToCompleteOnShutdown(true);
        //设置超时时间
        threadPoolTaskExecutor.setAwaitTerminationMillis(3000);
        return threadPoolTaskExecutor;
    }
}

​ 对于ThreadPoolTaskExecutor,setWaitForTasksToCompleteOnShutdown+setAwaitTerminationMillis就相当于ThreadPoolExecutor的shutdown+awaitTermination。

JVM与线程池

​ 但是到这里也还没结束,我们在线程池中使用到了其他资源,比如我们链接Redis查询了数据,然后这个时候恰巧JVM关闭了,那又怎么处理?

​ 经过上面的学习,我们知道JVM的关闭,当JVM接收到kill命令:

  • 调用DestroyJavaVM方法,调用java.lang.Shutdown.shutdown()
  • 等待Shutdown Hooks执行完毕便可以正常关机;

​ 但是上面提到有一个在Spring启动时注册的钩子:SpringContextShutdownHook,它会对Spring创建的单例Bean进行销毁,调用它们的DisposableBean钩子方法;发布ContextClosedEvent等。那刚刚我们提到的问题:如果我们强制关闭JVM,此时线程池里的任务与Spring Shutdhwon Hook并发地执行,一旦任务执行期,线程池执行的任务所依赖的资源先行被释放,那任务执行时必然会报错,比如我们在线程池中调用了Redis,如果我们的线程池还没关闭,Redis链接先被回收,这个时候就会抛出异常。

解决思路

​ 刚的问题本质上是在退出JVM的时候销毁bean与关闭连接池是同时的,那就有可能先回收了资源,再关闭连接池。如果我们可以在关闭JVM的时候先关闭连接池,再回收资源就可以不会有问题了。所以我们在Spring容器销毁前,或者在bean销毁前关闭线程池就可以了:

  • 监听 ContextClosedEvent事件,对我们的线程池做处理
applicationContext.addApplicationListener(new ApplicationListener<ApplicationEvent>() {
	@Override
	public void onApplicationEvent(ApplicationEvent applicationEvent) {
		if (applicationEvent instanceof ContextClosedEvent) {
			 //关闭线程池
		}
	}
});
  • 使用 DisposableBean 接口
public class TestDisposableBean implements DisposableBean {
    @Override
    public void destroy() throws Exception {
        //关闭线程池
    }
}
  • 使用 @PreDestroy 注解
public class TestPreDestroy {
    @PreDestroy
    public void preDestroy(){
       //关闭线程池
    }

}

部分内容摘自:

http://www.concurrentaffair.org/2005/12/22/destroyjavavm/

相关文章:

  • 分布式架构演进
  • synchronized关键字
  • 分布式锁的几种实现方式
  • 延时队列的几种实现方式(只有原理,并没有源码)
  • DDD整理(概念篇)
  • DDD的分层架构设计
  • 面试记录之synchronized的惨败经历
  • 面试复盘整理
  • Go语言基础_数据类型、基本语法篇
  • Go学习笔记_环境搭建
  • Markdown学习
  • Markdown下载客户端
  • JDK,JRE,JVM三者的区别
  • 2020-12-01
  • 2020-12-02
  • 【407天】跃迁之路——程序员高效学习方法论探索系列(实验阶段164-2018.03.19)...
  • 【划重点】MySQL技术内幕:InnoDB存储引擎
  • download使用浅析
  • ES6语法详解(一)
  • HashMap ConcurrentHashMap
  • Linux后台研发超实用命令总结
  • Node.js 新计划:使用 V8 snapshot 将启动速度提升 8 倍
  • python学习笔记 - ThreadLocal
  • React 快速上手 - 06 容器组件、展示组件、操作组件
  • thinkphp5.1 easywechat4 微信第三方开放平台
  • vue2.0开发聊天程序(四) 完整体验一次Vue开发(下)
  • 翻译:Hystrix - How To Use
  • 观察者模式实现非直接耦合
  • 前端面试题总结
  • 时间复杂度与空间复杂度分析
  •  一套莫尔斯电报听写、翻译系统
  • $Django python中使用redis, django中使用(封装了),redis开启事务(管道)
  • (+3)1.3敏捷宣言与敏捷过程的特点
  • (14)学习笔记:动手深度学习(Pytorch神经网络基础)
  • (31)对象的克隆
  • (4) PIVOT 和 UPIVOT 的使用
  • (C语言)输入一个序列,判断是否为奇偶交叉数
  • (二)正点原子I.MX6ULL u-boot移植
  • (附源码)spring boot火车票售卖系统 毕业设计 211004
  • (附源码)ssm跨平台教学系统 毕业设计 280843
  • (十一)c52学习之旅-动态数码管
  • (四)汇编语言——简单程序
  • **PHP二维数组遍历时同时赋值
  • .gitignore
  • .htaccess 强制https 单独排除某个目录
  • .Net Core webapi RestFul 统一接口数据返回格式
  • .Net 中Partitioner static与dynamic的性能对比
  • .net 重复调用webservice_Java RMI 远程调用详解,优劣势说明
  • .Net8 Blazor 尝鲜
  • @PreAuthorize注解
  • @property括号内属性讲解
  • [Android View] 可绘制形状 (Shape Xml)
  • [Asp.net MVC]Bundle合并,压缩js、css文件
  • [Codeforces] combinatorics (R1600) Part.2
  • [CTSC2014]企鹅QQ