关于java:7min到40sSpringBoot启动优化实践

46次阅读

共计 12656 个字符,预计需要花费 32 分钟才能阅读完成。

大家好,我是不才陈某~

公司 SpringBoot 我的项目在日常开发过程中发现服务启动过程异样迟缓,经常须要 6 - 7 分钟能力裸露端口,重大升高开发效率。通过 SpringBoot 的 SpringApplicationRunListenerBeanPostProcessor 原理和源码调试等伎俩排查发现,在 Bean 扫描和 Bean 注入这个两个阶段有很大的性能瓶颈。

关注公众号:码猿技术专栏,回复关键词:1111 获取阿里外部 Java 性能调优手册

通过 JavaConfig 注册 Bean,缩小 SpringBoot 的扫描门路,同时基于 Springboot 主动配置原理对第三方依赖优化革新,将服务本地启动工夫从 7min 降至 40s 左右的过程。本文会波及以下知识点:

  • 基于 SpringApplicationRunListener 原理察看 SpringBoot 启动 run 办法;
  • 基于 BeanPostProcessor 原理监控 Bean 注入耗时;
  • SpringBoot Cache 自动化配置原理;
  • SpringBoot 自动化配置原理及 starter 革新;

1. 耗时问题排查

SpringBoot 服务启动耗时排查,目前有 2 个思路:

  1. 排查 SpringBoot 服务的启动过程;
  2. 排查 Bean 的初始化耗时;

1.1 察看 SpringBoot 启动 run 办法

该我的项目应用基于 SpringBoot 革新的外部微服务组件 XxBoot 作为服务端实现,其启动流程与 SpringBoot 相似,分为 ApplicationContext 结构和 ApplicationContext 启动两局部,即通过构造函数实例化 ApplicationContext 对象,并调用其 run 办法启动服务:

public class Application {public static void main(String[] args) {SpringApplication.run(Application.class, args);
    }
}

public static ConfigurableApplicationContext run(Class<?>[] primarySources, String[] args) {return new SpringApplication(primarySources).run(args);
}

ApplicationContext 对象结构过程,次要做了自定义 Banner 设置、利用类型推断、配置源设置等工作,不做非凡扩大的话,大部分我的项目都是差不多的,不太可能引起耗时问题。通过在 run 办法中打断点,启动后很快就运行到断点地位,也能验证这一点。
接下就是重点排查 run 办法的启动过程中有哪些性能瓶颈?SpringBoot 的启动过程非常复杂,庆幸的是 SpringBoot 自身提供的一些机制,将 SpringBoot 的启动过程划分了多个阶段,这个阶段划分的过程就体现在 SpringApplicationRunListener 接口中,该接口将 ApplicationContext 对象的 run 办法划分成不同的阶段:

public interface SpringApplicationRunListener {
    // run 办法第一次被执行时调用,晚期初始化工作
    void starting();
    // environment 创立后,ApplicationContext 创立前
    void environmentPrepared(ConfigurableEnvironment environment);
    // ApplicationContext 实例创立,局部属性设置了
    void contextPrepared(ConfigurableApplicationContext context);
    // ApplicationContext 加载后,refresh 前
    void contextLoaded(ConfigurableApplicationContext context);
    // refresh 后
    void started(ConfigurableApplicationContext context);
    // 所有初始化实现后,run 完结前
    void running(ConfigurableApplicationContext context);
    // 初始化失败后
    void failed(ConfigurableApplicationContext context, Throwable exception);
}

目前,SpringBoot 中自带的 SpringApplicationRunListener 接口只有一个实现类:EventPublishingRunListener,该实现类作用:通过观察者模式的事件机制,在 run 办法的不同阶段触发 Event 事件,ApplicationListener 的实现类们通过监听不同的 Event 事件对象触发不同的业务解决逻辑。

通过自定义实现 ApplicationListener 实现类,能够在 SpringBoot 启动的不同阶段,实现肯定的解决,可见SpringApplicationRunListener 接口给 SpringBoot 带来了扩展性。

这里咱们不用深究实现类 EventPublishingRunListener 的性能,然而能够通过 SpringApplicationRunListener 原理,增加一个自定义的实现类,在不同阶段完结时打印下以后工夫,通过计算不同阶段的运行工夫,就能大体定位哪些阶段耗时比拟高,而后重点排查这些阶段的代码。
先看下 SpringApplicationRunListener 的实现原理,其划分不同阶段的逻辑体现在 ApplicationContextrun 办法中:

public ConfigurableApplicationContext run(String... args) {
    ...
    // 加载所有 SpringApplicationRunListener 的实现类
    SpringApplicationRunListeners listeners = getRunListeners(args);
    // 调用了 starting
    listeners.starting();
    try {ApplicationArguments applicationArguments = new DefaultApplicationArguments(args);
        // 调用了 environmentPrepared
        ConfigurableEnvironment environment = prepareEnvironment(listeners, applicationArguments);
        configureIgnoreBeanInfo(environment);
        Banner printedBanner = printBanner(environment);
        context = createApplicationContext();
        exceptionReporters = getSpringFactoriesInstances(SpringBootExceptionReporter.class, new Class[] {ConfigurableApplicationContext.class}, context);
        // 外部调用了 contextPrepared、contextLoaded
        prepareContext(context, environment, listeners, applicationArguments, printedBanner);
        refreshContext(context);
        afterRefresh(context, applicationArguments);
        stopWatch.stop();
        if (this.logStartupInfo) {new StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);
        }
        // 调用了 started
        listeners.started(context);
        callRunners(context, applicationArguments);
    }
    catch (Throwable ex) {
        // 外部调用了 failed
        handleRunFailure(context, ex, exceptionReporters, listeners);
        throw new IllegalStateException(ex);
    }
    try {
        // 调用了 running
        listeners.running(context);
    }
    catch (Throwable ex) {handleRunFailure(context, ex, exceptionReporters, null);
        throw new IllegalStateException(ex);
    }
    return context;
}

run 办法中 getRunListeners(args) 通过 SpringFactoriesLoader 加载 classpathMETA-INF/spring.factotries 中配置的所有 SpringApplicationRunListener 的实现类,通过反射实例化后,存到局部变量 listeners 中,其类型为 SpringApplicationRunListeners;而后在 run 办法不同阶段通过调用 listeners 的不同阶段办法来触发 SpringApplicationRunListener 所有实现类的阶段办法调用。

因而,只有编写一个 SpringApplicationRunListener 的自定义实现类,在实现接口不同阶段办法时,打印以后工夫;并在 META-INF/spring.factotries 中配置该类后,该类也会实例化,存到 listeners 中;在不同阶段完结时打印完结工夫,以此来评估不同阶段的执行耗时。
在我的项目中增加实现类 MySpringApplicationRunListener

@Slf4j
public class MySpringApplicationRunListener implements SpringApplicationRunListener {
    // 这个构造函数不能少,否则反射生成实例会报错
    public MySpringApplicationRunListener(SpringApplication sa, String[] args) { }
    @Override
    public void starting() {log.info("starting {}", LocalDateTime.now());
    }
    @Override
    public void environmentPrepared(ConfigurableEnvironment environment) {log.info("environmentPrepared {}", LocalDateTime.now());
    }
    @Override
    public void contextPrepared(ConfigurableApplicationContext context) {log.info("contextPrepared {}", LocalDateTime.now());
    }
    @Override
    public void contextLoaded(ConfigurableApplicationContext context) {log.info("contextLoaded {}", LocalDateTime.now());
    }
    @Override
    public void started(ConfigurableApplicationContext context) {log.info("started {}", LocalDateTime.now());
    }
    @Override
    public void running(ConfigurableApplicationContext context) {log.info("running {}", LocalDateTime.now());
    }
    @Override
    public void failed(ConfigurableApplicationContext context, Throwable exception) {log.info("failed {}", LocalDateTime.now());
    }
}

这边 (SpringApplication sa, String[] args) 参数类型的构造函数不能少,因为源码中限定了应用该参数类型的构造函数反射生成实例。

resources 文件下的 META-INF/spring.factotries 文件中配置上该类:

# Run Listeners
org.springframework.boot.SpringApplicationRunListener=\
com.xxx.ad.diagnostic.tools.api.MySpringApplicationRunListener

run 办法中是通过 getSpringFactoriesInstances 办法来获取 META-INF/spring.factotries 下配置的 SpringApplicationRunListener 的实现类,其底层是依赖 SpringFactoriesLoader 来获取配置的类的全限定类名,而后反射生成实例;

这种形式在 SpringBoot 用的十分多,如 EnableAutoConfigurationApplicationListenerApplicationContextInitializer 等。

重启服务,察看 MySpringApplicationRunListener 的日志输入,发现次要耗时都在 contextLoadedstarted 两个阶段之间,在这两个阶段之间调用了 2 个办法:refreshContextafterRefresh 办法,而 refreshContext 底层调用的是 AbstractApplicationContext#refresh,Spring 初始化 context 的外围办法之一就是这个 refresh

至此根本能够判定,高耗时的起因就是在初始化 Spring 的 context,然而这个办法仍然十分复杂,好在 refresh 办法也将初始化 Spring 的 context 的过程做了整顿,并具体正文了各个步骤的作用:

通过简略调试,很快就定位了高耗时的起因:

  1. invokeBeanFactoryPostProcessors(beanFactory) 办法中,调用了所有注册的 BeanFactory 的后置处理器;
  2. 其中,ConfigurationClassPostProcessor 这个后置处理器奉献了大部分的耗时;
  3. 查阅相干材料,该后置处理器相当重要,次要负责@Configuration@ComponentScan@Import@Bean 等注解的解析;
  4. 持续调试发现,次要耗时都花在主配置类的 @ComponentScan 解析上,而且次要耗时还是在解析属性 basePackages

即我的项目主配置类上 @SpringBootApplication 注解的 scanBasePackages 属性:

通过该办法 JavaDoc、查看相干代码,大体理解到该过程是在递归扫描、解析 basePackages 所有门路下的 class,对于可作为 Bean 的对象,生成其 BeanDefinition;如果遇到 @Configuration 注解的配置类,还得递归解析其 @ComponentScan。至此,服务启动迟缓的起因就找到了:

  1. 作为数据平台,咱们的服务援用了很多第三方依赖服务,这些依赖往往提供了对应业务的残缺性能,所以提供的 jar 包十分大;
  2. 扫描这些包门路下的 class 十分耗时,很多 class 都不提供 Bean,但还是花工夫扫描了;
  3. 每增加一个服务的依赖,都会线性减少扫描的工夫;

弄明确耗时的起因后,我有 2 个疑难:

  1. 是否所有的 class 都须要扫描,是否能够只扫描那些提供 Bean 的 class?
  2. 扫描进去的 Bean 是否都须要?我只接入一个性能,然而注入了所有的 Bean,这仿佛不太正当?

1.2 监控 Bean 注入耗时

第二个优化的思路是监控所有 Bean 对象初始化的耗时,即每个 Bean 对象实例化、初始化、注册所破费的工夫,有没有特地耗时 Bean 对象?
同样的,咱们能够利用 SpringBoot 提供了 BeanPostProcessor 接口来监控 Bean 的注入耗时,BeanPostProcessor 是 Spring 提供的 Bean 初始化前后的 IOC 钩子,用于在 Bean 初始化的前后执行一些自定义的逻辑:

public interface BeanPostProcessor {
    // 初始化前
    default Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {return bean;}
    // 初始化后
    default Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {return bean;}   
}

对于 BeanPostProcessor 接口的实现类,其前后置处理过程体现在 AbstractAutowireCapableBeanFactory#doCreateBean,这也是 Spring 中十分重要的一个办法,用于真正实例化 Bean 对象,通过 BeanFactory#getBean 办法一路 Debug 就能找到。在该办法中调用了 initializeBean 办法:

protected Object initializeBean(String beanName, Object bean, @Nullable RootBeanDefinition mbd) {
    ...
    Object wrappedBean = bean;
    if (mbd == null || !mbd.isSynthetic()) {
        // 利用所有 BeanPostProcessor 的前置办法
        wrappedBean = applyBeanPostProcessorsBeforeInitialization(wrappedBean, beanName);
    }
    try {invokeInitMethods(beanName, wrappedBean, mbd);
    }
    catch (Throwable ex) {
        throw new BeanCreationException((mbd != null ? mbd.getResourceDescription() : null),
                beanName, "Invocation of init method failed", ex);
    }
    if (mbd == null || !mbd.isSynthetic()) {
        // 利用所有 BeanPostProcessor 的后置办法
        wrappedBean = applyBeanPostProcessorsAfterInitialization(wrappedBean, beanName);
    }
    return wrappedBean;
}

通过 BeanPostProcessor 原理,在前置解决时记录下以后工夫,在后置解决时,用以后工夫减去前置解决工夫,就能晓得每个 Bean 的初始化耗时,上面是我的实现:

@Component
public class TimeCostBeanPostProcessor implements BeanPostProcessor {private Map<String, Long> costMap = Maps.newConcurrentMap();
        
    @Override
    public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {costMap.put(beanName, System.currentTimeMillis());
        return bean;
    }
    @Override
    public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {if (costMap.containsKey(beanName)) {Long start = costMap.get(beanName);
            long cost  = System.currentTimeMillis() - start;
            if (cost > 0) {costMap.put(beanName, cost);
                System.out.println("bean:" + beanName + "\ttime:" + cost);
            }
        }
        return bean;
    }
}

BeanPostProcessor 的逻辑是在 Beanfactory 筹备好后处理的,就不须要通过 SpringFactoriesLoader 加载了,间接 @Component 注入即可。

重启服务,通过以上办法排查 Bean 初始化过程,还真的有所发现:

这个 Bean 初始化耗时 43s,具体看下这个 Bean 的初始化办法,发现会从数据库查问大量配置元数据,并更新到 Redis 缓存中,所以初始化十分慢:

另外,还发现了一些非我的项目本身服务的 service、controller 对象,这些 Bean 来自于第三方依赖:UPM 服务,我的项目中并不需要:

其实,起因上文曾经提到:我只接入一个性能,但我注入了该服务门路下所有的 Bean,也就是说,服务里注入其余服务的、对本身无用的 Bean。

2.1 优化计划

2.1 如何解决扫描门路过多?

想到的解决方案比较简单粗犷:
梳理要引入的 Bean,删掉主配置类上扫描门路,应用 JavaConfig 的形式显式手动注入。
以 UPM 的依赖为例,之前的注入形式 是,我的项目依赖其 UpmResourceClient 对象,Pom 曾经援用了其 Maven 坐标,并在主配置类上的 scanBasePackages 中增加了其服务门路:”com.xxx.ad.upm”,通过扫描整个服务门路下的 class,找到 UpmResourceClient 并注入,因为该类注解了 @Service,因而会注入到服务的 Spring 上下文中,UpmResourceClient 源码片段及主配置类如下:

应用 JavaConfig 的革新形式是:不再扫描 UPM 的服务门路,而是被动注入。删除 ”com.xxx.ad.upm”,并在服务门路下增加以下配置类:

@Configuration
public class ThirdPartyBeanConfig {
    @Bean
    public UpmResourceClient upmResourceClient() {return new UpmResourceClient();
    }
}

Tips:如果该 Bean 还依赖其余 Bean,则须要把所依赖的 Bean 都注入;针对 Bean 依赖状况简单的场景梳理起来就比拟麻烦了,所幸我的项目用到的服务 Bean 依赖关系都比较简单,一些依赖关系简单的服务,察看到其门路扫描耗时也不是很高,就不解决了。

同时,通过 JavaConfig 按需注入的形式,就不存在冗余 Bean 的状况了,也有利于升高服务的内存耗费;解决了下面的引入无关的 upmService、upmController 的问题。

2.2 如何解决 Bean 初始化高耗时?

Bean 初始化耗时高,就须要 case by case 地解决了,比方我的项目中遇到的初始化配置元数据的问题,能够思考通过将该工作提交到线程池的形式异步解决或者懒加载的形式来解决。

3. 新的问题

实现以上优化后,本地启动工夫从之前的 7min 左右升高至 40s,成果还是十分显著的。本地自测通过后,便公布到预发进行验证,验证过程中,有同学发现我的项目接入的 Redis 缓存组件生效了。
该组件接入形式与上文形容的接入形式相似,通过增加扫描服务的根门路 ”com.xxx.ad.rediscache”,注入对应的 Bean 对象;查看该缓存组件我的项目的源码,发现该门路下有一个 config 类注入了一个缓存治理对象 CacheManager,其实现类是 RedisCacheManager

缓存组件代码片段:

本次优化中,我是通过 每次删除一条扫描门路,启动服务后依据启动日志中 Bean 缺失谬误的信息,来一一梳理、增加依赖的 Bean,保障服务失常启动 的形式来革新的,而删除 ”com.xxx.ad.rediscache” 后启动服务并无异样,因而就没有进一步的操作,间接上预发验证了。这就奇怪了,既然不扫描该组件的业务代码根门路,也就没有执行注入该组件中定义的 CacheManager 对象,为啥用到缓存的中央没有报错呢?
尝试在未增加扫描门路的状况下,从 ApplicationContext 中获取 CacheManager 类型的对象看下是否存在?后果发现的确存在 RedisCacheManager 对象:

其实,后面的剖析并没有错,删除扫描门路后生成的 RedisCacheManager 并不是缓存组件代码中配置的,而是 SpringBoot 的自动化配置生成的,也就是说该对象并不是咱们想要的对象,是不合乎预期的,下文介绍其起因。

3.1 SpringBoot 自动化拆卸,让人防不胜防

查阅 SpringBoot Cache 相干材料,发现 SpringBoot Cache 做了一些主动推断和注入的工作,原来是 SpringBoot 自动化拆卸的锅呀,接下来就剖析下 SpringBoot Cache 原理,明确呈现以上问题的起因。
SpringBoot 自动化配置,体现在主配置类上复合注解 @SpringBootApplication 中的@EnableAutoConfiguration 上,该注解开启了 SpringBoot 的主动配置性能。该注解中的@Import(AutoConfigurationImportSelector.class) 通过加载 META-INF/spring.factotries 下配置一系列 *AutoConfiguration 配置类,依据现有条件推断,尽可能地为咱们配置须要的 Bean。这些配置类负责各个性能的自动化配置,其中用于 SpringBoot Cache 的主动配置类是 CacheAutoConfiguration,接下来重点剖析这个配置类就行了。

@SpringBootApplication 复合注解中集成了三个十分重要的注解:@SpringBootConfiguration@EnableAutoConfiguration@ComponentScan,其中 @EnableAutoConfiguration 就是负责开启自动化配置性能;
SpringBoot 中有多[]()@EnableXXX 的注解,都是用来开启某一方面的性能,其实现原理也是相似的:通过 @Import 筛选、导入满足条件的自动化配置类。

能够看到 CacheAutoConfiguration 上有许多注解,重点关注下@Import({CacheConfigurationImportSelector.class})CacheConfigurationImportSelector 实现了 ImportSelector 接口,该接口用于动静抉择想导入的配置类,这个 CacheConfigurationImportSelector 用来导入不同类型的 Cache 的主动配置类:

通过调试 CacheConfigurationImportSelector 发现,依据 SpringBoot 反对的缓存类型(CacheType),提供了 10 种 cache 的主动配置类,按优先级排序,最终只有一个失效,而本我的项目中恰好就是 RedisCacheConfiguration,其外部提供的是 RedisCacheManager,和引入第三方缓存组件一样,所以造成了困惑:

看下 RedisCacheConfiguration 的实现:

这个配置类上有很多条件注解,当这些条件都满足的话,这个主动配置类就会失效,而本我的项目恰好都满足,同时我的项目主配置类上还加上了 @EnableCaching,开启了缓存性能,即便缓存组件没失效,SpringBoot 也会主动生成一个缓存治理对象;

即:缓存组件服务扫描门路存在的话,缓存组件中的代码生成缓存治理对象,@ConditionalOnMissingBean(CacheManager.class) 生效;扫描门路不存在的话,SpringBoot 通过推断,主动生成一个缓存治理对象。

这个也很好验证,在 RedisCacheConfiguration 中打断点,不删除扫描门路是走不到这边的 SpringBoot 主动拆卸过程的(缓存组件显式生成过了),删除了扫描门路是能走到的(SpringBoot 主动生成)。

上文屡次提到 @Import,这是 SpringBoot 中重要注解,次要有以下作用:
1、导入 @Configuration 注解的类;
2、导入实现了 ImportSelectorImportBeanDefinitionRegistrar 的类;
3、导入一般的 POJO。

3.2 应用 starter 机制,开箱即用

理解缓存生效的起因后,就有解决的方法了,因为是本人团队的组件,就没必要通过 JavaConfig 显式手动导入的形式革新,而是通过 SpringBoot 的 starter 机制,优化下缓存组件的实现,能够做到主动注入、开箱即用。只有革新下缓存组件的代码,在 resources 文件中增加一个 META-INF/spring.factotries 文件,在上面配置一个 EnableAutoConfiguration 即可,这样我的项目在启动时也会扫描到这个 jar 中的 spring.factotries 文件,将 XxxAdCacheConfiguration 配置类主动引入,而不须要扫描 ”com.xxx.ad.rediscache” 整个门路了:

# EnableAutoConfigurations
org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.xxx.ad.rediscache.XxxAdCacheConfiguration

SpringBoot 的 EnableAutoConfiguration 主动配置原理还是比较复杂的,在加载主动配置类前还要先加载主动配置的元数据,对所有主动配置类做有效性筛选,具体可查阅 EnableAutoConfiguration 相干代码;

最初说一句(别白嫖,求关注)

陈某每一篇文章都是精心输入,如果这篇文章对你有所帮忙,或者有所启发的话,帮忙 点赞 在看 转发 珍藏,你的反对就是我坚持下去的最大能源!

关注公众号:【码猿技术专栏】,公众号内有超赞的粉丝福利,回复:加群,能够退出技术探讨群,和大家一起探讨技术,吹牛逼!

正文完
 0