优雅的退出
优雅的退出程序
如何退出
我们希望任何程序的优雅退出,都需要在退出前做好收尾工作,比如我们在关机时,一般都是会按部就班的关机,如果长时间没响应了才会直接去按关机键强制关机。所以我们希望在退出任何程序前,都要先做好相应的收尾工作,那么退出程序一般会分为这几步:
- 切断上游流量入口,确保不再有流量进入到当前节点
- 向应用发送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/