[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

济南小老虎發表於2024-05-18
https://heapdump.cn/article/4136322?from=pc

我們線上的業務 jar 包基本上普遍比較龐大,動不動一個 jar 包上百 M,啟動時間在分鐘級,拖慢了我們在故障時快速擴容的響應。於是做了一些分析,看看 Java 程式啟動慢到底慢在哪裡,如何去最佳化,目前的效果是大部分大型應用啟動時間可以縮短 30%~50%

主要有下面這些內容

  • 修改 async-profiler 原始碼,只抓取啟動階段 main 執行緒的 wall 時間火焰圖(✅)
  • 重新實現 JarIndex(✅)
  • 結合 JarIndex 重新自定義類載入器,啟動提速 30%+(✅)
  • SpringBean 載入耗時 timeline 視覺化分析(✅)
  • SpringBean 的視覺化依賴分析(✅)
  • 基於依賴拓撲的 SpringBean 的非同步載入(❌)

無觀測不最佳化

秉承著無觀測不最佳化的想法,首先我們要知道啟動慢到底慢在了哪裡。我之前分享過很多次關於火焰圖的使用,結果很多人遇到問題就開始考慮火焰圖,但是一個啟動慢其實是一個時序問題,不是一個 hot CPU 熱點問題。很多時候慢,不一定是 cpu 佔用過高,很有可能是等鎖、等 IO 或者傻傻的 sleep。

在 Linux 中有一個殺手級的工具 bootchart 來分析 linux 核心啟動的問題,它把啟動過程中所有的 IO、CPU 佔用情況都做了詳細的劃分,我們可以很清楚的看到各個時間段,時間耗在了哪裡,基於這個 chart,你就可以看看哪些過程可以延後處理、非同步處理等。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

在 Java 中,暫時沒有類似的工具,但是又想知道時間到底耗在了哪裡要怎麼做呢,至少大概知道耗在了什麼地方。在生成熱點呼叫火焰圖的時候,我們透過 arthas 的幾個簡單的命令就可以生成,它底層用的是 async-profiler 這個開源專案,它的作者 apangin 做過一系列關於 jvm profiling 相關的分享,感興趣的同學可以去看看。

async-profiler 底層原理簡介

async-profiler 是一個非常強大的工具,使用 jvmti 技術來實現。它的 NB 之處在於它利用了 libjvm.so 中 JVM 內部的 API AsyncGetCallTrace 來獲取 Java 函式堆疊,精簡後的虛擬碼如下:

static bool vm_init(JavaVM *vm) {
std::cout << "vm_init" << std::endl;

// 從 libjvm.so 中獲取 AsyncGetCallTrace 的函式指標控制代碼
void *libjvm = dlopen("libjvm.so", RTLD_LAZY);
_asyncGetCallTrace = (AsyncGetCallTrace) dlsym(libjvm, "AsyncGetCallTrace");
}

// 事件回撥
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);

// 呼叫 AsyncGetCallTrace 獲取堆疊
_asyncGetCallTrace(&trace, maxFramesToCapture, ucontext);
}

你可能要說獲取個堆疊還需要搞這麼複雜,jstack 等工具不是實現的很好了嗎?其實不然。

jstack 等工具獲取函式堆疊需要 jvm 進入到 safepoint,對於取樣非常頻繁的場景,會嚴重的影響 jvm 的效能,具體的原理不是本次內容的重點這裡先不展開。

async-profiler 除了可以生成熱點呼叫的火焰圖,它還提供了 Wall-clock profiling 的功能,這個功能其實就是固定時間取樣所有的執行緒(不管執行緒當前是 Running、Sleeping 還是 Blocked),它在文件中也提到了,這種方式的 profiling 適合用來分析應用的啟動過程,我們姑且用這個不太精確的方式來粗略測量啟動階段耗時在了哪些函式里。

但是這個工具會抓取所有的執行緒的堆疊,按這樣的方式抓取的 wall-clock 火焰圖沒法看,不信你看。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

就算你找到了 main 執行緒,在函式耗時算佔比的時候也不太方便,我們關心的其實只是 main 執行緒(也就是載入 jar 包,執行 spring 初始化的執行緒),於是我做了一些簡單的修改,讓 async-profiler 只取抓取 main 執行緒的堆疊。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

重新編譯執行

java 
-agentpath:/path/to/libasyncProfiler.so=start,event=wall,interval=1ms,threads,file=profile.html
-jar xxx.jar

這樣生成的火焰圖就清爽多了,這樣就知道時間耗在了什麼函式上。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

接下來就是分析這個 wall-clock 的火焰圖,點開幾個呼叫棧仔細分析,發現很多時間花費在類和資原始檔查詢和載入(挺失望的,java 連這部分都做不好)

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

繼續分析程式碼看看類載入在做什麼。

Java 垃圾般實現的類查詢載入

Java 的類載入不出意外最終都走到了 java.net.URLClassLoader#findClass 這裡。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

這裡的 ucp 指的是 URLClassPath,也就是 classpath 路徑的集合。對於 SpringBoot 的應用來說,classpath 已經在 META-INF 裡寫清楚了。

Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/

此次測試的程式 BOOT-INF/lib/ 有 300 多個依賴的 jar 包,當載入某個類時,除了 BOOT-INF/classes/ 之外 Java 居然要遍歷那 300 個 jar 包去檢視這些 jar 包中是否包含某個類。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

我在 loader.getResource 上注入了一下列印,看看這些函式呼叫了多少次。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

可以看到太喪心病狂了,載入一個類,居然要呼叫 loader.getResource 去 jar 包中嘗試幾百次。我就按二分之一 150 來算,如果載入一萬個類,要呼叫這個函式 150W 次。

請忽略原始碼中的 LookupCache 特性,這個特性看起來是為了加速 jar 包查詢的,但是這個特性看原始碼是一個 oracle 商業版的才有的特性,在目前的 jdk 中是無法啟用的。(推測,如果理解不對請告知我)

於是有了一些粗淺的想法,為何不告訴 java 這個類在那個 jar 裡?做索引這麼天然的想法為什麼不實現。

以下面為例,專案依賴三個 jar 包,foo.jar、bar.jar、baz.jar,其中分別包含了特定包名的類,理想情況下我們可以生成一個索引檔案,如下所示。

foo.jar
com/foo1
com/foo2


bar.jar
com/bar
com/bar/barbar

baz.jar
com/baz

這就是我們接下來要介紹的 JarIndex 技術。

JarIndex 技術

其實 Jar 在檔案格式上是支援索引技術的,稱為 JarIndex,透過 jar -i 就可以在 META-INF/ 目錄下生成 INDEX.LIST 檔案。別高興的太早,這個 JarIndex 目前無法真正起到作用,有下面幾個原因:

  • INDEX.LIST 檔案生成不正確,尤其是目前最流行的 fatjar 中包含 jar 列表的情況
  • classloader 不支援(那不是白忙活嗎)

首先來看 INDEX.LIST 檔案生成不正確的問題,隨便拿一個 jar 檔案,使用 jar -i 生成一下試試。

JarIndex-Version: 1.0

encloud-api_origin.jar
BOOT-INF
BOOT-INF/classes
BOOT-INF/classes/com
BOOT-INF/classes/com/encloud
....
META-INF
META-INF/maven
META-INF/maven/com.encloud
META-INF/maven/com.encloud/encloud-api
BOOT-INF/lib
org
org/springframework
org/springframework/boot
org/springframework/boot/loader
org/springframework/boot/loader/jar
org/springframework/boot/loader/data
org/springframework/boot/loader/archive
org/springframework/boot/loader/util

可以看到在 BOOT-INF/lib 目錄中的類索引並沒有在這裡生成,這裡面可是有 300 多個 jar 包。

同時生成不對的地方還有,org 目錄下只有資料夾並沒有 class 檔案,org 這一行不應該在 INDEX.LIST 檔案中。

第二個缺陷才是最致命的,目前的 classloader 不支援 JarIndex 這個特性。

所以我們要做兩個事情,生成正確的 JarIndex,同時修改 SpringBoot 的 classloader 讓其支援 JarIndex。

生成正確的 JarIndex

這個簡單,就是遍歷 jar 包裡的類,將其所在的包名抽取出來。SpringBoot 應用有三個地方存放了 class:

  • BOOT-INF/classes
  • BOOT-INF/lib
  • jar 包根目錄下 org/springframework/boot/loader

生成的時候需要考慮到上面的情況,剩下的就簡單了。遍歷這些目錄,將所有的包含 class 檔案的包名過濾過來就行。

大概生成的結果是:

JarIndex-Version: 1.0

encloud-api.jar

/BOOT-INF/classes
com/encloud
com/encloud/app/controller
com/encloud/app/controller/v2

/
org/springframework/boot/loader
org/springframework/boot/loader/archive
org/springframework/boot/loader/data
org/springframework/boot/loader/jar
org/springframework/boot/loader/util

/BOOT-INF/lib/spring-core-4.3.9.RELEASE.jar
org/springframework/a**
org/springframework/cglib
org/springframework/cglib/beans
org/springframework/cglib/core

/BOOT-INF/lib/guava-19.0.jar
com/google/common/annotations
com/google/common/base
com/google/common/base/internal
com/google/common/cache

... other jar ...

除了載入類需要查詢,其實還有不少資原始檔需要查詢,比如 spi 等掃描過程中需要,順帶把資原始檔的索引也生成一下寫入到 RES_INDEX.LIST 中,原理類似,這裡展開。

自定義 classloder

生成了 INDEX.LIST 檔案,接下來就是要實現了一個 classloader 能支援一步到位透過索引檔案去對應的 jar 包中去載入 class,核心的程式碼如下:

public class JarIndexLaunchedURLClassLoader extends URLClassLoader {
public JarIndexLaunchedURLClassLoader(boolean exploded, Archive rootArchive, URL[] urls, ClassLoader parent) {
super(urls, parent);
initJarIndex(urls); // 根據 INDEX.LIST 建立包名到 jar 檔案的對映關係
}

@Override
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {

Class<?> loadedClass = findLoadedClass(name);
if (loadedClass != null) return loadedClass;

// 如果是 loader 相關的類,則直接載入,不用找了,就在 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 預設的 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 找到對應的 jar 包
List<JarFileResourceLoader> 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;
}
}
}
}
// 執行到這裡,說明需要父類載入器來載入類(兜底)
definePackageIfNecessary(name);
return super.loadClass(name, resolve);
}
}

到這裡我們基本上就實現了一個支援 JarIndex 的類載入器,這裡的改動經實測效果已經效果非常明顯。

除此之外,我還發現查詢一個已載入的類是一個非常高頻執行的操作,於是可以在 JarIndexLaunchedURLClassLoader 之前再加一層快取(思想來自 sofa-boot)

public class CachedLaunchedURLClassLoader extends JarIndexLaunchedURLClassLoader {
private final Map<String, LoadClassResult> 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 的程式碼實現 classloader 的替換

接下的一個問題是如何不修改 SpringBoot 的情況下,把 SpringBoot 的 Classloader 替換為我們寫的呢?

大家都知道,SpringBoot 的 jar 包啟動類其實並不是我們專案中寫的 main 函式,其實是

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 包就可以了,但是這樣後面維護的成本比較高,如果在打包的時候就替換就好了。SpringBoot 的打包是用 spring-boot-maven-plugin 外掛

<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>

最終生成的 META-INF/MANIFEST.MF 檔案如下

$ cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
Implementation-Title: encloud-api
Implementation-Version: 2.0.0-SNAPSHOT
Archiver-Version: Plexus Archiver
Built-By: arthur
Implementation-Vendor-Id: com.encloud
Spring-Boot-Version: 1.5.4.RELEASE
Implementation-Vendor: Pivotal Software, Inc.
Main-Class: org.springframework.boot.loader.JarLauncher
Start-Class: com.encloud.APIBoot
Spring-Boot-Classes: BOOT-INF/classes/
Spring-Boot-Lib: BOOT-INF/lib/
Created-By: Apache Maven 3.8.5
Build-Jdk: 1.8.0_332
Implementation-URL: http://projects.spring.io/spring-boot/parent/enclo
ud-api/

為了實現我們的需求,就要看 spring-boot-maven-plugin 這個外掛到底是如何寫入 Main-Class 這個類的,經過漫長的 maven 外掛原始碼的除錯,發現這個外掛居然提供了擴充套件點,可以支援修改 Main-Class,它提供了一個 layoutFactory 可以自定義

<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
<configuration>
<layoutFactory implementation="com.seewo.psd.bootx.loader.tools.MyLayoutFactory"/>
</configuration>
</execution>
</executions>
<dependencies>
<dependency>
<groupId>com.seewo.psd.bootx</groupId>
<artifactId>bootx-loader-tools</artifactId>
<version>0.1.1</version>
</dependency>
</dependencies>
</plugin>

實現這個

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 相關的檔案到 jar 根目錄
writer.writeLoaderClasses(NESTED_LOADER_JAR);
// 複製 bootx loader 相關的檔案到 jar 根目錄
writer.writeLoaderClasses(NESTED_LOADER_JAR_BOOTX);
}

@Override
public String getLauncherClassName() {
// 替換為我們自己的 JarLauncher
return "com.seewo.psd.bootx.loader.JarLauncher";
}
}
}

接下來實現我們自己的 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);
}
}

重新編譯就可以實現替換

$ cat META-INF/MANIFEST.MF
Manifest-Version: 1.0
...
Main-Class: com.seewo.psd.bootx.loader.JarLauncher
...

到這裡,我們就基本完成所有的工作,不用改一行業務程式碼,只用改幾行 maven 打包指令碼,就可以實現支援 JarIndex 的類載入實現。

最佳化效果

我們來看下實際的效果,專案 1 稍微小型一點,啟動耗時從 70s 降低到 46s

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

第二個 jar 包更大一點,效果更明顯,啟動耗時從 220s 減少到 123s

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

未完待續

其實最佳化到這裡,還遠遠沒有達到我想要的目標,為什麼啟動需要這麼長時間,解決了類查詢的問題,那我們來深挖一下 Spring 的初始化。

Spring bean 的初始化是序列進行的,於是我先來做一個視覺化 timeline,看看到底是哪些 Bean 耗時很長。

Spring Bean 初始化時序視覺化

因為不會寫前端,這裡偷一下懶,利用 APM 的工具,把資料上報到 jaeger,這樣我們就可以得到一個包含呼叫關係的timeline 的介面了。jaeger 的網址在這裡:https://www.jaegertracing.io/

首先我們繼承 DefaultListableBeanFactory 來對 createBean 的過程做記錄。

public class BeanLoadTimeCostBeanFactory extends DefaultListableBeanFactory {

private static ThreadLocal<Stack<BeanCreateResult>> parentStackThreadLocal = new ThreadLocal<>();


@Override
protected Object createBean(String beanName, RootBeanDefinition rbd, Object[] args) throws BeanCreationException {
// 記錄 bean 初始化開始
Object object = super.createBean(beanName, rbd, args);
// 記錄 bean 初始化結束
return object;
}

接下來我們實現 ApplicationContextInitializer,在 initialize 方法中替換 beanFactory 為我們自己寫的。

public class BeanLoadTimeCostApplicationContextInitializer implements ApplicationContextInitializer<ConfigurableApplicationContext>, 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);
}
}
}

接下來將記錄的狀態上報到 jaeger 中,實現視覺化堆疊顯示。

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,生成的圖如下所示。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

這對我們進一步最佳化 bean 的載入提供了思路,可以看到 bean 的依賴關係和載入耗時具體耗在了哪個 bean。透過這種方式可以在 SpringBean 序列載入的前提下,把 bean 的載入儘可能的最佳化。

SpringBean 的依賴分析

更好一點的方案是基於 SpringBean 的依賴關係做並行載入。這個特性 2011 年前就有人提給了 Spring,具體看這個 issue:https://github.com/spring-projects/spring-framework/issues/13410

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

就在去年,還有人去這個 issue 下去恭祝這個 issue 10 週年快樂。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

做並行載入確實有一些難度,真實專案的 Spring Bean 依賴關係非常複雜,我把 Spring Bean 的依賴關係匯入到 neo4j 圖資料庫,然後進行查詢

MATCH (n)
RETURN n;

得到的圖如下所示。一方面 Bean 的數量特別多,還有複雜的依賴關係,以及迴圈依賴。

[轉帖]【全網首發】一些可以顯著提高 Java 啟動速度方法原創

基於此依賴關係,我們是有機會去做 SpringBean 的並行載入的,這部分還沒實現,希望後面有機會可以完整的實現這塊的邏輯,個人感覺可以做到 10s 內啟動完一個超大的專案。

Java 啟動最佳化的其它技術

Java 啟動的其它技術還有 Heap Archive、CDS,以及 GraalVM 的 AOT 編譯,不過這幾個技術目前都有各自的缺陷,還無法完全解決目前我們遇到的問題。

後記

這篇文章中用到的技術只是目前比較粗淺的嘗試,如果大家有更好的最佳化,可以跟我交流,非常感謝。

相關文章