探索|Spring并行初始化加速的思路和实践

新闻资讯   2023-06-14 09:02   94   0  

阿里妹导读


作者通过看过的两篇文章发现实现Spring初始化加速的思路和方案有很多类似之处,通过本文记录一下当时的思考和实践。

前言

之前的一篇文章一些杂想:Java老矣,尚能饭否里提到了一个rhino-boot-turbo组件:
Java应用启动慢,还有一个罪魁祸首是Spring的bean初始化,我之前写了个异步初始化Spring Bean的starter rhino-boot-turbo,把串行改并行启动速度会快很多。
最近看到一篇文章《Java应用提速(速度与激情)-其他优化篇》里讲到了Spring异步加载,以及以《Spring 并行加载 Bean 的理想解决方案与不可为》开始的一系列文章,发现实现的思路和方案有很多类似之处,所以记录一下我当时的思考和实践。
我们现在基于Spring开发的Java应用,启动时长受bean初始化影响非常大,之前对经手过的应用做了一个粗略的统计,大部分启动过程花了50%+的时间在做bean的初始化,更有甚者会占到80%以上(主要是启动时去拉取配置)。
多说一句,这个组件是19年的时候搞的,起这个名字是当初受华为技术“GPU Turbo”的启发....

争议

并行初始化是Spring的老大难问题,而且已经成为“unresolved issue”中投票数最多的之一。其实很早(2011年)就有人向官方提过issue: Parallel bean initialization during startup [SPR-8767] #13410,时至今日该issue仍是open状态。官方的态度是:首先,对于大部分应用而言,启动时间并不存在大问题;其次,并行初始化虽然可能对一小部分应用的效果是显著的,但是会对每个Spring带来不可避免的bug,增加复杂性,以及难以预料的副作用。
The upside of parallelizing bean initialization in the Spring container could be significant for a minority of applications using Spring, while the downsides - the inevitable bugs, added complexity and unintended side effects - would affect potentially every application using Spring. Not an attractive outlook, I'm afraid.
我对于官方“启动时间并不存在大问题”的说法不敢苟同(在issue里也有很多人同样表示:启动性能对我来说非常重要!)为什么并行初始化会“带来不可避免的bug,增加复杂性,以及难以预料的副作用”?可以试着分析一下原因。
首先,如果bean是独立,也就是说没有和其他bean有依赖关系,那么对其独立做初始化是完全可行的,并不会任何问题。
当然,bean和bean之间的依赖是难以避免的。这种依赖关系可以用一个有向有环图表达,如下图:


有环图是很难处理的,一种朴素的想法就是将其转化为有向无环图(DAG)。图里的环(D - E)来自于循环依赖,我们要做的就是将循环依赖的bean当做是同一组。得到DAG后,就可以先加载下层的bean,然后同一层的bean做并行加载。在issue中也有人提出了这个想法。我个人理解,这个方案的难点在于:
  1. DAG的分析很难,包括如何分析以及分析本身的耗时,特别是循环依赖的嵌套比较深的时候。

  2. 兼容目前的生态很难。打个比方,按照Spring目前的设计,有很多开放的扩展点可以修改bean的定义和依赖,比如BeanDefinitionRegistryPostProcessorBeanFactoryPostProcessor等。如何兼容是个难题。

总结一下,Spring至今拖着没有支持并行初始化,最大的困难在于,需要重写大量底层流程逻辑,在重写的基础上,还要兼容目前大生态下的已经开放出去的扩展点。
既然构建DAG比较困难,是否有其他方式可以做到并行呢?

思路


背景知识

Spring初始化的流程中,关键的代码在org.springframework.context.support.AbstractApplicationContext#refresh。先借用一张网上的图,来看下Spring Bean启动的生命周期:


Spring会在主线程串行地对所有Bean进行初始化,在一个Bean的生命周期中,有两类初始化方法会被调用:
  1. Init-method: 包括手动指定的init-method和实现InitializingBean时写的afterPropertiesSet,在构造、属性赋值后由BeanFactory调用;

  2. @PostConstruct标记的方法,在构造、属性赋值后由CommonAnnotationBeanPostProcessor调用;

另外,Spring提供了两个扩展点,后面会用到:

  1. ApplicationContextInitializer:在ApplicationContextrefresh之前会调用,可以对ConfiurableApplicationContext实例对象做处理。

  2. 在所有bean初始化完成后,refresh方法的最后一步会publish一个ContextRefreshedEvent,可以注册一个ApplicationListener<ContextRefreshedEvent>来监听该事件。

方案

如果不考虑bean和bean之间的依赖关系的话,只需要改造上面所述的两类初始化方法,将其异步初始化就行了。同时注册一个ApplicationListener<ContextRefreshedEvent>,在监听里等待所有异步初始化的bean完成。
现在考虑bean和bean之间有依赖的情况。既然DAG的构建很困难,我们可以退而求其次,对图中每一个没有依赖边的子图分别做并行初始化。如上面的示例图所示,可以将从顶层的A开始和从F开始的bean做并行初始化,这样是不会有副作用的。思路如下:
  1. 判断一个bean是不是顶层的bean,也就是没有其他的bean依赖它。

  2. 如果是顶层的bean,就单独起一个异步任务做初始化。

  3. 如果不是顶层的bean,那么就意味着肯定有其他bean依赖它,将其放到跟依赖它的bean同一个线程中做串行初始化。

关键是如何判断一个bean是不是顶层bean,或者说是否有bean依赖它呢?Spring是一个依赖注入框架,由容器统一管理所有bean,如果要用到另一个bean,需要从BeanFactory中获取。这时候就会调用到一个关键的方法:
org.springframework.beans.factory.support.AbstractBeanFactory#doGetBean

如果Bean A初始化依赖B,就肯定还在初始化过程中调用doGetBean去从容器中拿Bean B的实例,拿的过程中完成Bean B的初始化,如果B又有其他依赖,也是同理类推。

所以可以考虑使用一个栈,记录每一次doGetBean的情况:当尝试获取一个bean时,把要获取的bean的beanName入栈,当获取返回后,再做出栈。
还是以上面的DAG示例图为例,栈的变化长这样:


如果doGetBean时,发现栈不为空,那么就表示当前栈顶的bean依赖当前要获取的bean,譬如上图获取bean B时,栈不为空,栈顶元素为bean A,说明A依赖了B,A的初始化必须要等到B完成后才能返回。

细节点

实现这个方案有几个细节点值得说一下。
第一是如何替换默认的初始化逻辑?需要改造上面说的两类初始化方法的入口。
1、对于BeanFactory#invokeInitMethods方式调用的,需要替换默认的BeanFactory并重写这个方法:
@Overrideprotected void invokeInitMethods(String beanName, Object bean, RootBeanDefinition mbd) throws Throwable {    // 构建bean初始化任务,提交给taskManager执行    BaseBeanInitTask task = new BaseBeanInitTask(beanName, canAsyncInit(beanName, bean, mbd),        BeanInitTypeEnum.INIT_METHOD) {        @Override        public void doInit() throws Throwable {            AsyncInitBeanFactory.super.invokeInitMethods(beanName, bean, mbd);        }    };    initTaskManager.init(task);}

2、对于由@PostConstruct注解的方法,要替换默认的org.springframework.context.annotation.internalCommonAnnotationProcessor,并重写postProcessBeforeInitialization

@Overridepublic Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {    BaseBeanInitTask task = new BaseBeanInitTask(beanName, canAsyncInit(beanName), BeanInitTypeEnum.POST_CONSTRUCT_METHOD) {        @Override        public void doInit() {            AsyncInitAnnotationBeanPostProcessor.super.postProcessBeforeInitialization(bean, beanName);        }    };    initTaskManager.init(task);    return bean;}

第二是如何替换上面说的BeanFactoryinternalCommonAnnotationProcessor?需要注册一个ApplicationContextInitializer,在refresh之前进行替换:

@Slf4j@Order(Ordered.HIGHEST_PRECEDENCE)public class AsyncInitApplicationContextInitializer    implements ApplicationContextInitializer<ConfigurableApplicationContext> {
@Override public void initialize(ConfigurableApplicationContext context) { if (context instanceof GenericApplicationContext) { attach((GenericApplicationContext)context); } }
private void attach(GenericApplicationContext context) { // 省略 InitTaskManager initTaskManager = new InitTaskManager(config);
// 替换BeanFactory AsyncInitBeanFactory beanFactory = new AsyncInitBeanFactory(context.getBeanFactory(), config, initTaskManager); replaceBeanFactory(context, beanFactory);
// 注入用于处理@PostConstruct注解初始化的BeanPostProcessor AsyncInitAnnotationBeanPostProcessor annotationBeanPostProcessor = new AsyncInitAnnotationBeanPostProcessor( config, initTaskManager); annotationBeanPostProcessor.setBeanFactory(beanFactory); beanFactory.registerSingleton(AsyncInitBeanFactoryPostProcessor.NAME, new AsyncInitBeanFactoryPostProcessor(annotationBeanPostProcessor));
context.addApplicationListener(initTaskManager);
// 省略 }
private void replaceBeanFactory(GenericApplicationContext context, AsyncInitBeanFactory beanFactory) { Field field = ReflectionUtils.findField(context.getClass(), "beanFactory"); field.setAccessible(true); ReflectionUtils.setField(field, context, beanFactory); }}

最后是改造BeanFactory#doGetBean了:
@Overrideprotected <T> T doGetBean(String name, Class<T> requiredType, Object[] args, boolean typeCheckOnly)    throws BeansException {    if (initTaskManager.isStarted()) {        return super.doGetBean(name, requiredType, args, typeCheckOnly);    }
LinkedList<String> stack = stackLocal.get(); if (stack == null) { return super.doGetBean(name, requiredType, args, typeCheckOnly); }
String peek = stack.peek(); stack.push(name); T bean = super.doGetBean(name, requiredType, args, typeCheckOnly); if (peek != null) { // 栈不为空,表示栈顶的bean依赖当前bean,等待当前bean初始化完成 initTaskManager.waitInitDone(name); } stack.pop(); return bean;}

实现

整体代码800+行,实现一个简单的turbo加速器,测了几个应用,启动时间提升在一倍以上。(完整代码无法对外展示)
功能上支持两种加速模式:
  • 自动挡:自动对大部分Bean进行异步初始化加速,适合新手。
  • 手动挡:手动进行配置,比如指定需要和不需要加速的bean,适合老司机。

配置

配置示例:
# 全局启动开关# 默认值:默认为false,需要手动启用spring.rhino-boot-turbo.global-enable=true# [可选] 是否启动自动档加速# 默认值:truespring.rhino-boot-turbo.auto-mode-enable=true# [可选] 异步初始化线程池大小,如果超出线程池大小,初始化任务会放到主线程执行# 默认值:Runtime.getRuntime().availableProcessors() * 2spring.rhino-boot-turbo.pool-size=20# [可选] 最大等待超时(单位:秒),如果超出该值一个bean还没初始化完成,则会报错# 默认值:60,如果有bean初始化特别久,可以考虑增加超时时间spring.rhino-boot-turbo.wait-timeout=60# [可选] 需要被异步加载的bean名称列表,如何配置参考手动挡说明spring.rhino-boot-turbo.include=beanA,beanB# [可选] 不想被异步加载的bean名称列表,如何配置参考手动挡说明spring.rhino-boot-turbo.exclude=beanA,beanB# [可选] 需要跳过等待的bean名称,如何配置参考手动挡说明spring.rhino-boot-turbo.skip-wait=beanC
为了保证线上环境的绝对安全,组件只会在本地、项目和日常环境生效,即启动项-Dspring.profiles.active为其中之一:testprojectdefault

自动挡

在默认配置下(spring.rhino-boot-turbo.auto-mode-enable=true),会自动对大部分存在第一类初始化方法(init-method)的Bean进行异步初始化加速。
一些例外:
  1. Spring本身的Bean,例如class名称以org.springframework开头; 
    这类Bean一般耗时很短,也没有异步初始化的必要
  2. 一些Spring的生命周期回调Bean,例如ApplicationContextInitializerBeanFactoryPostProcessorBeanPostProcessor
    对这类Bean进行异步初始化可能会有意想不到的后果,如果确定可以异步,参考手动挡配置。

启动统计

在启动完成后,可以访问ip:managementport/bean-init页面(例如:localhost:7002/bean-init)来查看启动统计。
结果样例:
{    "beanCount": 698,                      # bean数目    "taskCount": 1377,                      # bean初始化任务数量,包括同步和异步的init-method和@PostConstruct    "totalInitTime": 176658,            # bean初始化时长总计,单位ms,包括异步和同步任务    "totalWaitTime": 27282,               # 等待时长总计,包括异步和同步任务    "totalSyncInitTime": 14048,            # 同步任务初始化时长总计    "totalAsyncInitTime": 162610,        # 异步任务初始化时长总计    "totalSyncWaitTime": 14048,            # 异步任务等待时长总计    "totalAsyncWaitTime": 13234,        # 异步任务等待时长总计    "tasks": [                                            # 所有初始化任务列表        {            "beanName": "beanA",                                        # bean名称            "type": "POST_CONSTRUCT_METHOD",            # 初始化类型,分POST_CONSTRUCT_METHOD和INIT_METHOD            "initTime": 13007,                                                  # 初始化用时            "waitTime": 13007                                                # 等待用时        },        {            "beanName": "beanB (async)",                          # 标记了(async)的表示会进行异步初始化            "type": "INIT_METHOD",            "initTime": 13125,            "waitTime": 13001        },                ....      }]}

手动挡

适合对代码熟悉,追求更快启动速度的老司机。
为了保证效率和安全,自动挡会进行以下限制:
  1. 不会对特定类型的Bean进行异步初始化,参考自动档说明; 

  2. @PostConstruct初始化方式的bean,目前自动档不会自动进行异步初始化,这一点主要是考虑到在class中反射获取@PostConstruct注解方法在极端情况下可能会比较耗时; 
    Spring本身会对找到的@PostConstruct注解方法进行缓存,但放在private内部类中,无法hook拿到;
  3. 如果一个bean在初始化时发现依赖了其他的bean,默认会阻塞等待这个被依赖的bean初始化完成;

另外,有一些bean可能不能安全地进行异步初始化,但自动挡无法自动识别。举个例子:beanA依赖beanB,但是beanA是通过手动set的方式引用了beanB(而不是通过@Autowired、构造器注入等正常方式),在异步初始化beanA时不会等待beanB完成,导致运行异常。
针对以上各种情况,可以进行手动配置。

判断一个bean是否要异步初始化

如何判断一个bean是否有必要进行异步初始化?可以参考初始化任务列表中的waitTime字段(启动统计的任务列表会根据waitTime等待时长进行排序),该字段表示阻塞等待该bean完成初始化的耗时,包括两部分:

  • 在初始化其他bean时,发现依赖了当前的bean,为了保证安全,需要阻塞等待该bean初始化完成; 
    例如,beanA依赖beanB,beanB依赖beanC,依赖链beanA -> beanB -> beanC,都进行异步初始化:
    在初始化beanA时,发现beanB还未完成初始化,则会等待beanB完成初始化,等待时间会加到beanB的waitTime上;
    在初始化beanB时,发现beanC还未完成初始化,则会等待beanC完成初始化,等待时间会加到beanC的waitTime上,同时也会影响beanB的waitTime;
  • Spring完成了启动,发出了ContextRefreshedEvent事件,但实际上有的bean还未异步初始化完成,也需要阻塞等待其完成,这部分时间也会加到waitTime上;

因此waitTime反映了由于这个bean初始化阻塞的时间,在优化时区分两类情况:
  • 如果这个bean没有进行异步初始化(在启动统计中没有(async)后缀),考虑将其指定进行异步初始化

  • 如果这个bean已经进行了异步初始化(在启动统计中带有(async)后缀),考虑将其指定跳过阻塞等待,前提是确认其不会影响其他bean,几个参考判断条件: 

    • 依赖这个bean的其他bean不会在初始化时就调用必须在当前bean初始化完成后才能使用的功能,这时候跳过等待当前bean是OK的。一般来说,大部分bean是满足这个条件的,例如HSF的provider/consumer,metaq的的producer/consumer等等;

    • 应用启动后,除非手动触发,不会立刻就调用必须初始化完成才能使用的功能;
指定异步初始化的bean
通过配置项spring.rhino-boot-turbo.include可以手动指定要异步初始化的bean。
spring.rhino-boot-turbo.include=beanA,beanB
指定跳过等待
通过配置项spring.rhino-boot-turbo.skip-wait可以手动指定不进行阻塞等待初始化完成的bean。
spring.rhino-boot-turbo.skip-wait=beanC
指定不要异步初始化的bean
通过配置项spring.rhino-boot-turbo.exclude可以手动排除自动档会进行异步初始化的bean。
spring.rhino-boot-turbo.exclude=beanA,beanB

已知问题

  1. 建议不要配置跳过等待dataSource,虽然TDDL初始化耗时很长,但跳过等待有几率会触发Pandora内部的死锁,目前无解。

题外

其实Spring官方也“辩解”了一下:大多数时候应用启动慢的元凶就是少数的一些bean,可以重点优化这些bean的初始化时长。具体如何优化?官方没有说,这里列一些可行的方法:
  • 如无必要,不在bean初始化中做耗时的操作,包括:阻塞IO操作、远程接口调用等等。

  • 依赖加载的方式,将初始化工作后置。

  • 如果确实是要在bean初始化中执行耗时操作,考虑改造其初始化方法为异步。


阿里云开发者社区,千万开发者的选择


阿里云开发者社区,百万精品技术内容、千节免费系统课程、丰富的体验场景、活跃的社群活动、行业专家分享交流,欢迎点击【阅读原文】加入我们。


文章引用微信公众号"阿里开发者",如有侵权,请联系管理员删除!

博客评论
还没有人评论,赶紧抢个沙发~
发表评论
说明:请文明发言,共建和谐网络,您的个人信息不会被公开显示。