我們線上的業(yè)務(wù) jar 包基本上普遍比較龐大,動不動一個 jar 包上百 M,啟動時間在分鐘級,拖慢了我們在故障時快速擴(kuò)容的響應(yīng)。于是做了一些分析,看看 Java 程序啟動慢到底慢在哪里,如何去優(yōu)化,目前的效果是大部分大型應(yīng)用啟動時間可以縮短 30%~50%
主要有下面這些內(nèi)容
- 修改 async-profiler 源碼,只抓取啟動階段 main 線程的 wall 時間火焰圖( )
- 重新實現(xiàn) JarIndex( )
- 結(jié)合 JarIndex 重新自定義類加載器,啟動提速 30%+( )
- SpringBean 加載耗時 timeline 可視化分析( )
- SpringBean 的可視化依賴分析( )
- 基于依賴拓?fù)涞?SpringBean 的異步加載( )
無觀測不優(yōu)化
秉承著無觀測不優(yōu)化的想法,首先我們要知道啟動慢到底慢在了哪里。我之前分享過很多次關(guān)于火焰圖的使用,結(jié)果很多人遇到問題就開始考慮火焰圖,但是一個啟動慢其實是一個時序問題,不是一個 hot CPU 熱點問題。很多時候慢,不一定是 cpu 占用過高,很有可能是等鎖、等 IO 或者傻傻的 sleep。
在 Linux 中有一個殺手級的工具 bootchart 來分析 linux 內(nèi)核啟動的問題,它把啟動過程中所有的 IO、CPU 占用情況都做了詳細(xì)的劃分,我們可以很清楚地看到各個時間段,時間耗在了哪里,基于這個 chart,你就可以看看哪些過程可以延后處理、異步處理等。
在 Java 中,暫時沒有類似的工具,但是又想知道時間到底耗在了哪里要怎么做呢,至少大概知道耗在了什么地方。在生成熱點調(diào)用火焰圖的時候,我們通過 arthas 的幾個簡單的命令就可以生成,它底層用的是 async-profiler 這個開源項目,它的作者 apangin 做過一系列關(guān)于 jvm profiling 相關(guān)的分享,感興趣的同學(xué)可以去看看。
async-profiler 底層原理簡介
async-profiler 是一個非常強(qiáng)大的工具,使用 jvmti 技術(shù)來實現(xiàn)。它的 NB 之處在于它利用了 libjvm.so 中 JVM 內(nèi)部的 API AsyncGetCallTrace 來獲取 Java 函數(shù)堆棧,精簡后的偽代碼如下:
static bool vm_init(JavaVM *vm) { std::cout << "vm_init" << std::endl; // 從 libjvm.so 中獲取 AsyncGetCallTrace 的函數(shù)指針句柄 void *libjvm = dlopen("libjvm.so", RTLD_LAZY); _asyncGetCallTrace = (AsyncGetCallTrace) dlsym(libjvm, "AsyncGetCallTrace");}// 事件回調(diào)void recordSample(void *ucontext, uint64_t counter, jint event_type, Event *event) { std::cout << "Profiler::recordSample: " << std::endl; ASGCT_CallFrame frames[maxFramesToCapture]; ASGCT_CallTrace trace; trace.frames = frames; trace.env = getJNIEnv(g_jvm); // 調(diào)用 AsyncGetCallTrace 獲取堆棧 _asyncGetCallTrace(&trace, maxFramesToCapture, ucontext);}
你可能要說獲取個堆棧還需要搞這么復(fù)雜,jstack 等工具不是實現(xiàn)得很好了嗎?其實不然。
jstack 等工具獲取函數(shù)堆棧需要 jvm 進(jìn)入到 safepoint,對于采樣非常頻繁的場景,會嚴(yán)重的影響 jvm 的性能,具體的原理不是本次內(nèi)容的重點這里先不展開。
async-profiler 除了可以生成熱點調(diào)用的火焰圖,它還提供了 Wall-clock profiling 的功能,這個功能其實就是固定時間采樣所有的線程(不管線程當(dāng)前是 Running、Sleeping 還是 Blocked),它在文檔中也提到了,這種方式的 profiling 適合用來分析應(yīng)用的啟動過程,我們姑且用這個不太精確的方式來粗略測量啟動階段耗時在了哪些函數(shù)里。
但是這個工具會抓取所有的線程的堆棧,按這樣的方式抓取的 wall-clock 火焰圖沒法看,不信你看。
就算你找到了 main 線程,在函數(shù)耗時算占比的時候也不太方便,我們關(guān)心的其實只是 main 線程(也就是加載 jar 包,執(zhí)行 spring 初始化的線程),于是我做了一些簡單的修改,讓 async-profiler 只取抓取 main 線程的堆棧。
重新編譯運行
java -agentpath:/path/to/libasyncProfiler.so=start,event=wall,interval=1ms,threads,file=profile.html-jar xxx.jar
這樣生成的火焰圖就清爽多了,這樣就知道時間耗在了什么函數(shù)上。
接下來就是分析這個 wall-clock 的火焰圖,點開幾個調(diào)用棧仔細(xì)分析,發(fā)現(xiàn)很多時間花費在類和資源文件查找和加載(挺失望的,java 連這部分都做不好)
繼續(xù)分析代碼看看類加載在做什么。
Java 垃圾般實現(xiàn)的類查找加載
Java 地類加載不出意外最終都走到了 java.net.URLClassLoader#findClass 這里。
這里的 ucp 指的是 URLClassPath,也就是 classpath 路徑的集合。對于 SpringBoot 的應(yīng)用來說,classpath 已經(jīng)在 META-INF 里寫清楚了。
Spring-Boot-Classes: BOOT-INF/classes/Spring-Boot-Lib: BOOT-INF/lib/
此次測試的程序 BOOT-INF/lib/ 有 300 多個依賴的 jar 包,當(dāng)加載某個類時,除了 BOOT-INF/classes/ 之外 Java 居然要遍歷那 300 個 jar 包去查看這些 jar 包中是否包含某個類。
我在 loader.getResource 上注入了一下打印,看看這些函數(shù)調(diào)用了多少次。
可以看到太喪心病狂了,加載一個類,居然要調(diào)用 loader.getResource 去 jar 包中嘗試幾百次。我就按二分之一 150 來算,如果加載一萬個類,要調(diào)用這個函數(shù) 150W 次。
請忽略源碼中的 LookupCache 特性,這個特性看起來是為了加速 jar 包查找的,但是這個特性看源碼是一個 oracle 商業(yè)版的才有的特性,在目前的 jdk 中是無法啟用的。(推測,如果理解不對請告知我)
于是有了一些粗淺的想法,為何不告訴 java 這個類在那個 jar 里?做索引這么天然的想法為什么不實現(xiàn)。
以下面為例,項目依賴三個 jar 包,foo.jar、bar.jar、baz.jar,其中分別包含了特定包名的類,理想情況下我們可以生成一個索引文件,如下所示。
foo.jarcom/foo1com/foo2bar.jarcom/barcom/bar/barbarbaz.jarcom/baz
這就是我們接下來要介紹的 JarIndex 技術(shù)。
JarIndex 技術(shù)
其實 Jar 在文件格式上是支持索引技術(shù)的,稱為 JarIndex,通過 jar -i 就可以在 META-INF/ 目錄下生成 INDEX.LIST 文件。別高興的太早,這個 JarIndex 目前無法真正起到作用,有下面幾個原因:
- INDEX.LIST 文件生成不正確,尤其是目前最流行的 fatjar 中包含 jar 列表的情況
- classloader 不支持(那不是白忙活嗎)
首先來看 INDEX.LIST 文件生成不正確的問題,隨便拿一個 jar 文件,使用 jar -i 生成一下試試。
JarIndex-Version: 1.0encloud-api_origin.jarBOOT-INFBOOT-INF/classesBOOT-INF/classes/comBOOT-INF/classes/com/encloud….META-INFMETA-INF/mavenMETA-INF/maven/com.encloudMETA-INF/maven/com.encloud/encloud-apiBOOT-INF/liborgorg/springframeworkorg/springframework/bootorg/springframework/boot/loaderorg/springframework/boot/loader/jarorg/springframework/boot/loader/dataorg/springframework/boot/loader/archiveorg/springframework/boot/loader/util
可以看到在 BOOT-INF/lib 目錄中的類索引并沒有在這里生成,這里面可是有 300 多個 jar 包。
同時生成不對的地方還有,org 目錄下只有文件夾并沒有 class 文件,org 這一行不應(yīng)該在 INDEX.LIST 文件中。
第二個缺陷才是最致命的,目前的 classloader 不支持 JarIndex 這個特性。
所以我們要做兩個事情,生成正確的 JarIndex,同時修改 SpringBoot 的 classloader 讓其支持 JarIndex。
生成正確的 JarIndex
這個簡單,就是遍歷 jar 包里的類,將其所在的包名抽取出來。SpringBoot 應(yīng)用有三個地方存放了 class:
- BOOT-INF/classes
- BOOT-INF/lib
- jar 包根目錄下 org/springframework/boot/loader
生成的時候需要考慮到上面的情況,剩下的就簡單了。遍歷這些目錄,將所有的包含 class 文件的包名過濾過來就行。
大概生成的結(jié)果是:
JarIndex-Version: 1.0encloud-api.jar/BOOT-INF/classescom/encloudcom/encloud/app/controllercom/encloud/app/controller/v2/org/springframework/boot/loaderorg/springframework/boot/loader/archiveorg/springframework/boot/loader/dataorg/springframework/boot/loader/jarorg/springframework/boot/loader/util/BOOT-INF/lib/spring-core-4.3.9.RELEASE.jarorg/springframework/asmorg/springframework/cgliborg/springframework/cglib/beansorg/springframework/cglib/core/BOOT-INF/lib/guava-19.0.jarcom/google/common/annotationscom/google/common/basecom/google/common/base/internalcom/google/common/cache… other jar …
除了加載類需要查找,其實還有不少資源文件需要查找,比如 spi 等掃描過程中需要,順帶把資源文件的索引也生成一下寫入到 RES_INDEX.LIST 中,原理類似,這里展開。
自定義 classloder
生成了 INDEX.LIST 文件,接下來就是要實現(xiàn)了一個 classloader 能支持一步到位通過索引文件去對應(yīng)的 jar 包中去加載 class,核心的代碼如下:
public class JarIndexLaunchedURLClassLoader extends URLClassLoader { public JarIndexLaunchedURLClassLoader(boolean exploded, Archive rootArchive, URL[] urls, ClassLoader parent) { super(urls, parent); initJarIndex(urls); // 根據(jù) INDEX.LIST 創(chuàng)建包名到 jar 文件的映射關(guān)系 } @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { Class loadedClass = findLoadedClass(name); if (loadedClass != null) return loadedClass; // 如果是 loader 相關(guān)的類,則直接加載,不用找了,就在 jar 包的根目錄下 if (name.startsWith(“org.springframework.boot.loader.”) || name.startsWith(“com.seewo.psd.bootx.loader.”)) { Class result = loadClassInLaunchedClassLoader(name); if (resolve) { resolveClass(result); } return result; } // skip java.*, org.w3c.dom.* com.sun.* ,這些包交給 java 默認(rèn)的 classloader 去處理 if (!name.startsWith(“java”) && !name.contains(“org.w3c.dom.”) && !name.contains(“xml”) && !name.startsWith(“com.sun”)) { int lastDot = name.lastIndexOf(‘.’); if (lastDot >= 0) { String packageName = name.substring(0, lastDot); String packageEntryName = packageName.replace(‘.’, ‘/’); String path = name.replace(‘.’, ‘/’).concat(“.class”); // 通過 packageName 找到對應(yīng)的 jar 包 List loaders = package2LoaderMap.get(packageEntryName); if (loaders != null) { for (JarFileResourceLoader loader : loaders) { ClassSpec classSpec = loader.getClassSpec(path); // 從 jar 包中讀取文件 if (classSpec == null) { continue; } // 文件存在,則加載這個 class Class definedClass = defineClass(name, classSpec.getBytes(), 0, classSpec.getBytes().length, classSpec.getCodeSource()); definePackageIfNecessary(name); return definedClass; } } } } // 執(zhí)行到這里,說明需要父類加載器來加載類(兜底) definePackageIfNecessary(name); return super.loadClass(name, resolve); }}
到這里我們基本上就實現(xiàn)了一個支持 JarIndex 的類加載器,這里的改動經(jīng)實測效果已經(jīng)效果非常明顯。
除此之外,我還發(fā)現(xiàn)查找一個已加載的類是一個非常高頻執(zhí)行的操作,于是可以在 JarIndexLaunchedURLClassLoader 之前再加一層緩存(思想來自 sofa-boot)
public class CachedLaunchedURLClassLoader extends JarIndexLaunchedURLClassLoader { private final Map classCache = new ConcurrentHashMap(3000); @Override protected Class loadClass(String name, boolean resolve) throws ClassNotFoundException { return loadClassWithCache(name, resolve); } private Class loadClassWithCache(String name, boolean resolve) throws ClassNotFoundException { LoadClassResult result = classCache.get(name); if (result != null) { if (result.getEx() != null) { throw result.getEx(); } return result.getClazz(); } try { Class clazz = super.findLoadedClass(name); if (clazz == null) { clazz = super.loadClass(name, resolve); } if (clazz == null) { classCache.put(name, LoadClassResult.NOT_FOUND); } return clazz; } catch (ClassNotFoundException exception) { classCache.put(name, new LoadClassResult(exception)); throw exception; }}
注意:這里為了簡單示例直接用 ConcurrentHashMap 來緩存 class,更好的做法是用 guava-cache 等可以帶過期淘汰的 map,避免類被永久緩存。
如何不動 SpringBoot 的代碼實現(xiàn) classloader 的替換
接下的一個問題是如何不修改 SpringBoot 的情況下,把 SpringBoot 的 Classloader 替換為我們寫的呢?
大家都知道,SpringBoot 的 jar 包啟動類其實并不是我們項目中寫的 main 函數(shù),其實是
org.springframework.boot.loader.JarLauncher,這個類才是真正的 jar 包的入口。
package org.springframework.boot.loader;public class JarLauncher extends ExecutableArchiveLauncher {public static void main(String[] args) throws Exception {new JarLauncher().launch(args);}}
那我們只要替換這個入口類就可以接管后面的流程了。如果只是替換那很簡單,修改生成好的 jar 包就可以了,但是這樣后面維護(hù)的成本比較高,如果在打包的時候就替換就好了。SpringBoot 的打包是用 spring-boot-maven-plugin 插件
org.springframework.boot spring-boot-maven-plugin
最終生成的 META-INF/MANIFEST.MF 文件如下
$ cat META-INF/MANIFEST.MFManifest-Version: 1.0Implementation-Title: encloud-apiImplementation-Version: 2.0.0-SNAPSHOTArchiver-Version: Plexus ArchiverBuilt-By: arthurImplementation-Vendor-Id: com.encloudSpring-Boot-Version: 1.5.4.RELEASEImplementation-Vendor: Pivotal Software, Inc.Main-Class: org.springframework.boot.loader.JarLauncherStart-Class: com.encloud.APIBootSpring-Boot-Classes: BOOT-INF/classes/Spring-Boot-Lib: BOOT-INF/lib/Created-By: Apache Maven 3.8.5Build-Jdk: 1.8.0_332Implementation-URL: http://projects.spring.io/spring-boot/parent/enclo ud-api/
為了實現(xiàn)我們的需求,就要看 spring-boot-maven-plugin 這個插件到底是如何寫入 Main-Class 這個類的,經(jīng)過漫長的 maven 插件源碼的調(diào)試,發(fā)現(xiàn)這個插件居然提供了擴(kuò)展點,可以支持修改 Main-Class,它提供了一個 layoutFactory 可以自定義
org.springframework.boot spring-boot-maven-plugin repackage com.seewo.psd.bootx bootx-loader-tools 0.1.1
實現(xiàn)這個
package com.seewo.psd.bootx.loader.tools;import org.springframework.boot.loader.tools.*;import java.io.File;import java.io.IOException;import java.util.Locale;public class MyLayoutFactory implements LayoutFactory { private static final String NESTED_LOADER_JAR = “META-INF/loader/spring-boot-loader.jar”; private static final String NESTED_LOADER_JAR_BOOTX = “META-INF/loader/bootx-loader.jar”; public static class Jar implements RepackagingLayout, CustomLoaderLayout { @Override public void writeLoadedClasses(LoaderClassesWriter writer) throws IOException { // 拷貝 springboot loader 相關(guān)的文件到 jar 根目錄 writer.writeLoaderClasses(NESTED_LOADER_JAR); // 拷貝 bootx loader 相關(guān)的文件到 jar 根目錄 writer.writeLoaderClasses(NESTED_LOADER_JAR_BOOTX); } @Override public String getLauncherClassName() { // 替換為我們自己的 JarLauncher return “com.seewo.psd.bootx.loader.JarLauncher”; } }}
接下來實現(xiàn)我們自己的 JarLauncher
package com.seewo.psd.bootx.loader;import java.net.URL;public class JarLauncher extends org.springframework.boot.loader.JarLauncher { @Override protected ClassLoader createClassLoader(URL[] urls) throws Exception { return new CachedLaunchedURLClassLoader(urls, getClass().getClassLoader()); } public static void main(String[] args) throws Exception { new JarLauncher().launch(args); }}
重新編譯就可以實現(xiàn)替換
$ cat META-INF/MANIFEST.MFManifest-Version: 1.0…Main-Class: com.seewo.psd.bootx.loader.JarLauncher…
到這里,我們就基本完成所有的工作,不用改一行業(yè)務(wù)代碼,只用改幾行 maven 打包腳本,就可以實現(xiàn)支持 JarIndex 的類加載實現(xiàn)。
優(yōu)化效果
我們來看下實際的效果,項目 1 稍微小型一點,啟動耗時從 70s 降低到 46s
第二個 jar 包更大一點,效果更明顯,啟動耗時從 220s 減少到 123s
未完待續(xù)
其實優(yōu)化到這里,還遠(yuǎn)遠(yuǎn)沒有達(dá)到我想要的目標(biāo),為什么啟動需要這么長時間,解決了類查找的問題,那我們來深挖一下 Spring 的初始化。
Spring bean 的初始化是串行進(jìn)行的,于是我先來做一個可視化 timeline,看看到底是哪些 Bean 耗時很長。
Spring Bean 初始化時序可視化
因為不會寫前端,這里偷一下懶,利用 APM 的工具,把數(shù)據(jù)上報到 jaeger,這樣我們就可以得到一個包含調(diào)用關(guān)系的timeline 的界面了。jaeger 的網(wǎng)址在這里:www.jaegertracing.io/
首先我們繼承 DefaultListableBeanFactory 來對 createBean 的過程做記錄。
public class BeanLoadTimeCostBeanFactory extends DefaultListableBeanFactory { private static ThreadLocal parentStackThreadLocal = new ThreadLocal(); @Override protected Object createBean(String beanName, RootBeanDefinition rbd, Object[] args) throws BeanCreationException { // 記錄 bean 初始化開始 Object object = super.createBean(beanName, rbd, args); // 記錄 bean 初始化結(jié)束 return object; }
接下來我們實現(xiàn) ApplicationContextInitializer,在 initialize 方法中替換 beanFactory 為我們自己寫的。
public class BeanLoadTimeCostApplicationContextInitializer implements ApplicationContextInitializer, Ordered { public BeanLoadCostApplicationContextInitializer() { System.out.println(“in BeanLoadCostApplicationContextInitializer()”); } @Override public void initialize(ConfigurableApplicationContext applicationContext) { if (applicationContext instanceof GenericApplicationContext) { System.out.println(“BeanLoadCostApplicationContextInitializer run”); BeanLoadTimeCostBeanFactory beanFactory = new BeanLoadTimeCostBeanFactory(); Field field = GenericApplicationContext.class.getDeclaredField(“beanFactory”); field.setAccessible(true); field.set(applicationContext, beanFactory); } }}
接下來將記錄的狀態(tài)上報到 jaeger 中,實現(xiàn)可視化堆棧顯示。
public void reportBeanCreateResult(BeanCreateResult beanCreateResult) { Span span = GlobalTracer.get().buildSpan(beanCreateResult.getBeanClassName()).withStartTimestamp(beanCreateResult.getBeanStartTime() * 1000).start(); try (Scope ignore = GlobalTracer.get().scopeManager().activate(span)) { for (BeanCreateResult item : beanCreateResult.getChildren()) { Span childSpan = GlobalTracer.get().buildSpan(item.getBeanClassName()).withStartTimestamp(item.getBeanStartTime() * 1000).start(); try (Scope ignore2 = GlobalTracer.get().scopeManager().activate(childSpan)) { printBeanStat(item); } finally { childSpan.finish(item.getBeanEndTime() * 1000); } } } finally { span.finish(beanCreateResult.getBeanEndTime() * 1000); }}
通過這種方式,我們可以很輕松的看到 spring 啟動階段 bean 加載的 timeline,生成的圖如下所示。
這對我們進(jìn)一步優(yōu)化 bean 的加載提供了思路,可以看到 bean 的依賴關(guān)系和加載耗時具體耗在了哪個 bean。通過這種方式可以在 SpringBean 串行加載的前提下,把 bean 的加載盡可能的優(yōu)化。
SpringBean 的依賴分析
更好一點的方案是基于 SpringBean 的依賴關(guān)系做并行加載。這個特性 2011 年前就有人提給了 Spring,具體看這個 issue:github.com/spring-proj…
就在去年,還有人去這個 issue 下去恭祝這個 issue 10 周年快樂。
做并行加載確實有一些難度,真實項目的 Spring Bean 依賴關(guān)系非常復(fù)雜,我把 Spring Bean 的依賴關(guān)系導(dǎo)入到 neo4j 圖數(shù)據(jù)庫,然后進(jìn)行查詢
MATCH (n)RETURN n;
得到的圖如下所示。一方面 Bean 的數(shù)量特別多,還有復(fù)雜的依賴關(guān)系,以及循環(huán)依賴。
基于此依賴關(guān)系,我們是有機(jī)會去做 SpringBean 的并行加載的,這部分還沒實現(xiàn),希望后面有機(jī)會可以完整的實現(xiàn)這塊的邏輯,個人感覺可以做到 10s 內(nèi)啟動完一個超大的項目。
Java 啟動優(yōu)化的其它技術(shù)
Java 啟動的其它技術(shù)還有 Heap Archive、CDS,以及 GraalVM 的 AOT 編譯,不過這幾個技術(shù)目前都有各自的缺陷,還無法完全解決目前我們遇到的問題。
后記
這篇文章中用到的技術(shù)只是目前比較粗淺的嘗試,如果大家有更好的優(yōu)化,可以跟我交流,非常感謝。
作者:挖坑的張師傅鏈接:https://juejin.cn/post/7117815437559070734