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

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

科技

阿里妹导读


作者通过看过的两篇文章发现实现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初始化中执行耗时操作,考虑改造其初始化方法为异步。


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


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


微信扫码关注该文公众号作者

戳这里提交新闻线索和高质量文章给我们。
相关阅读
西雅图周末不无聊|华州春天最盛大的庆祝活动Washington Spring Fair来啦!建设元宇宙基础设施——PPIO边缘云在云渲染/云游戏的思考和实践【首发】极瞳生命完成数千万Pre-A轮融资,加速高端生命科学仪器SPR分子互作仪国产化进程救命!我以为影像组学都被别人发完了,没想到还有这种发1区的思路CubeFS在大数据和机器学习的探索和实践丨ArchSummit峰会实录百度高考营销:内容与产品并行,探索品牌营销的年轻化转型In Northeast China, a New ‘Fangcang’ Hospital Sparks Uproar加速AI推理,探索英特尔® 至强® 可扩展处理器在OCR应用落地方面的实践【外汇商品】第三季度核心通胀去化加速——美国6月CPI数据点评【无忧买房】Lexington全新联排别墅出售,顶级学区,2023年全新建成,近2号公路和Arlington用SLG的思路,做二次元策略TeamRPG,网易这款新游戏很大胆!下一代病理产业化加速,阔然生物多重荧光免疫组化助力肿瘤精准诊疗再升级答复snowandlotusEB-5成功案例 │ 缺少官方文件证明初始资金来源?补件后顺利通过展望与计划 Prospecting vs Planning一文讲透财务分析的思路和技巧Bean异步初始化,让你的应用启动飞起来券商机构业务数字化加速!广发证券正式推出"广发智汇",有何布局?海外连推两爆款,莉莉丝的思路变化宾州斯沃斯莫尔学院(Swarthmore College),校园樱花关键业务服务保证技术QoS理论和实践太酷了,这个新风格打开了我的思路我,用老师教的思路参加了一场产品面试,结果......作业帮多云多活架构的探索和实践浅谈 Linux 的性能调优的思路《绿色的牧歌》&《怎么了》哈哈哈好清奇的思路牛大吉吴海金:从产品经理到创业者,创业这10年,我是如何思考和实践的提供一些解决地方财政和房价问题的思路揭秘Spring依赖注入和SpEL表达式Agustín Hernández:中美洲建筑背景下的未来主义巨构血压高至180 ,继续退圈Court Ruling Sparks Debate Over Promoting Work on Social MediaSpring循环依赖那些事儿(含Spring详细流程图)无题
logo
联系我们隐私协议©2024 redian.news
Redian新闻
Redian.news刊载任何文章,不代表同意其说法或描述,仅为提供更多信息,也不构成任何建议。文章信息的合法性及真实性由其作者负责,与Redian.news及其运营公司无关。欢迎投稿,如发现稿件侵权,或作者不愿在本网发表文章,请版权拥有者通知本网处理。