优雅的退出

优雅的退出,第1张

优雅的退出 优雅的退出程序 如何退出

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

  • 切断上游流量入口,确保不再有流量进入到当前节点
  • 向应用发送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 信号,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 {
    
    private static IdentityHashMap 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 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 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() {
	@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/

欢迎分享,转载请注明来源:内存溢出

原文地址: https://outofmemory.cn/zaji/5676849.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-17
下一篇 2022-12-17

发表评论

登录后才能评论

评论列表(0条)

保存