Spring Boot 啟動優化實踐
一、前言
隨著業務的發展,筆者項目對應的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);
// 打印banner
Banner 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){
}
@Override
public void starting(){
startTime = System.currentTimeMillis();
log.info("MySpringListener啟動開始 {}", LocalTime.now());
}
@Override
public void environmentPrepared(ConfigurableEnvironment environment){
log.info("MySpringListener環境準備 準備耗時:{}毫秒", (System.currentTimeMillis() - startTime));
startTime = System.currentTimeMillis();
}
@Override
public void contextPrepared(ConfigurableApplicationContext context){
log.info("MySpringListener上下文準備 耗時:{}毫秒", (System.currentTimeMillis() - startTime));
startTime = System.currentTimeMillis();
}
@Override
public void contextLoaded(ConfigurableApplicationContext context){
log.info("MySpringListener上下文載入 耗時:{}毫秒", (System.currentTimeMillis() - startTime));
startTime = System.currentTimeMillis();
}
@Override
public 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();
@Override
public Object postProcessBeforeInstantiation(Class<?> beanClass, String beanName) throws BeansException {
if (!costMap.containsKey(beanName)) {
costMap.put(beanName, System.currentTimeMillis());
}
return null;
}
@Override
public boolean postProcessAfterInstantiation(Object bean, String beanName) throws BeansException {
return true;
}
@Override
public PropertyValues postProcessPropertyValues(PropertyValues pvs, PropertyDescriptor[] pds, Object bean, String beanName) throws BeansException {
return pvs;
}
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
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;
// 只打印耗時長的bean
if (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{
// 省略無關代碼
@Override
public 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);
}
@Override
protected 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
@Override
public 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.Applicatinotallow=com.xxx.AsyncAccelerateInitializer,這樣這個類才能真正生效。
這樣異步化以后還有一個點需要注意,如果該初始化方法執行耗時很長,那么會存在Spring容器已經啟動完成,但是異步初始化任務沒執行完的情況,可能會導致空指針等異常。為了避免這種問題的發生,還要借助于Spring容器啟動中finishRefresh()方法,監聽對應事件,確保異步任務執行完成之后,再啟動容器。
public class AsyncInitCompletionListener implements ApplicationListener<ContextRefreshedEvent>, ApplicationContextAware, PriorityOrdered{
private ApplicationContext currentContext;
@Override
public void setApplicationContext(ApplicationContext applicationContext)throws BeansException {
this.currentContext = applicationContext;
}
@Override
public void onApplicationEvent(ContextRefreshedEvent event){
if (event.getApplicationContext() == currentContext) {
AsyncBeanInitExecutor.waitForInitTasks();
}
}
@Override
public int getOrder(){
return Ordered.HIGHEST_PRECEDENCE;
}
}
七、總結
啟動優化后的項目實際測試結果如下:
通過異步化初始化和分庫分表加載優化,項目啟動時間從 280 秒縮短至 159 秒,提升約 50%。這對于提升日常開發效率、加快測試與聯調流程具有重要意義。