您可以捐助,支持我们的公益事业。

1元 10元 50元





认证码:  验证码,看不清楚?请点击刷新验证码 必填



  求知 文章 文库 Lib 视频 iPerson 课程 认证 咨询 工具 讲座 Model Center   Code  
会员   
   
 
     
   
 
 订阅
Spring并行初始化加速的思路和实践
 
作者: 傅健丰(健风)

  574  次浏览      13 次
 2023-6-14
 
编辑推荐:
本文主要介绍了Spring并行初始化加速的思路和实践相关知识。希望能对遇到类似问题的同学有所帮助。
本文来自于微信公众号阿里开发者,由火龙果软件Linda编辑、推荐。

前言

之前的一篇文章《一些杂想: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中也有人提出了这个想法。我个人理解,这个方案的难点在于:

DAG的分析很难,包括如何分析以及分析本身的耗时,特别是循环依赖的嵌套比较深的时候。

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

总结一下,Spring至今拖着没有支持并行初始化,最大的困难在于,需要重写大量底层流程逻辑,在重写的基础上,还要兼容目前大生态下的已经开放出去的扩展点。

既然构建DAG比较困难,是否有其他方式可以做到并行呢?

思路

背景知识

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

Spring会在主线程串行地对所有Bean进行初始化,在一个Bean的生命周期中,有两类初始化方法会被调用:

Init-method: 包括手动指定的init-method和实现InitializingBean时写的afterPropertiesSet,在构造、属性赋值后由BeanFactory调用;

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

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

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

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

方案

如果不考虑bean和bean之间的依赖关系的话,只需要改造上面所述的两类初始化方法,将其异步初始化就行了。同时注册一个ApplicationListener<ContextRefreshedEvent>,在监听里等待所有异步初始化的bean完成。

现在考虑bean和bean之间有依赖的情况。既然DAG的构建很困难,我们可以退而求其次,对图中每一个没有依赖边的子图分别做并行初始化。如上面的示例图所示,可以将从顶层的A开始和从F开始的bean做并行初始化,这样是不会有副作用的。思路如下:

判断一个bean是不是顶层的bean,也就是没有其他的bean依赖它。

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

如果不是顶层的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并重写这个方法:

@Override

protected 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.internal
CommonAnnotationProcessor,并重写postProcessBeforeInitialization:

@Override

public 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;

}

 

第二是如何替换上面说的BeanFactory和internalCommonAnnotationProcessor?需要注册一个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了:

@Override

protected <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

# [可选] 是否启动自动档加速

# 默认值:true

spring.rhino-boot-turbo.auto-mode-enable=true

# [可选] 异步初始化线程池大小,如果超出线程池大小,初始化任务会放到主线程执行

# 默认值:Runtime.getRuntime().availableProcessors() * 2

spring.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为其中之一:test、project、default。

自动挡

在默认配置下(spring.rhino-boot-turbo.auto-mode-enable=true),会自动对大部分存在第一类初始化方法(init-method)的Bean进行异步初始化加速。

一些例外:

Spring本身的Bean,例如class名称以org.springframework开头;

这类Bean一般耗时很短,也没有异步初始化的必要

一些Spring的生命周期回调Bean,例如ApplicationContextInitializer、
BeanFactoryPostProcessor、BeanPostProcessor

对这类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

},

....

}]

}

 

手动挡

适合对代码熟悉,追求更快启动速度的老司机。

为了保证效率和安全,自动挡会进行以下限制:

不会对特定类型的Bean进行异步初始化,参考自动档说明;

对@PostConstruct初始化方式的bean,目前自动档不会自动进行异步初始化,这一点主要是考虑到在class中反射获取@PostConstruct注解方法在极端情况下可能会比较耗时;

Spring本身会对找到的@PostConstruct注解方法进行缓存,但放在private内部类中,无法hook拿到;

如果一个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

已知问题

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

题外

其实Spring官方也“辩解”了一下:大多数时候应用启动慢的元凶就是少数的一些bean,可以重点优化这些bean的初始化时长。具体如何优化?官方没有说,这里列一些可行的方法:

如无必要,不在bean初始化中做耗时的操作,包括:阻塞IO操作、远程接口调用等等。

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

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

 

 
   
574 次浏览       13
相关文章

Java微服务新生代之Nacos
深入理解Java中的容器
Java容器详解
Java代码质量检查工具及使用案例
相关文档

Java性能优化
Spring框架
SSM框架简单简绍
从零开始学java编程经典
相关课程

高性能Java编程与系统性能优化
JavaEE架构、 设计模式及性能调优
Java编程基础到应用开发
JAVA虚拟机原理剖析

最新活动计划
自动化测试体系建设与最佳实践 12-21[北京]
基于UML和EA进行分析设计 1-11[在线]
需求分析师能力培养 1-18[在线]
大语言模型(LLM)Fine Tune 1-20[在线特惠]
业务驱动的数字化应用 1-30日[在线]
图数据库与知识图谱 2-25[北京]
 
 
最新文章
Java虚拟机架构
JVM——Java虚拟机架构
Java容器详解
Java进阶--深入理解ArrayList实现原理
Java并发容器,底层原理深入分析
最新课程
java编程基础到应用开发
JavaEE架构、 设计模式及性能调优
高性能Java编程与系统性能优化
SpringBoot&Cloud、JavaSSM框架
Spring Boot 培训
更多...   
成功案例
国内知名银行 Spring+SpringBoot+Cloud+MVC
北京 Java编程基础与网页开发基础
北京 Struts+Spring
华夏基金 ActiveMQ 原理
某民航公 Java基础编程到应用开发
更多...