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

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 中,暫時沒有類似的工具,但是又想知道時間到底耗在了哪裏要怎麼做呢,至少大概知道耗在了什麼地方。在生成熱點調用火焰圖的時候,我們通過 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 火焰圖沒法看,不信你看。

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

重新編譯運行

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

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

接下來就是分析這個 wall-clock 的火焰圖,點開幾個調用棧仔細分析,發現很多時間花費在類和資源文件查找和加載(挺失望的,java 連這部分都做不好)

繼續分析代碼看看類加載在做什麼。

Java 垃圾般實現的類查找加載

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

這裏的 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 包中是否包含某個類。

我在 loader.getResource 上注入了一下打印,看看這些函數調用了多少次。

可以看到太喪心病狂了,加載一個類,居然要調用 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

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

未完待續

其實優化到這裏,還遠遠沒有達到我想要的目標,爲什麼啓動需要這麼長時間,解決了類查找的問題,那我們來深挖一下 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,生成的圖如下所示。

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

SpringBean 的依賴分析

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

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

做並行加載確實有一些難度,真實項目的 Spring Bean 依賴關係非常複雜,我把 Spring Bean 的依賴關係導入到 neo4j 圖數據庫,然後進行查詢

MATCH (n)
RETURN n;

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

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

Java 啓動優化的其它技術

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

後記

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章