本文系統性分析并優化了一個Spring Boot項目啟動耗時高達 280 秒的問題。通過識別瓶頸、優化分庫分表加載邏輯、異步初始化耗時任務等手段,最終將啟動耗時縮短至 159 秒,提升近 50%。文章涵蓋啟動流程分析、性能熱點識別、異步初始化設計等關鍵技術細節,適用于大型Spring Boot項目的性能優化參考。
文章太長?1分鐘看圖抓住核心觀點👇
一、前言
隨著業務的發展,筆者項目對應的Spring Boot工程的依賴越來越多。隨著依賴數量的增長,Spring 容器需要加載更多組件、解析復雜依賴并執行自動裝配,導致項目啟動時間顯著增長。在日常開發或測試過程中,一旦因為配置變更或者其他熱部署不生效的變更時,項目重啟就需要等待很長的時間影響代碼的交付。加快Spring項目的啟動可以更好的投入項目中,提升開發效率。
整體環境介紹:
- Spring版本:4.3.22
- Spring Boot版本:1.5.19
- CPU:i5-9500
- 內存:24GB
- 優化前啟動耗時:280秒
二、Spring Boot項目啟動流程介紹
Spring Boot項目主要啟動流程都在org.spring-
framework.boot.SpringApplication#run(java.lang.String...)方法中:
public?ConfigurableApplicationContext?run(String... args)?{StopWatch?stopWatch?=?new?StopWatch();stopWatch.start();// Spring上下文ConfigurableApplicationContext?context?=?null;FailureAnalyzers?analyzers?=?null;configureHeadlessProperty();// 初始化SpringApplicationRunListener監聽器SpringApplicationRunListeners?listeners?=?getRunListeners(args);listeners.starting();try?{ApplicationArguments?applicationArguments?=?new?DefaultApplicationArguments(args);// 環境準備ConfigurableEnvironment?environment?=?prepareEnvironment(listeners,applicationArguments);// 打印bannerBanner?printedBanner?=?printBanner(environment);// 創建上下文context = createApplicationContext();analyzers =?new?FailureAnalyzers(context);// 容器初始化prepareContext(context, environment, listeners, applicationArguments,printedBanner);// 刷新容器內容refreshContext(context);afterRefresh(context, applicationArguments);// 結束監聽廣播listeners.finished(context,?null);stopWatch.stop();if?(this.logStartupInfo) {new?StartupInfoLogger(this.mainApplicationClass).logStarted(getApplicationLog(), stopWatch);}return?context;}?catch?(Throwable ex) {handleRunFailure(context, listeners, analyzers, ex);throw?new?IllegalStateException(ex);} }
可以看到在啟動流程中,監聽器應用在了應用的多個生命周期中。并且Spring Boot中也預留了針對listener的擴展點。我們可以借此實現一個自己的擴展點去監聽Spring Boot的每個階段的啟動耗時,實現如下:
@Slf4j public?class?MySpringApplicationRunListener?implements?SpringApplicationRunListener{private?Long?startTime;public?MySpringApplicationRunListener(SpringApplication?application,?String[] args){}@Overridepublic?void?starting(){startTime =?System.currentTimeMillis();log.info("MySpringListener啟動開始 {}",?LocalTime.now());}@Overridepublic?void?environmentPrepared(ConfigurableEnvironment environment){log.info("MySpringListener環境準備 準備耗時:{}毫秒", (System.currentTimeMillis() - startTime));startTime =?System.currentTimeMillis();}@Overridepublic?void?contextPrepared(ConfigurableApplicationContext context){log.info("MySpringListener上下文準備 耗時:{}毫秒", (System.currentTimeMillis() - startTime));startTime =?System.currentTimeMillis();}@Overridepublic?void?contextLoaded(ConfigurableApplicationContext context){log.info("MySpringListener上下文載入 耗時:{}毫秒", (System.currentTimeMillis() - startTime));startTime =?System.currentTimeMillis();}@Overridepublic?void?finished(ConfigurableApplicationContext context, Throwable exception){log.info("MySpringListener結束 耗時:{}毫秒", (System.currentTimeMillis() - startTime));startTime =?System.currentTimeMillis();} }
接著還需要在classpath/META-INF目錄下新建spring.factories文件,并添加如下文件內容:
org.springframework.boot.SpringApplicationRunListener=com.vivo.internet.gameactivity.api.web.MySpringApplicationRunListener
至此,借助Listener機制,我們能夠追蹤Spring Boot啟動各階段的耗時分布,為后續性能優化提供數據支撐。
contextLoaded事件是在run方法中的prepareContext()結束時調用的,因此contextLoaded事件和finished事件之間僅存在兩個語句:refreshContext(context)和afterRefresh
(context,applicationArguements)消耗了285秒的時間,調試一下就能發現主要耗時在refreshContext()中。
三、AbstractApplicationContext#refresh
refreshContext()最終調用到org.spring-framework.context.support.AbstractApplicationContext#refresh方法中,這個方法主要是beanFactory的預準備、對beanFactory完成創建并進行后置處理、向容器添加bean并且給bean添加屬性、實例化所有bean。通過調試發現,finishBeanFactoryInitialization(beanFactory) 方法耗時最久。該方法負責實例化容器中所有的單例 Bean,是啟動性能的關鍵影響點。
四、找出實例化耗時的Bean
Spring Boot也是利用的Spring的加載流程。在Spring中可以實現InstantiationAwareBeanPost-
Processor接口去在Bean的實例化和初始化的過程中加入擴展點。因此我們可以實現該接口并添加自己的擴展點找到處理耗時的Bean。
@Service public?class?TimeCostCalBeanPostProcessor?implements?InstantiationAwareBeanPostProcessor?{private?Map<String,?Long> costMap =?Maps.newConcurrentMap();@Overridepublic?Object?postProcessBeforeInstantiation(Class<?> beanClass,?String?beanName) throws?BeansException?{if?(!costMap.containsKey(beanName)) {costMap.put(beanName,?System.currentTimeMillis());}return?null;}@Overridepublic?boolean?postProcessAfterInstantiation(Object?bean,?String?beanName) throws?BeansException?{return?true;}@Overridepublic?PropertyValues?postProcessPropertyValues(PropertyValues?pvs,?PropertyDescriptor[] pds,?Object?bean,?String?beanName) throws?BeansException?{return?pvs;}@Overridepublic?Object?postProcessBeforeInitialization(Object?bean,?String?beanName) throws?BeansException?{return?bean;}@Overridepublic?Object?postProcessAfterInitialization(Object?bean,?String?beanName) throws?BeansException?{if?(costMap.containsKey(beanName)) {Long?start = costMap.get(beanName);long cost =?System.currentTimeMillis() - start;// 只打印耗時長的beanif?(cost >?5000) {System.out.println("bean: "?+ beanName +?"\ttime: "?+ cost +?"ms");}}return?bean;} }
具體原理就是在Bean開始實例化之前記錄時間,在Bean初始化完成后記錄結束時間,打印實例化到初始化的時間差獲得Bean的加載總體耗時。結果如圖:
可以看到有許多耗時在10秒以上的類,接下來可以針對性的做優化。值得注意的是,統計方式為單點耗時計算,未考慮依賴鏈上下文對整體加載順序的影響,實際優化還需結合依賴關系分析。
五、singletonDataSource
@Bean(name = "singletonDataSource") public?DataSource?singletonDataSource(DefaultDataSourceWrapper dataSourceWrapper)?throws?SQLException {//先初始化連接dataSourceWrapper.getMaster().init();//構建分庫分表數據源String?dataSource0?=?"ds0";Map<String, DataSource> dataSourceMap =?new?HashMap<>();dataSourceMap.put(dataSource0, dataSourceWrapper.getMaster());//分庫分表數據源DataSource?shardingDataSource?=?ShardingDataSourceFactory.createDataSource(dataSourceMap,shardingRuleConfiguration, prop);return?shardingDataSource; ? ?}
singletonDataSource是一個分庫分表的數據源,連接池采用的是Druid,分庫分表組件采用的是公司內部優化后的中間件。通過簡單調試代碼發現,整個Bean耗時的過程發生在createDataSource方法,該方法中會調用createMetaData方法去獲取數據表的元數據,最終運行到loadDefaultTables方法。該方法如下圖,會遍歷數據庫中所有的表。因此數據庫中表越多,整體就越耗時。
筆者的測試環境數據庫中有很多的分表,這些分表為了和線上保持一致,分表的數量都和線上是一樣的。
因此在測試環境啟動時,為了加載這些分表會更加的耗時。可通過將分表數量配置化,使測試環境在不影響功能驗證的前提下減少分表數量,從而加快啟動速度。
六、初始化異步
activityServiceImpl啟動中,主要會進行活動信息的查詢初始化,這是一個耗時的操作。類似同樣的操作在工程的其他類中也存在。
@Service public?class?ActivityServiceImpl?implements?ActivityService, InitializingBean{// 省略無關代碼@Overridepublic?void?afterPropertiesSet()?throws?Exception {initActivity();}// 省略無關代碼 }
可以通過將afterPropertiesSet()異步化的方式加速項目的啟動。
觀察Spring源碼可以注意到afterPropertiesSet方法是在AbstractAutowireCapableBeanFactory#
invokeInitMethods中調用的。在這個方法中,不光處理了afterPropertiesSet方法,也處理了init-method。
因此我們可以寫一個自己的BeanFactory繼承AbstractAutowireCapableBeanFactory,將invokeInitMethods方法進行異步化重寫。考慮到AbstractAutowireCapableBeanFactory是個抽象類,有額外的抽象方法需要實現,因此繼承該抽象類的子類DefaultListableBeanFactory。具體實現代碼如下:
public?class?AsyncInitListableBeanFactory?extends?DefaultListableBeanFactory{public?AsyncInitBeanFactory(ConfigurableListableBeanFactory beanFactory){super(beanFactory);}@Overrideprotected?void?invokeInitMethods(String beanName, Object bean, RootBeanDefinition mbd)throws?Throwable {if?(beanName.equals("activityServiceImpl")) {AsyncTaskExecutor.submitTask(() -> {try?{super.invokeInitMethods(beanName, bean, mbd);}?catch?(Throwable throwable) {throwable.printStackTrace();}});}?else?{super.invokeInitMethods(beanName, bean, mbd);}} }
又因為Spring在refreshContext()方法之前的prepareContext()發放中針對initialize方法提供了接口擴展(applyInitializers())。因此我們可以通過實現該接口并將我們的新的BeanFactory通過反射的方式更新到Spring的初始化流程之前。
public?interface?ApplicationContextInitializer<C?extends?ConfigurableApplicationContext> {/*** Initialize the given application context.* @param applicationContext the application to configure*/void?initialize(C applicationContext);}
改造后的代碼如下,新增AsyncAccelerate-
Initializer類實現ApplicationContextInitializer接口:
public?class?AsyncBeanFactoryInitializer?implements?ApplicationContextInitializer<ConfigurableApplicationContext> {@SneakyThrows@Overridepublic?void?initialize(ConfigurableApplicationContext applicationContext){if?(applicationContext instanceof GenericApplicationContext) {AsyncInitListableBeanFactory beanFactory =?new?AsyncInitListableBeanFactory(applicationContext.getBeanFactory());Field field = GenericApplicationContext.class.getDeclaredField("beanFactory");field.setAccessible(true);field.set(applicationContext, beanFactory);}} } public?class?AsyncBeanInitExecutor{private?static?final?int?CPU_COUNT = Runtime.getRuntime().availableProcessors();private?static?final AtomicReference<ThreadPoolExecutor> THREAD_POOL_REF =?new?AtomicReference<>();private?static?final List<Future<?>> FUTURES =?new?ArrayList<>();/*** 創建線程池實例*/private?static?ThreadPoolExecutor?createThreadPoolExecutor(){int?poolSize = CPU_COUNT +?1;return?new?ThreadPoolExecutor(poolSize, poolSize,?50L, TimeUnit.SECONDS,?new?LinkedBlockingQueue<>(),?new?ThreadPoolExecutor.CallerRunsPolicy());}/*** 確保線程池已初始化(線程安全)*/private?static?void?ensureThreadPoolExists(){if?(THREAD_POOL_REF.get() !=?null) {return;}ThreadPoolExecutor executor = createThreadPoolExecutor();if?(!THREAD_POOL_REF.compareAndSet(null, executor)) {executor.shutdown();?// 另一線程已初始化成功}}/*** 提交異步初始化任務** @param task 初始化任務* @return 提交后的 Future 對象*/public?static?Future<?> submitInitTask(Runnable task) {ensureThreadPoolExists();Future<?> future = THREAD_POOL_REF.get().submit(task);FUTURES.add(future);return?future;}/*** 等待所有初始化任務完成并釋放資源*/public?static?void?waitForInitTasks(){try?{for?(Future<?> future : FUTURES) {future.get();}}?catch?(Exception ex) {throw?new?RuntimeException("Async init task failed", ex);}?finally?{FUTURES.clear();shutdownThreadPool();}}/*** 關閉線程池并重置引用*/private?static?void?shutdownThreadPool(){ThreadPoolExecutor executor = THREAD_POOL_REF.getAndSet(null);if?(executor !=?null) {executor.shutdown();}} }
實現類后,還需要在META-INF/spring.factories下新增說明org.springframework.context.
ApplicationContextInitializer=com.xxx.AsyncAccelerateInitializer,這樣這個類才能真正生效。
這樣異步化以后還有一個點需要注意,如果該初始化方法執行耗時很長,那么會存在Spring容器已經啟動完成,但是異步初始化任務沒執行完的情況,可能會導致空指針等異常。為了避免這種問題的發生,還要借助于Spring容器啟動中finishRefresh()方法,監聽對應事件,確保異步任務執行完成之后,再啟動容器。
public?class?AsyncInitCompletionListener?implements?ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware, PriorityOrdered{private?ApplicationContext currentContext;@Overridepublic?void?setApplicationContext(ApplicationContext applicationContext)throws?BeansException {this.currentContext = applicationContext;}@Overridepublic?void?onApplicationEvent(ContextRefreshedEvent event){if?(event.getApplicationContext() == currentContext) {AsyncBeanInitExecutor.waitForInitTasks();}}@Overridepublic?int?getOrder(){return?Ordered.HIGHEST_PRECEDENCE;} }
七、總結
啟動優化后的項目實際測試結果如下:
通過異步化初始化和分庫分表加載優化,項目啟動時間從 280 秒縮短至 159 秒,提升約 50%。這對于提升日常開發效率、加快測試與聯調流程具有重要意義。