這樣優(yōu)化Spring Boot,啟動速度快到飛起(springboot啟動慢如何優(yōu)化)
微服務用到一時爽,沒用好就呵呵啦,特別是對于服務拆分沒有把控好業(yè)務邊界、拆分粒度過大等問題,某些 Spring Boot 啟動速度太慢了,可能你也會有這種體驗,這里將探索一下關于 Spring Boot 啟動速度優(yōu)化的一些方方面面。
啟動時間分析
IDEA 自帶集成了 async-proFile 工具,所以我們可以通過火焰圖來更直觀的看到一些啟動過程中的問題,比如下圖例子當中,通過火焰圖來看大量的耗時在 Bean 加載和初始化當中。
圖來自 IDEA 自帶集成的 async-profile 工具,可在 Preferences 中搜索 java Profiler 自定義配置,啟動使用 Run with xx Profiler。
y 軸表示調用棧,每一層都是一個函數,調用棧越深,火焰就越高,頂部就是正在執(zhí)行的函數,下方都是它的父函數。
x 軸表示抽樣數,如果一個函數在 x 軸占據的寬度越寬,就表示它被抽到的次數多,即執(zhí)行的時間長。
啟動優(yōu)化
減少業(yè)務初始化
大部分的耗時應該都在業(yè)務太大或者包含大量的初始化邏輯,比如建立數據庫連接、Redis連接、各種連接池等等,對于業(yè)務方的建議則是盡量減少不必要的依賴,能異步則異步。
延遲初始化
Spring Boot 2.2版本后引入 Spring.main.lazy-initialization屬性,配置為 true 表示所有 Bean 都將延遲初始化。
可以一定程度上提高啟動速度,但是第一次訪問可能較慢。
spring.main.lazy-initialization=true
Spring Context Indexer
Spring5 之后版本提供了spring-context-indexer功能,主要作用是解決在類掃描的時候避免類過多導致的掃描速度過慢的問題。
使用方法也很簡單,導入依賴,然后在啟動類打上@Indexed注解,這樣在程序編譯打包之后會生成META-INT/spring.components文件,當執(zhí)行ComponentScan掃描類時,會讀取索引文件,提高掃描速度。
<dependency> <groupId>org.springframework</groupId> <artifactId>spring-context-indexer</artifactId> <optional>true</optional></dependency>
關閉JMX
Spring Boot 2.2.X 版本以下默認會開啟 JMX,可以使用 jconsole 查看,對于我們無需這些監(jiān)控的話可以手動關閉它。
spring.jmx.enabled=false
關閉分層編譯
Java8 之后的版本,默認打開多層編譯,使用命令Java -XX: PrintFlagsFinal -version | grep CompileThreshold查看。
Tier3 就是 C1、Tier4 就是 C2,表示一個方法解釋編譯 2000 次進行 C1編譯,C1編譯后執(zhí)行 15000 次會進行 C2編譯。
我們可以通過命令使用 C1 編譯器,這樣就不存在 C2 的優(yōu)化階段,能夠提高啟動速度,同時配合 -Xverify:none/ -noverify 關閉字節(jié)碼驗證,但是,盡量不要在線上環(huán)境使用。
-XX:TieredStopAtLevel=1 -noverify
另外的思路
上面介紹了一些從業(yè)務層面、啟動參數之類的優(yōu)化,下面我們再看看基于 Java 應用本身有哪些途徑可以進行優(yōu)化。
在此之前,我們回憶一下 Java 創(chuàng)建對象的過程,首先要進行類加載,然后去創(chuàng)建對象,對象創(chuàng)建之后就可以調用對象方法了,這樣就還會涉及到 JIT,JIT通過運行時將字節(jié)碼編譯為本地機器碼來提高 Java 程序的性能。
因此,下面涉及到的技術將會概括以上涉及到的幾個步驟。
jar Index
Jar包其實本質上就是一個 ZIP 文件,當加載類的時候,我們通過類加載器去遍歷Jar包,找到對應的 class 文件進行加載,然后驗證、準備、解析、初始化、實例化對象。
JarIndex 其實是一個很古老的技術,就是用來解決在加載類的時候遍歷 Jar 性能問題,早在 JDK1.3的版本中就已經引入。
假設我們要在ABC 3個Jar包中查找一個class,如果能夠通過類型com.C,立刻推斷出具體在哪個jar包,就可以避免遍歷 jar 的過程。
A.jarcom/AB.jarcom/BC.jarcom/C
通過 Jar Index 技術,就可以生成對應的索引文件 INDEX.LIST。
com/A --> A.jarcom/B --> B.jarcom/C --> C.jar
不過對于現在的項目來說,Jar Index 很難應用:
- 通過 jar -i 生成的索引文件是基于 META-INF/MANIFEST.MF 中的 Class-Path 來的,我們目前大多項目都不會涉及到這個,所以索引文件的生成需要我們自己去做額外處理
- 只支持 URLClassloader,需要我們自己自定義類加載邏輯
APPCDS
App CDS 全稱為 Application Class Data Sharing,主要是用于啟動加速和節(jié)省內存,其實早在在 JDK1.5 版本就已經引入,只是在后續(xù)的版本迭代過程中在不斷的優(yōu)化升級,JDK13 版本中則是默認打開,早期的 CDS 只支持BootClassLoader, 在 JDK8 中引入了 AppCDS,支持 AppClassLoader 和 自定義的 ClassLoader。
我們都知道類加載的過程中伴隨解析、校驗這個過程,CDS 就是將這個過程產生的數據結構存儲到歸檔文件中,在下次運行的時候重復使用,這個歸檔文件被稱作 Shared Archive,以jsa作為文件后綴。
在使用時,則是將 jsa 文件映射到內存當中,讓對象頭中的類型指針指向該內存地址。
讓我們一起看看怎么使用。
首先,我們需要生成希望在應用程序之間共享的類列表,也即是 lst文件。對于 Oracle JDK 需要加入 -XX: UnlockCommercialFeature 命令來開啟商業(yè)化的能力,openJDK 無需此參數,JDK13的版本中將1、2兩步合并為一步,但是低版本還是需要這樣做。
java -XX:DumpLoadedClassList=test.lst
然后得到 lst 類列表之后,dump 到適合內存映射的 jsa 文件當中進行歸檔。
java -Xshare:dump -XX:SharedClassListFile=test.lst -XX:SharedArchiveFile=test.jsa
最后,在啟動時加入運行參數指定歸檔文件即可。
-Xshare:on -XX:SharedArchiveFile=test.jsa
需要注意的是,AppCDS只會在包含所有 class 文件的 FatJar 生效,對于 SpringBoot 的嵌套 Jar 結構無法生效,需要利用 maven shade plugin 來創(chuàng)建 shade jar。
<build> <finalName>helloworld</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <configuration> <keepDependenciesWithProvidedScope>true</keepDependenciesWithProvidedScope> <createDependencyReducedPom>false</createDependencyReducedPom> <filters> <filter> <artifact>*:*</artifact> <excludes> <exclude>META-INF/*.SF</exclude> <exclude>META-INF/*.DSA</exclude> <exclude>META-INF/*.RSA</exclude> </excludes> </filter> </filters> </configuration> <executions> <execution> <phase>package</phase> <goals><goal>shade</goal></goals> <configuration> <transformers> <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer"> <resource>META-INF/spring.handlers</resource> </transformer> <transformer implementation="org.springframework.boot.maven.PropertiesMergingResourceTransformer"> <resource>META-INF/spring.factories</resource> </transformer> <transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer"> <resource>META-INF/spring.schemas</resource> </transformer> <transformer implementation="org.apache.maven.plugins.shade.resource.ServicesResourceTransformer" /> <transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer"> <mainClass>${mainClass}</mainClass> </transformer> </transformers> </configuration> </execution> </executions> </plugin> </plugins></build>
然后按照上述的步驟使用才可以,但是如果項目過大,文件數大于65535啟動會報錯:
Caused by: java.lang.IllegalStateException: Zip64 archives are not supported
源碼如下:
public int getNumberOfRecords() { long numberOfRecords = Bytes.littleEndianValue(this.block, this.offset 10, 2); if (numberOfRecords == 0xFFFF) { throw new IllegalStateException("Zip64 archives are not supported");}
在 2.2 及以上版本修復了這個問題,所以使用的時候盡量使用高版本可以避免此類問題的出現。
Heap Archive
JDK9 中引入了HeapArchive,并且 JDK12 中被正式使用,我們可以認為 Heap Archive 是對 APPCDS 的一個延伸。
APPCDS 是持久化了類加載過程中驗證、解析產生的數據,Heap Archive 則是類初始化(執(zhí)行 static 代碼塊 cinit 進行初始化) 相關的堆內存的數據。
簡單來講,可以認為 HeapArchive 是在類初始化的時候通過內存映射持久化了一些 static 字段,避免調用類初始化器,提前拿到初始化好的類,提高啟動速度。
AOT編譯
我們說過,JIT 是通過運行時將字節(jié)碼編譯為本地機器碼,需要的時候直接執(zhí)行,減少了解釋的時間,從而提高程序運行速度。
上面我們提到的 3 個提高應用啟動速度的方式都可以歸為類加載的過程,到真正創(chuàng)建對象實例、執(zhí)行方法的時候,由于可能沒有被 JIT 編譯,在解釋模式下執(zhí)行的速度非常慢,所以產生了 AOT 編譯的方式。
AOT(Ahead-Of-Time) 指的是程序運行之前發(fā)生的編譯行為,他的作用相當于是預熱,提前編譯為機器碼,減少解釋時間。
比如現在 Spring Cloud Native 就是這樣,在運行時直接靜態(tài)編譯成可執(zhí)行文件,不依賴 JVM,所以速度非??臁?/span>
但是 Java 中 AOT 技術不夠成熟,作為實驗性的技術在 JDK8 之后版本默認關閉,需要手動打開。
java -XX: UnlockExperimentalVMOptions -XX:AOTLibrary=
并且由于長期缺乏維護和調優(yōu)這項技術,在 JDK 16 的版本中已經被移除,這里就不再贅述了。
下線時間優(yōu)化
優(yōu)雅下線
Spring Boot 在 2.3 版本中增加了新特性優(yōu)雅停機,支持Jetty、Reactor Netty、Tomcat 和 Undertow,使用方式:
server: Shutdown: graceful# 最大等待時間spring: lifecycle: timeout-per-shutdown-phase: 30s
如果低于 2.3 版本,官方也提供了低版本的實現方案,新版本中的實現基本也是這個邏輯,先暫停外部請求,關閉線程池處理剩余的任務。
@SpringBootApplication@RestControllerpublic class Gh4657Application { public static void main(String[] args) { SpringApplication.run(Gh4657Application.class, args); } @RequestMapping("/pause") public String pause() throws InterruptedException { Thread.sleep(10000); return "Pause complete"; } @Bean public GracefulShutdown gracefulShutdown() { return new GracefulShutdown(); } @Bean public EmbeddedServletContainerCustomizer tomcatCustomizer() { return new EmbeddedServletContainerCustomizer() { @Override public void customize(ConfigurableEmbeddedServletContainer container) { if (container instanceof TomcatEmbeddedServletContainerFactory) { ((TomcatEmbeddedServletContainerFactory) container) .addConnectorCustomizers(gracefulShutdown()); } } }; } private static class GracefulShutdown implements TomcatConnectorCustomizer, ApplicationListener<ContextClosedEvent> { private static final Logger log = LoggerFactory.getLogger(GracefulShutdown.class); private volatile Connector connector; @Override public void customize(Connector connector) { this.connector = connector; } @Override public void onApplicationEvent(ContextClosedEvent event) { this.connector.pause(); Executor executor = this.connector.getProtocolHandler().getExecutor(); if (executor instanceof ThreadPoolExecutor) { try { ThreadPoolExecutor threadPoolExecutor = (ThreadPoolExecutor) executor; threadPoolExecutor.shutdown(); if (!threadPoolExecutor.awaitTermination(30, TimeUnit.SECONDS)) { log.warn("Tomcat thread pool did not shut down gracefully within " "30 seconds. Proceeding with forceful shutdown"); } } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } } } }}
Eureka服務下線時間
另外,對于客戶端感知服務端下線時間方面的問題,我在之前的文章有提及到。
Eureka 使用了三級緩存來保存服務的實例信息。
服務注冊的時候會和 server 保持一個心跳,這個心跳的時間是 30 秒,服務注冊之后,客戶端的實例信息保存到 Registry 服務注冊表當中,注冊表中的信息會立刻同步到 readWriteCacheMap 之中。
而客戶端如果感知到這個服務,要從 readOnlyCacheMap 去讀取,這個只讀緩存需要 30 秒的時間去從 readWriteCacheMap 中同步。
客戶端和 ribbon 負載均衡 都保持一個本地緩存,都是 30 秒定時同步。
按照上面所說,我們來計算一下客戶端感知到一個服務下線極端的情況需要多久。
- 客戶端每隔 30 秒會發(fā)送心跳到服務端
- registry 保存了所有服務注冊的實例信息,他會和 readWriteCacheMap 保持一個實時的同步,而 readWriteCacheMap 和 readOnlyCacheMap 會每隔 30 秒同步一次。
- 客戶端每隔 30 秒去同步一次 readOnlyCacheMap 的注冊實例信息
- 考慮到如果使用 ribbon 做負載均衡的話,他還有一層緩存每隔 30 秒同步一次
如果說一個服務的正常下線,極端的情況這個時間應該就是 30 30 30 30 差不多 120 秒的時間了。
如果服務非正常下線,還需要靠每 60 秒執(zhí)行一次的清理線程去剔除超過 90 秒沒有心跳的服務,那么這里的極端情況可能需要 3 次 60秒才能檢測出來,就是 180 秒的時間。
累計可能最長的感知時間就是:180 120 = 300 秒,5分鐘的時間。
解決方案當然就是改這些時間。
修改 ribbon 同步緩存的時間為 3 秒:ribbon.ServerListRefreshInterval = 3000
修改客戶端同步緩存時間為 3 秒 :eureka.client.registry-fetch-interval-seconds = 3
心跳間隔時間修改為 3 秒:eureka.instance.lease-renewal-interval-in-seconds = 3
超時剔除的時間改為 9 秒:eureka.instance.lease-expiration-duration-in-seconds = 9
清理線程定時時間改為 5 秒執(zhí)行一次:eureka.server.eviction-interval-timer-in-ms = 5000
同步到只讀緩存的時間修改為 3 秒一次:eureka.server.response-cache-update-interval-ms = 3000
如果按照這個時間參數設置讓我們重新計算可能感知到服務下線的最大時間:
正常下線就是 3 3 3 3=12 秒,非正常下線再加 15 秒為 27 秒。
結束
OK,關于 Spring Boot 服務的啟動、下線時間的優(yōu)化就聊到這里,但是我認為服務拆分足夠好,代碼寫的更好一點,這些問題可能都不是問題了。