Spring Shutdown Hook工作机制揭秘

前言

上篇文章,我们讨论了在Spring环境中正确关闭线程池的姿势,抛出了问题并给出了解决方案。本篇,将接着讨论解决方案背后的原理:Spring Shutdown Hook工作机制

源码解析

源码基于Spring Boot 2.1.0.RELEASE

注册Spring Shutdown Hook的时机

首先要找到入口在哪,即Spring Shutdown Hook是在哪注册的,很容易猜想,应该是在应用启动过程中注册的,找到如下源码位置:org.springframework.boot.SpringApplication#refreshContext(Spring Boot)

image-20200722130735903

Spring Boot 在启动过程中,刷新Context之后,如果registerShutdownHook开启[默认为true],则会注册一个Shutdown Hook

org.springframework.context.support.AbstractApplicationContext#registerShutdownHook (spring-context) 如下:

image-20200722131219294

这里有一点需要注意的是:提供Spring Shutdown Hook能力的是spring-context,即spring framework本身的能力,但是将shutdown hook注册进JVM shutdown hook的行为,却是Spring Boot提供的。也就是说,如果在纯Spring环境下,需要自己手动调用AbstractApplicationContext#registerShutdownHook注册shutdown hook来支持Spring的优雅关闭

画外音:哪有什么岁月静好,只不过有人(Spring Boot) 替你负重前行

Spring Shutdown Hook的逻辑

接下来看看Spring Shutdown Hook的具体实现逻辑,在org.springframework.context.support.AbstractApplicationContext#doClose

protected void doClose() {
    if (this.active.get() && this.closed.compareAndSet(false, true)) {
        // ...(省略)

        // 发布Spring 应用上下文的关闭事件,让监听器们有机会在应用关闭之前做出一些响应
        publishEvent(new ContextClosedEvent(this));

        // 执行lifecycleProcessor的关闭方法,让Lifecycle们有机会在应用关闭之前做出一些响应
        this.lifecycleProcessor.onClose();
            
        // 销毁IOC容器里所有单例Bean
        destroyBeans();

        // 关闭BeanFactory
        closeBeanFactory();

        // 勾子函数,让子类实现后做各自的资源清理,比如ServletWebServerApplicationContext会实现该勾子函数关闭内嵌的WebServer(Tomcat)
        onClose();

        this.active.set(false);
    }
}

Spring Shutdown Hook 一共做了5件事:

  1. 发布Spring应用上下文的关闭事件,让监听器们有机会在应用关闭之前做出一些响应
  2. 执行lifecycleProcessor的关闭方法,让Lifecycle们有机会在应用关闭之前做出一些响应
  3. 销毁IOC容器里所有单例Bean
  4. 关闭BeanFactory
  5. 执行勾子函数,子类实现后做各自的资源清理,比如ServletWebServerApplicationContext会实现该勾子函数关闭内嵌的WebServer(Tomcat)

不得不赞称,站在上层的角度去理解,该段逻辑非常清晰,这样的代码鲜明地为我们展示了编码原则:一个方法内部,代码尽量保持在同一抽象层次

其中第1、第2件事,正是我们在Spring环境中正确关闭线程池的姿势利用到的解决方案:即在第3件事情开始前,通过某些机制通知应用程序对事件做出响应

第1件事与第2件事看起来很像,都是让应用关闭之前做出一些响应,但是有使用场景的区别:

  • ContextClosedEvent是应用级别的事件,因此对之做出的响应更适用于全局性的行为
  • Lifecycle一般是Bean级别的通知,因此对之做出的响应更适用于单个Bean的行为

接下来看第3件事:org.springframework.context.support.AbstractApplicationContext#destroyBeans

image-20200722205332016

这是个模板方法,默认情况下会销毁IOC容器里的单例Bean,子类可以覆盖它并添加一些额外的行为,但是迄今为止,也没有子类覆盖该方法

org.springframework.beans.factory.support.DefaultListableBeanFactory#destroySingletons 方法如下:

image-20200722210024779

destroySingletons是个重载方法,核心逻辑在父类DefaultSingletonBeanRegistry中,调用完父类方法后就清理一下本类涉及的的一些本地缓存数据。我们接着看父类方法的逻辑:

// org.springframework.beans.factory.support.DefaultSingletonBeanRegistry#destroySingletons

public void destroySingletons() {
    // ...(省略)

    String[] disposableBeanNames;
    // disposableBeans 是个Map,Key为bean name,value为disposable bean实例
    // 即 <beanName, disposableInstance>
    // private final Map<String, Object> disposableBeans = new LinkedHashMap<>();
    synchronized (this.disposableBeans) {
        disposableBeanNames = StringUtils.toStringArray(this.disposableBeans.keySet());
    }
    // 依次销毁 disposableInstances
    for (int i = disposableBeanNames.length - 1; i >= 0; i--) {
        destroySingleton(disposableBeanNames[i]);
    }
    // 清除本类使用到的一些本地缓存
    this.containedBeanMap.clear();
    this.dependentBeanMap.clear();
    this.dependenciesForBeanMap.clear();
    // 清除单例缓存
    clearSingletonCache();
}

/**
 * Clear all cached singleton instances in this registry.
 * @since 4.3.15
 */
protected void clearSingletonCache() {
    synchronized (this.singletonObjects) {
        this.singletonObjects.clear();
        this.singletonFactories.clear();
        this.earlySingletonObjects.clear();
        this.registeredSingletons.clear();
        this.singletonsCurrentlyInDestruction = false;
    }
}

这个段方法的逻辑也很简单:

  1. 拿到所有的disposable beans(即实现了DisposableBean接口的bean),依次执行destroySingleton方法,进行资源回收
  2. 清除本类使用到的一些本地缓存
  3. 清除单例缓存

2、3清除缓存的动作很简单,就是调用Map#clear\Set#clear方法,将集合清空

这里有两个缓存Map需要注意:dependentBeanMapdependenciesForBeanMap,它们的定义如下:

/** Map between dependent bean names: bean name to Set of dependent bean names. */
private final Map<String, Set<String>> dependentBeanMap = new ConcurrentHashMap<>(64);

/** Map between depending bean names: bean name to Set of bean names for the bean's dependencies. */
private final Map<String, Set<String>> dependenciesForBeanMap = new ConcurrentHashMap<>(64);
  • dependentBeanMap: Bean名称和所有依赖于Bean的名称的映射关系,即:谁依赖我
  • dependenciesForBeanMap: Bean名称和Bean所依赖的所有名称的映射关系,即:我依赖谁
image-20200722220042221

命名上很像,不好理解。我举个例子帮助理解:假设A依赖B(即A->B),A依赖C(即A->C),那么,

  • dependentBeanMap: <B, [A]>与<C, [A]>
  • dependenciesForBeanMap: <A, [B,C]>

此处请先将两个Map映射关系记住,至于具体作用会在下文解释

还有一个缓存Map: containedBeanMap,定义如下:

/** Map between containing bean names: bean name to Set of bean names that the bean contains. */
private final Map<String, Set<String>> containedBeanMap = new ConcurrentHashMap<>(16);
  • containedBeanMap: Bean名称和Bean所包含的所有Bean的名称的映射关系,即:我包含谁

这种"我包含谁"的关系在主流的Annotation-Base的场景下已经比较少出现了,要构造这种映射关系,需要是XML-Base,

假设Foo包含Bar,需要通过如下Spring的配置文件进行配置,才会将这种"包含"关系放入containedBeanMap

public class Foo {

    private Bar bar;

    public Foo(Bar bar) {
        this.bar = bar;
    }
}

public class Bar {
}
// Spring 配置文件

<bean id="foo" class="com.example.demo.Foo">
     <constructor-arg>
         <bean class="com.example.demo.Bar"/>
     </constructor-arg>
 </bean>

从另一个角度看,这也是一种依赖关系:Foo依赖Bar。由于使用该场景的人越来越少,因此简单了解一下containedBeanMap的含义即可

接下来看org.springframework.beans.factory.support.DefaultListableBeanFactory#destroySingleton方法,销毁单个bean,注意跟上文提到的方法的区别,上文是destroySingletons

image-20200722220501030

同样的,destroySingleton也是个重载方法,核心逻辑也是在父类DefaultSingletonBeanRegistry中,接着看:org.springframework.beans.factory.support.DefaultSingletonBeanRegistry#destroySingleton

image-20200723101422658
image-20200723101835006

接下来看org.springframework.beans.factory.support.DefaultSingletonBeanRegistry#destroyBean方法:

// org.springframework.beans.factory.support.DefaultSingletonBeanRegistry#destroyBean

protected void destroyBean(String beanName, @Nullable DisposableBean bean) {
    // 1. 首先回收所有依赖"我"的beans
    Set<String> dependencies;
    synchronized (this.dependentBeanMap) {
        // Within full synchronization in order to guarantee a disconnected Set
        dependencies = this.dependentBeanMap.remove(beanName);
    }
    for (String dependentBeanName : dependencies) {
        // 递归调用DefaultSingletonBeanRegistry#destroySingleton
        destroySingleton(dependentBeanName);
    }

    // 2. 执行DisposableBean的destroy方法,进行资源的回收
    bean.destroy();

    // 3. 回收"我"包含的所有beans
    Set<String> containedBeans;
    synchronized (this.containedBeanMap) {
        // Within full synchronization in order to guarantee a disconnected Set
        containedBeans = this.containedBeanMap.remove(beanName);
    }
    if (containedBeans != null) {
        for (String containedBeanName : containedBeans) {
            destroySingleton(containedBeanName);
        }
    }

    // 4. 解除"我"对其他Bean的依赖关系(dependentBeanMap)
    synchronized (this.dependentBeanMap) {
        for (Iterator<Map.Entry<String, Set<String>>> it = this.dependentBeanMap.entrySet().iterator(); it.hasNext();) {
            Map.Entry<String, Set<String>> entry = it.next();
            Set<String> dependenciesToClean = entry.getValue();
            dependenciesToClean.remove(beanName);
            if (dependenciesToClean.isEmpty()) {
                it.remove();
            }
        }
    }

    // 5. 解除"我"对其他Bean的依赖关系(dependenciesForBeanMap)
    this.dependenciesForBeanMap.remove(beanName);
}

这个方法一共做了5件事:

  1. 首先回收所有依赖"我"的beans
  2. 执行DisposableBean的destroy方法,进行资源的回收
  3. 回收"我"包含的所有beans(containedBeanMap)
  4. 解除"我"对其他Bean的依赖关系(dependentBeanMap)
  5. 解除"我"对其他Bean的依赖关系(dependenciesForBeanMap)

为了便于理解,我举个例子来分析这整个过程:

  • 假设A\B\C三个Bean,A\C都是普通的被Spring管理的Bean,B实现了DisposableBean接口,同样被Spring管理
  • A依赖B,B依赖C
image-20200723131333874

上图显示初始状态下的依赖关系,以及三个Map各自的数据

此时,要销毁Bean B

  1. 首先回收所有依赖"我"的beans。通过dependentBeanMap找到"谁依赖我",递归执行destroySingleton将依赖我的对象先回收掉,由图可知A依赖了B,因此先回收A。该步骤执行完之后,状态如下示:

    image-20200723133055164
  2. 执行DisposableBean的destroy方法,进行资源的回收。此处,要执行B的destroy方法,完成资源的回收。一旦该方法执行完毕,说明B就已经完成其使命,可以被回收掉

  3. 回收"我"包含的所有beans(containedBeanMap)。 由于此处不构造containedBeanMap,为空,此步骤跳过

  4. 解除"我"对其他Bean的依赖关系(dependentBeanMap)。B被销毁之后,已经是一个"无用"的Bean,但是它本身可能还引用着其它的Bean,这种引用关系仍然被保存在dependentBeanMap里,因此需要把这种引用关系断掉,来保证逻辑语义的正确

  5. 解除"我"对其他Bean的依赖关系(dependenciesForBeanMap)。同第4步,引用关系仍然可能被保存在dependenciesForBeanMap里,因此需要把这种引用关系断掉,来保证逻辑语义的正确

第4、第5件事是从不同的Map中断掉这种引用关系,因此本质上是同一回事。经过4、5之后,如图示:

image-20200723133241196

注意:本文中提及的解除引用关系是指在Map上把依赖关系给删除,而不是真正把对象间的引用给解除;

同理,销毁(回收)Bean同样指的是执行destroy方法进行了资源的回收,并不是真的把Bean给销毁、回收

至此,Spring Shutdown Hook整个执行过程我们已经分析完毕,为了更好理解,接下来会用上篇文章的案例来分析Spring Shutdown Hook的执行过程

案例解析

为了阅读的连续性,此处再把案例阐述一遍

@Resource
private RedisTemplate<String, Integer> redisTemplate;

// org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor
@Resource
private ThreadPoolTaskExecutor executor;

@GetMapping("/incr")
public void incr() {
    executor.execute(() -> {
        // 依赖Redis进行计数
        redisTemplate.opsForValue().increment("demo", 1L);
    });
}
  1. 使用Spring的ThreadPoolTaskExecutor,用于异步任务的执行
  2. 高并发请求/incr接口,每次请求该接口,都会往线程池中添加一个任务,任务异步执行的过程中依赖Redis

此时,上游流量被切断且应用程序收到停机请求,在应用启动之初注册的Spring Shutdown Hook被激活

  1. 我们此处并不自定义上篇文章中提到的ContextClosedEvent,也不实现Lifecycle接口,因此发布Spring应用上下文的关闭事件执行lifecycleProcessor的关闭方法这两个过程略过(如果有疑问:这样做,上篇文章提到的问题不就出现了么?不就不能实现优雅关闭线程池了?别急,下面会有答案)
  2. 接着会销毁所有实现了DisposableBean的Bean,很巧的是,ThreadPoolTaskExecutor与JedisConnectionFactory都实现了该接口,因此,依赖关系如图所示:
image-20200723190949389

org.springframework.data.redis.connection.jedis.JedisConnectionFactory#destroy

image

org.springframework.scheduling.concurrent.ExecutorConfigurationSupport#destroy

image
image

按照我们上文的分析,ThreadPoolTaskExecutor、JedisConnectionFactory的destroy方法都会被执行:

  • ThreadPoolTaskExecutor#destroy: 执行线程池的优雅关闭

  • JedisConnectionFactory#destroy: 关闭Jedis连接池,回收Jedis连接

那ThreadPoolTaskExecutor与JedisConnectionFactory执行destroySingleton方法的先后不同,会导致结果的不同吗?

  • 假设ThreadPoolTaskExecutor先执行。此时XXXController会先被destroy,然后执行ThreadPoolTaskExecutor#destroy,由于支持优雅关闭,任务理论上已经执行完毕,不再需要使用到RedisTemplate,因此这种情况OK
  • 假设JedisConnectionFactory行先执行。此时RedisTemplate会先要求被destroy,进而引发XXXController与ThreadPoolTaskExecutor先行被destroy。此时就进入了第一种情况,因此这种情况也是OK的

可以发现,无论ThreadPoolTaskExecutor、JedisConnectionFactory谁先执行destroySingleton,结果都是一样的,都能使得线程池被优雅关闭,根本原因就是Spring会找到引用链中的头节点先行销毁,然后依着引用链依次销毁Bean,使得最底层被依赖的对象最晚被销毁

那么为什么上篇文章还会出现Spring环境下线程池未优雅关闭的问题?

那是因为,很多代码会直接使用自定义的JDK线程池,未被Spring管理,也没有找到合适的地方执行shutdown(Now) + awaitTermination。Spring Shutdown Hook执行的时候,只能找到它管理的Bean进行销毁,而我们使用的自定义的JDK线程池既不被Spring管理,也没有实现DisposableBean,Spring必然"看不见"该线程池的存在,直接就把JedisConnectionFactory给回收了,导致线程池里的任务获取连接失败

所以你瞧,使用ThreadPoolTaskExecutor还有这种福利,真是个意外的惊喜,建议大家在Spring环境中都使用它代替直接使用JDK线程池类。 当然,如果有定制线程池的需要,也可以自定义线程池类,然后再实现DisposableBean接口同时把相应的destroy方法实现,同时将实例交给Spring管理,效果也是等价的

那些非DisposableBean beans是如何销毁的?

需要实现资源回收的Bean,需要关注Bean销毁事件的Bean才需要实现DisposableBean接口。我们一般开发过程中使用到的无状态的Controller、Service,是不需要实现DisposableBean接口的--->我们何时关心过它们的销毁呢?所以,我们不关心,Spring当然也不关心,Spring Shutdown Hook 的第3件事"销毁IOC容器里所有单例Bean",只是执行DisposableBean的destroy方法完成资源回收工作,以及清空各种依赖关系的Map和Singleon Cache,但对象本身并没有真实被销毁。因此对于非DisposableBean beans,在接下来应用关闭之后就自动死亡

总结

本篇文章主要分析了Spring Shutdown Hook的执行流程,从源码层面可以看出作者的代码功力非常强,考虑到了多种扩展角度(扩展点机制、模板方法、勾子方法),代码从布局上也非常清晰,同一抽象语义的代码在同一个方法里,易于理解跟阅读,这是非常值得我们学习的地方(敲重点:能从源码中学到什么?)。其次从功能层面可以看到,做为一个成熟的框架,Spring考虑的非常全面:哪些Bean需要先销毁哪些Bean需要后销毁,哪些Bean需要执行资源回收方法,哪些Bean不需要执行资源回收方法都是有考量的,资源回收之后还清理各种本地缓存和映射关系,确保程序逻辑语义的正确。正是Spring考虑的多,所以我们才可以心安理得考虑的少:哪有什么岁月静好,只不过有人替你负重前行

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,999评论 4 368
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,102评论 1 302
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,709评论 0 250
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,439评论 0 217
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,846评论 3 294
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,881评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,062评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,783评论 0 205
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,517评论 1 248
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,762评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,241评论 1 264
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,568评论 3 260
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,236评论 3 241
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,145评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,941评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,965评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,802评论 2 275

推荐阅读更多精彩内容