探究 Java 應用的啟動速度優化


簡介: 在高性能的背后,Java 的啟動性能差也令人印象深刻,大家印象中的 Java 笨重緩慢的印象也大多來源於此。高性能和快啟動速度似乎有一些相悖,本文將和大家一起探究兩者是否可以兼得。

作者 | 梁希

高性能和快啟動速度,能否魚和熊掌兼得?

Java 作為一門面向對象編程語言,在性能方面的卓越表現獨樹一幟。

《Energy Efficiency across Programming Languages,How Does Energy, Time, and Memory Relate?》這份報告調研了各大編程語言的執行效率,雖然場景的豐富程度有限,但是也能夠讓我們見微知著。

image.png

從表中,我們可以看到,Java 的執行效率非常高,約為最快的 C 語言的一半。這在主流的編程語言中,僅次於 C、Rust 和 C++。

Java 的優異性能得益於 Hotspot 中非常優秀的 JIT 編譯器。Java 的 Server Compiler(C2) 編譯器是 Cliff Click 博士的作品,使用了 Sea-of-Nodes 模型。而這項技術,也通過時間證明了它代表了業界的最先進水平:

  • 著名的 V8(JavaScript 引擎)的 TurboFan 編譯器使用了相同的設計,只是用更加現代的方式去實現;
  • Hotspot 使用 Graal JVMCI 做 JIT 時,性能基本與 C2 持平;
  • Azul 的商業化產品將 Hotspot 中的 C2 compiler 替換成 LLVM,峰值性能和 C2 也是持平。

在高性能的背后,Java 的啟動性能差也令人印象深刻,大家印象中的 Java 笨重緩慢的印象也大多來源於此。高性能和快啟動速度似乎有一些相悖,本文將和大家一起探究兩者是否可以兼得。

JAVA 啟動慢的根因

1、框架復雜

JakartaEE 是 Oracle 將 J2EE 捐贈給 Eclipse 基金會后的新名字。Java 在1999年推出時便發布了 J2EE 規范,EJB(Java Enterprise Beans) 定義了企業級開發所需要的安全、IoC、AOP、事務、並發等能力。設計極度復雜,最基本的應用都需要大量的配置文件,使用非常不便。

隨着互聯網的興起,EJB 逐漸被更加輕量和免費的 Spring 框架取代,Spring 成了 Java 企業開發的事實標准。Spring 雖然定位更加輕量,但是骨子里依然很大程度地受 JakartaEE 的影響,比如早期版本大量 xml 配置的使用、大量 JakartaEE 相關的注解(比如JSR 330依賴注入),以及規范(如JSR 340 Servlet API)的使用。

但 Spring 仍是一個企業級的框架,我們看幾個 Spring 框架的設計哲學:

  • 在每一層都提供選項,Spring 可以讓你盡可能的推遲選擇。
  • 適應不同的視角,Spring 具有靈活性,它不會強制為你決定該怎么選擇。它以不同的視角支持廣泛的應用需求。
  • 保持強大的向后兼容性。

在這種設計哲學的影響下,必然存在大量的可配置和初始化邏輯,以及復雜的設計模式來支撐這種靈活性。我們通過一個試驗來看:

我們跑一個spring-boot-web的helloword,通過-verbose:class可以看到依賴的class文件:

$ java -verbose:class -jar myapp-1.0-SNAPSHOT.jar | grep spring | head -n 5
[Loaded org.springframework.boot.loader.Launcher from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]
[Loaded org.springframework.boot.loader.ExecutableArchiveLauncher from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]
[Loaded org.springframework.boot.loader.JarLauncher from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]
[Loaded org.springframework.boot.loader.archive.Archive from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]
[Loaded org.springframework.boot.loader.LaunchedURLClassLoader from file:/Users/yulei/tmp/myapp-1.0-SNAPSHOT.jar]

$ java -verbose:class -jar myapp-1.0-SNAPSHOT.jar | egrep '^\[Loaded' > classes
$ wc classes
    7404   29638 1175552 classes

class 個數到達驚人的 7404 個。

我們再對比下 JavaScript 生態,使用常用的 express 編寫一個基本應用:

const express = require('express')
const app = express()

app.get('/', (req, res) => {
  res.send('Hello World!')
})
  
  app.listen(3000, () => {
    console.log(`Example app listening at http://localhost:${port}`)
})

我們借用 Node 的 debug 環境變量分析:

NODE_DEBUG=module node app.js 2>&1  | head -n 5
MODULE 18614: looking for "/Users/yulei/tmp/myapp/app.js" in ["/Users/yulei/.node_modules","/Users/yulei/.node_libraries","/usr/local/Cellar/node/14.4.0/lib/node"]
MODULE 18614: load "/Users/yulei/tmp/myapp/app.js" for module "."
MODULE 18614: Module._load REQUEST express parent: .
MODULE 18614: looking for "express" in ["/Users/yulei/tmp/myapp/node_modules","/Users/yulei/tmp/node_modules","/Users/yulei/node_modules","/Users/node_modules","/node_modules","/Users/yulei/.node_modules","/Users/yulei/.node_libraries","/usr/local/Cellar/node/14.4.0/lib/node"]
MODULE 18614: load "/Users/yulei/tmp/myapp/node_modules/express/index.js" for module "/Users/yulei/tmp/myapp/node_modules/express/index.js"

$ NODE_DEBUG=module node app.js 2>&1  | grep ': load "' > js
$ wc js
      55     392    8192 js

這里只依賴了區區 55個 js 文件。

雖然拿 spring-boot 和 express 比並不公平。在 Java 世界也可以基於 Vert.X、Netty 等更加輕量的框架來構建應用,但是在實踐中,大家幾乎都會不假思索地選擇 spring-boot,以便享受 Java 開源生態的便利。

2、一次編譯,到處運行

Java 啟動慢是因為框架復雜嗎?答案只能說框架復雜是啟動慢的原因之一。通過 GraalVM 的 Native Image 功能結合 spring-native 特性,可以將 spring-boot 應用的啟動時間縮短約十倍。

Java 的 Slogan 是 "Write once, run anywhere"(WORA),Java 也確實通過字節碼和虛擬機技術做到了這一點。

WORA 使得開發者在 MacOS 上開發調試完成的應用可以快速部署到 Linux 服務器,跨平台性也讓 Maven 中心倉庫更加易於維護,促成了 Java 開源生態的繁榮。

我們來看一下 WORA 對 Java 的影響:

  • Class Loading

Java 通過 class 來組織源碼,class 被塞進 JAR 包以便組織成模塊和分發,JAR 包本質上是一個 ZIP 文件:

$ jar tf slf4j-api-1.7.25.jar | head
META-INF/
META-INF/MANIFEST.MF
org/slf4j/
org/slf4j/event/EventConstants.class
org/slf4j/event/EventRecodingLogger.class
org/slf4j/event/Level.class

每個 JAR 包都是功能上比較獨立的模塊,開發者就可以按需依賴特定功能的 JAR,這些 JAR 通過 class path 被JVM 所知悉,並進行加載。

根據,執行到 new 或者 invokestatic 字節碼時會觸發類加載。JVM 會將控制交給 Classloader ,最常見的實現 URLClassloader 會遍歷 JAR 包,去尋找相應的 class 文件:

for (int i = 0; (loader = getNextLoader(cache, i)) != null; i++) {
    Resource res = loader.getResource(name, check);
    if (res != null) {
        return res;
    }
}

因此查找類的開銷,通常和 JAR 包個數成正比,在大型應用的場景下個數會上千,導致整體的查找耗時很高。

當找到 class 文件后 JVM 需要校驗 class 文件的是否合法,並解析成內部可用的數據結構,在 JVM 中叫做 InstanceKlass ,聽過 javap 窺視一下class文件包含的信息:

$ javap -p SimpleMessage.class
public class org.apache.logging.log4j.message.SimpleMessage implements org.apache.logging.log4j.message.Message,org.apache.logging.log4j.util.StringBuilderFormattable,java.lang.CharSequence {
  private static final long serialVersionUID;
  private java.lang.String message;
  private transient java.lang.CharSequence charSequence;
  public org.apache.logging.log4j.message.SimpleMessage();
  public org.apache.logging.log4j.message.SimpleMessage(java.lang.String);

這個結構包含接口、基類、靜態數據、對象的 layout、方法字節碼、常量池等等。這些數據結構都是解釋器執行字節碼或者JIT編譯所必須的。

  • Class initialize

當類被加載完成后,要完成初始化才能實際創建對象或者調用靜態方法。類初始化可以簡單理解為靜態塊:

public class A {
  private final static String JAVA_VERSION_STRING = System.getProperty("java.version");
    private final static Set<Integer> idBlackList = new HashSet<>();
    static {
        idBlackList.add(10);
        idBlackList.add(65538);
    }
}

上面的第一個靜態變量 JAVA_VERSION_STRING 的初始化在編譯成字節碼后也會成為靜態塊的一部分。

類初始化有如下特點:

  • 只執行一次;
  • 有多線程嘗試訪問類時,只有一個線程會執行類初始化,JVM 保證其他線程都會阻塞等待初始化完成。

這些特點非常適合讀取配置,或者構造一些運行時所需要數據結構、緩存等等,因此很多類的初始化邏輯會寫的比較復雜。

  • Just In Time compile

Java 類在被初始化后就可以實例對象,並調用對象上的方法了。解釋執行類似一個大的 switch..case 循環,性能比較差:

while (true) {
  switch(bytocode[pc]) {
        case AALOAD:
            ...
            break;
        case ATHROW:
            ...
            break;
    }
}

我們用 JMH 來跑一個 Hessian 序列化的 Micro Benchmark 試驗:

$ java -jar benchmarks.jar hessianIO
Benchmark                      Mode  Cnt       Score   Error  Units
SerializeBenchmark.hessianIO  thrpt       118194.452          ops/s

$ java -Xint -jar benchmarks.jar hessianIO
Benchmark                      Mode  Cnt     Score   Error  Units
SerializeBenchmark.hessianIO  thrpt       4535.820          ops/s

第二次運行的 -Xint 參數控制了我們只使用解釋器,這里差了26倍,這是直接機器執行的執行和解釋執行的差異帶來的。這個差距跟場景的關系很大,我們通常的經驗值是50倍。

我們來進一步看下 JIT 的行為:

$ java -XX:+PrintFlagsFinal -version | grep CompileThreshold
     intx Tier3CompileThreshold                     = 2000                                {product}
     intx Tier4CompileThreshold                     = 15000                               {product}

這里是兩項 JDK 內部的 JIT 參數的數值,我們暫不對分層編譯原理做過多介紹,可以參考Stack Overflow。Tier3 可以簡單理解為(client compiler)C1,Tier4 是 C2。當一個方法解釋執行2000次會進行 C1 編譯,當 C1 編譯后執行15000次后就會 C2 編譯,真正達到文章開頭的 C 的一半性能完全體。

在應用剛啟動階段,方法還沒有完全被JIT編譯完成,因此大部分情況停留在解釋執行,影響了應用啟動的速度。

如何優化 Java 應用的啟動速度

前面我們花了大量的篇幅分析了 Java 應用啟動慢的主要原因,總結下就是:

  • 受到 JakartaEE 影響,常見框架考慮復用和靈活性,設計得比較復雜;
  • 為了跨平台性,代碼是動態加載,並且動態編譯的,啟動階段加載和執行耗時;

這兩者綜合起來造成了 Java 應用啟動慢的現狀。

Python 和 Javascript 都是動態解析加載模塊的,CPyhton 甚至沒有 JIT,理論上啟動不會比 Java 快很多,但是它們並沒有使用很復雜的應用框架,因此整體不會感受到啟動性能的問題。

雖然我們無法輕易去改變用戶對框架的使用習慣,但是可以在運行時層面進行增強,使啟動性能盡量靠近 Native image。OpenJDK 官方社區也一直在努力解決啟動性能問題,那么我們作為普通 Java 開發者,是否可以借助OpenJDK的最新特性來協助我們提升啟動性能呢?

  • Class Loading
  1. 通過 JarIndex 解決 JAR 包遍歷問題,不過該技術過於古老,很難在現代的囊括了tomcat、fatJar的項目里使用起來
  2. AppCDS 可以解決 class 文件解析處理的性能問題
  • Class Initialize: OpenJDK9 加入了 HeapArchive,可以持久化一部分類初始化相關的 Heap 數據,不過只有寥寥數個 JDK 內部 class (比如 IntegerCache )可以被加速,沒有開放的使用方式。
  • JIT預熱: JEP295 實現了 AOT 編譯,但是存在 bug,使用不當會引發程序正確性能問題。在性能上沒有得到很好的 tuning,大部分情況下看不到效果,甚至會出現性能回退。

面對 OpenJDK 上述特性所存在的問題,Alibaba Dragonwell 對以上各項技術進行了研發優化,並與雲產品進行了整合,用戶不需要投入太多精力就可以輕松地優化啟動時間。

1、AppCDS

CDS(Class Data Sharing)在 Oracle JDK1.5 被首次引入,在 Oracle JDK8u40 中引入了 AppCDS,支持 JDK 以外的類 ,但是作為商業特性提供。隨后 Oracle 將 AppCDS 貢獻給了社區,在 JDK10 中 CDS 逐漸完善,也支持了用戶自定義類加載器(又稱 AppCDS v2)。

面向對象語言將對象(數據)和方法(對象上的操作)綁定到了一起,來提供更強的封裝性和多態。這些特性都依賴對象頭中的類型信息來實現,Java、Python 語言都是如此。Java 對象在內存中的 layout 如下:

+-------------+|  mark       |+-------------+|  Klass*     |+-------------+|  fields     ||             |+-------------+

mark 表示了對象的狀態,包括是否被加鎖、GC 年齡等等。而 Klass*指向了描述對象類型的數據結構 InstanceKlass :

//  InstanceKlass layout:
//    [C++ vtbl pointer           ] Klass
//    [java mirror                ] Klass
//    [super                      ] Klass
//    [access_flags               ] Klass
//    [name                       ] Klass
//    [methods                    ]
//    [fields                     ]
...

基於這個結構,諸如 o instanceof String 這樣的表達式就可以有足夠的信息判斷了。要注意的是 InstanceKlass 結構比較復雜,包含了類的所有方法、field 等等,方法又包含了字節碼等信息。這個數據結構是通過運行時解析 class 文件獲得的,為了保證安全性,解析 class 時還需要校驗字節碼的合法性( 非通過 Javac 產生的方法字節碼很容易引起 JVM crash)。

CDS 可以將這個解析、校驗產生的數據結構存儲(dump)到文件,在下一次運行時重復使用。這個 dump 產物叫做 Shared Archive,以 jsa 后綴(Java shared archive)。

為了減少 CDS 讀取 jsa dump 的開銷,避免將數據反序列化到 InstanceKlass 的開銷,jsa 文件中的存儲 layout 和 InstanceKlass 對象完全一樣,這樣在使用 jsa 數據時,只需要將 jsa 文件映射到內存,並且讓對象頭中的類型指針指向這塊內存地址即可,十分高效。

Object:+-------------+|  mark       |         +-------------------------++-------------+         |classes.jsa file         ||  Klass*     +--------->java_mirror|super|methods|+-------------+         |java_mirror|super|methods||  fields     |         |java_mirror|super|methods||             |    
Object:
+-------------+
|  mark       |         +-------------------------+
+-------------+         |classes.jsa file         |
|  Klass*     +--------->java_mirror|super|methods|
+-------------+         |java_mirror|super|methods|
|  fields     |         |java_mirror|super|methods|
|             |         +-------------------------+
+-------------+     +-------------------------++-------------+

1、AppCDS 對 customer class loader 力不從心
jsa 中存儲的 InstanceKlass 是對 class 文件解析的產物。對於 boot classloader (就是加載 jre/lib/rt.jar下面的類的 classloader)和 system(app) classloader (加載-classpath 下面的類的 classloader ),CDS 有內部機制可以跳過對 class 文件 的讀取,僅僅通過類名在 jsa 文件中匹配對應的數據結構。

Java 還提供用戶自定義類加載器(custom class loader)的機制,用戶通過 Override 自己的 Classloader.loadClass() 方法可以高度定制化獲取類的邏輯,比如從網絡上獲取、直接在代碼中動態生成都是可行的。為了增強 AppCDS 的安全性,避免因為從 CDS 加載了類定義反而獲得了非預期的類,AppCDS customer class loader 需要經過如下步驟:

  1. 調用用戶定義的 Classloader.loadClass(),拿到 class byte stream
  2. 計算 class byte stream 的 checksum,與 jsa 中的同類名結構的 checksum 比較
  3. 如果匹配成功則返回jsa中的InstanceKlass,否則繼續使用 slow path 解析 class文件

我們看到許多場景下,上述的第一步占據了類加載耗時的大頭,此時 AppCDS 就顯得力不從心了。舉例來說:

bar.jar
 +- com/bar/Bar.class

baz.jar
 +- com/baz/Baz.class

foo.jar
 +- com/foo/Foo.class

class path 包含如上的三個 jar 包,在加載 class com.foo.Foo 時,大部分 Classloader 實現(包括 URLClassloader、tomcat、spring-boot)都選擇了最簡單的策略(過早的優化是萬惡之源): 按照 jar 包出現在磁盤的順序逐個嘗試抽取 com/foo/Foo.class 這個文件。

JAR 包使用了 zip 格式作為存儲,每次類加載都需要遍歷 classpath 下的 JAR 包們,嘗試從 zip 中抽取單個文件,來確保存在的類可以被找到。假設有 N個 JAR 包,那么平均一個類加載需要嘗試訪問 N/2 個 zip 文件。

在我們的一個真實場景下,N 到達 2000,此時 JAR 包查找開銷非常大,並且遠大於 InstanceKlass 解析的開銷。面對此類場景 AppCDS 技術就力不從心了。

2、JAR Index

根據 jar 文件規范,JAR 文件是一種使用 zip封裝,並使用文本在 META-INF 目錄存儲元信息的格式。該格式在設計時已經考慮了應對上述的查找場景,這項技術叫做 JAR Index。

假設我們要在上述的 bar.jar、baz.jar、foo.jar 中查找一個 class,如果能夠通過類型 com.foo.Foo,立刻推斷出具體在哪個 jar 包,就可以避免上述的掃描開銷了。

JarIndex-Version: 1.0

foo.jar
com/foo

bar.jar
com/bar

baz.jar
com/baz

通過 JAR Index 技術,可以生成出上述的索引文件 INDEX.LIST。加載到內存后成為一個 HashMap:

com/bar --> bar.jar
com/baz --> baz.jar
com/foo --> foo.jar

當我們看到類名com.foo.Foo,可以根據包名 com.foo 從索引中得知具體的 jar 包 foo.jar,迅速抽取 class 文件。

Jar Index 技術看似解決了我們的問題,但是這項技術十分古老,很難在現代應用中被使用起來:

  • jar i 根據 META-INF/MANIFEST.MF 中的 Class-Path 屬性產生索引文件,現代項目幾乎不維護這個屬性
  • 只有 URLClassloader 支持 JAR Index
  • 要求帶索引的 jar 盡量出現在 classpath 的前面

Dragonwell 通過 agent 注入使得 INDEX.LIST 能夠被正確地生成,並出現在 classpath 的合適位置來幫助應用提升啟動性能。

2、類提前初始化

類的 static block 中的代碼執行我們稱之為類初始化,類加載完成后必須執行完初始化代碼才能被使用(創建 instance、調用 static 方法)。

很多類的初始化本質上只是構造一些 static field:

class IntegerCache {
    static final Integer cache[];
    static {
        Integer[] c = new Integer[size];
        int j = low;
        for(int k = 0; k < c.length; k++)
            c[k] = new Integer(j++);
        cache = c;
    }
}

我們知道 JDK 對 box type 中常用的一段區間有緩存,避免過多的重復創建,這段數據就需要提前構造好。由於這些方法只會被執行一次,因此是以純解釋的方式執行的,如果可以持久化幾個 static 字段的方式來避免調用類初始化器,我們就可以拿到提前初始化好的類,減少啟動時間。

將持久化加載到內存使用最高效的方式是內存映射:

int fd = open("archive_file", O_READ);
struct person *persons = mmap(NULL, 100 * sizeof(struct person),
                              PROT_READ, fd, 0);
int age = persons[5].age;

C語言幾乎是直接面向內存來操作數據的,而 Java 這樣的高級語言都將內存抽象成了對象,有 mark、Klass*等元信息,每次運行之間都存在一定的變化,因此需要更加復雜的機智來獲得高效的對象持久化。

1、Heap Archive 簡介

OpenJDK9 引入了 HeapArchive 能力,OpenJDK12 中 heap archive 被正式使用。顧名思義,Heap Archive 技術可以將堆上的對象持久化存儲下來。

對象圖被提前被構建好后放進 archive,我們將這個階段稱為 dump;而使用 archive 里的數據稱為運行時。dump 和運行時通常不是一個進程,但在某些場景下也可以是同一個進程。

回憶下使用 AppCDS 后的內存布局,對象的 Klass*指針指向了 SharedArchive 中的的數據。AppCDS 對 InstanceKlass 這個元信息進行了持久化,如果想要復用持久化的對象,那么對象頭的類型指針必須也要指向一塊被持久化過的元信息,因此 HeapArchive 技術是依賴 AppCDS 的。

為了適應多種場景,OpenJDK 的 HeapArchive 還提供了 Open 和 Closed 兩種級別:

image.png

上圖是允許的引用關系:
  • Closed Archive
  1. 不允許引用 Open Archive 和 Heap 中的對象
  2. 可以引用 Closed Archive 內部的對象
  3. 只讀,不可寫
  • Open Archive
  1. 可以引用任何對象
  2. 可寫

這樣設計的原因是對於一些只讀結構,放在 Closed Archive 中可以做到對 GC 完全無開銷。

為什么只讀?想象一下,假如Closed Archive中的對象A引用了heap中的對象B,那么當對象B移動時,GC需要修正A中指向B的field,這會帶來GC開銷。

2、利用 Heap Archive 提前做類初始化
支持這種結構后,在類加載后,將 static 變量指向被 Archive 的對象,即可完成類初始化:

class Foo {
  static Object data;
}                 +
                  |
        <---------+
Open Archive Object:
+-------------+
|  mark       |         +-------------------------+
+-------------+         |classes.jsa file         |
|  Klass*     +--------->java_mirror|super|methods|
+-------------+         |java_mirror|super|methods|
|  fields     |         |java_mirror|super|methods|
|             |         +-------------------------+
+-------------+

3、AOT 編譯

除去類的加載,方法的前幾次執行因為沒有被 JIT 編譯器給編譯,字節碼在解釋模式下執行。根據本文上半部分的分析,解釋執行速度約為 JIT 編譯后的幾十分之一,代碼解釋執行慢也啟動慢的一大元凶。

傳統的 C/C++等語言都是直接編譯到目標平台的 native 機器碼。隨着大家意識到 Java、JS 等解釋器 JIT 語言的啟動預熱問題,通過 AOT 將字節碼直接編譯到 native 代碼這種方式逐漸進入公眾視野。

wasm、GraalVM、OpenJDK 都不同程度地支持了 AOT 編譯,我們主要圍繞 JEP295 引入的 jaotc 工具優化啟動速度。

注意這里的術語使用:

JEP295使用AOT是將class文件中的方法逐個編譯到native代碼片段,通過Java虛擬機在加載某個類后替換方法的的入口到AOT代碼。而GraalVM的的Native Image功能是更加徹底的靜態編譯,通過一個用Java代碼編寫的小型運行時SubstrateVM,該運行時和應用代碼一起被靜態編譯到可執行的文件(類似Go),不再依賴JVM。該做法也是一種AOT,但是為了區分術語,這里的AOT單指JEP295的方式。

1、AOT特性初體驗

通過 JEP295 的介紹,我們可以快速體驗 AOT

cat > HelloWorld.java <<EOF
public class HelloWorld {
    public static void main(String[] args) { System.out.println("Hello World!"); }
}
EOF
jaotc --output libHelloWorld.so HelloWorld.class
java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=./libHelloWorld.so HelloWorld

jaotc 命令會調用 Graal 編譯器對字節碼進行編譯,產生 libHelloWorld.so 文件。這里產生的 so 文件容易讓人誤以為會直接像 JNI 一樣調用進編譯好的庫代碼。但是這里並沒有完全使用 ld 的加載機制來運行代碼,so 文件更像是當做一個 native 代碼的容器。hotsopt runtime 在加載 AOT so 后需要進行進一步的動態鏈接。在類加載后 hotspot  會自動關聯 AOT 代碼入口,對於下次方法調用使用 AOT 版本。而 AOT 生成的代碼也會主動與 hotspot 運行時交互,在 aot、解釋器、JIT 代碼間相互跳轉。

1)AOT 的一波三折

看起來JEP295已經實現了一套完備的AOT體系,但是為何不見這項技術被大規模使用?在 OpenJDK 的各項新特性中,AOT 算得上是命途多舛。

2)多 Classloader 問題

JDK-8206963: bug with multiple class loaders

這是在設計上沒有考慮到Java的多 Classloader 場景,當多個 Classloader 加載的同名類都使用了 AOT 后,他們的 static field 是共享的,而根據 Java 語言的設計,這部分數據應該是隔開的。

由於沒有可以快速修復這個問題的方案,OpenJDK 僅僅是添加了如下代碼:

ClassLoaderData* cld = ik->class_loader_data();
  if (!cld->is_builtin_class_loader_data()) {
    log_trace(aot, class, load)("skip class  %s  for custom classloader %s (%p) tid=" INTPTR_FORMAT,
                                ik->internal_name(), cld->loader_name(), cld, p2i(thread));
    return false;
}

對於用戶自定義類加載器不允許使用 AOT。從這里已經可以初步看出該特性在社區層面已經逐漸缺乏維護。

在這種情況下,雖然通過 class-path 指定的類依然可以使用 AOT,但是我們常用的 spring-boot、Tomcat 等框架都需要通過 Custom Classloader 加載應用代碼。可以說這一改變切掉了 AOT 的一大塊場景。

3)缺乏調優和維護,退回成實驗特性

JDK-8227439: Turn off AOT by default

JEP 295 AOT is still experimental, and while it can be useful for startup/warmup when used with custom generated archives tailored for the application, experimental data suggests that generating shared libraries at a module level has overall negative impact to startup, dubious efficacy for warmup and severe static footprint implications.

從此打開 AOT 需要添加 experimental 參數:

java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=...

根據 issue 的描述,這項特性編譯整個模塊的情況下,對啟動速度和內存占用都起到了反作用。我們分析的原因如下:

  • Java 語言本身過分復雜,動態類加載等運行時機制導致 AOT 代碼沒法運行得像預期一樣快
  • AOT 技術作為階段性的項目在進入 Java 9 之后並沒有被長期維護,缺乏必要的調優(反觀AppCDS一直在迭代優化)

4)JDK16 中被刪除

JDK-8255616:Disable AOT and Graal in Oracle OpenJDK

在 OpenJDK16 發布前夕,Oracle正式決定不再維護這項技術:

We haven't seen much use of these features, and the effort required to support and enhance them is significant.

其根本原因還是這項基於缺乏必要的優化和維護。而對於 AOT 相關的未來的規划,只能從只言片語中推測將來Java的AOT 有兩種技術方向:

  • 在 OpenJDK 的 C2 基礎上做 AOT
  • 在 GraalVM 的 native-image 上支持完整的 Java 語言特性,需要 AOT 的用戶逐漸從 OpenJDK 過渡到native-image

上述的兩個技術方向都沒法在短期內看到進展,因此 Dragonwell 的技術方向是讓現有的 JEP295 更好地工作,為用戶帶來極致的啟動性能。

5)Dragonwell 上的快速啟動
Dragonwell 的快速啟動特性攻關了 AppCDS、AOT 編譯技術上的弱點,並基於 HeapArchive 機制研發了類提前初始化特性。這些特性將 JVM 可見的應用啟動耗時幾乎全部消除。

此外,因為上述幾項技術都符合 trace-dump-replay 的使用模式,Dragonwell 將上述啟動加速技術統一了流程,並且集成到了 SAE 產品中。

SAE x Dragonwell : Serverless with Java 啟動加速最佳實踐

有了好的食材,還需要相匹配的佐料,以及一位烹飪大師。

將 Dragonwell 的啟動加速技術和和以彈性著稱的 Serverless 技術相結合更相得益彰,同時共同落地在微服務應用的全生命周期管理中,才能發揮他們縮短應用端到端啟動時間的作用,因此 Dragonwell 選擇了 SAE 來落地其啟動加速技術。

SAE (Serverless 應用引擎)是首款面向 Serverless 的 PaaS 平台,他可以:

 
  • Java 軟件包部署:零代碼改造享受微服務能力,降低研發成本
  • Serverless 極致彈性:資源免運維,快速擴容應用實例, 降低運維與學習成本

1、難點分析

通過分析,我們發現微服務的用戶在應用啟動層面面臨着一些難題:

  • 軟件包大:幾百 MB 甚至 GB 級別
  • 依賴包多:上百個依賴包,幾千個 Class
  • 加載耗時:從磁盤加載依賴包,再到 Class 按需加載,最高可占啟動耗時的一半

借助 Dragonwell 快速啟動能力,SAE 為 Serverless Java 應用提供了一套,讓應用盡可能加速啟動的最佳實踐,讓開發者更專注於業務開發:

  • Java 環境 + JAR/WAR 軟件包部署:集成 Dragonwell 11 ,提供加速啟動環境
  • JVM 快捷設置:支持一鍵開啟快速啟動,簡化操作
  • NAS 網盤:支持跨實例加速,在新包部署時,加速新啟動實例/分批發布啟動速度

image.png

2、加速效果

我們選擇一些微服務、復雜依賴的業務場景典型 Demo 或內部應用,測試啟動效果,發現應用普遍能降低 5%~45% 的啟動耗時。若應用啟動,存在下列場景,會有明顯加速效果:

  • 類加載多(spring-petclinic 啟動加載約 12000+ classes)
  • 依賴外部數據越少

image.png

3、客戶案例
  • 阿里巴巴搜索推薦 Serverless 平台

阿里內部的搜索推薦 Serverless 平台通過類加載隔離機制,將多個業務的合並部署在同一個 Java 虛擬機中。調度系統會按需地將業務代碼合並部署到空閑的容器中,讓多個業務可以共享同一個資源池,大大提高部署密度和整體的 CPU 使用率。

由於要支撐大量不同的業務研發運行,平台本身需要提供足夠豐富的功能,如緩存、RPC調用。因此搜索推薦Serverless 平台的每個 JVM 都需要拉起類似 Pandora Boot 的中間件隔離容器,這將加載大量的類,拖累了平台自身的啟動速度。當突增的需求進入,調度系統需要拉起更多容器以供業務代碼部署,此時容器本身的啟動時間就顯得尤為重要。

基於 Dragonwell 的快速啟動技術,搜索推薦平台在預發布環境會執行 AppCDS、Jarindex 等優化,將產生的 archive 文件打入容器鏡像中,這樣每一個容器在啟動時都能享受加速,減少約30%的啟動耗時。

  • 潮牌秒殺SAE極致彈性

某外部客戶,借助 SAE 提供的 Jar 包部署與 Dragonwell 11,快速迭代上線了某潮牌商場 App。

在面對大促秒殺時,借助 SAE Serverless 極致彈性,與應用指標 QPS RT 指標彈性能力,輕松面對 10 倍以上快速擴容需求;同時一鍵開啟 Dragonwell 增強的 AppCDS 啟動加速能力,降低 Java 應用 20% 以上啟動耗時,進一步加速應用啟動,保證業務平穩健康運行。

總結

Dragonwell 上的快速啟動技術方向上完全基於 OpenJDK 社區的工作,對各項功能進行了細致的優化與 bugfix,並降低了上手的難度。這樣做既保證了對標准的兼容,避免內部定制,也能夠為開源社區做出貢獻。

作為基礎軟件,Dragonwell 只能生成/使用磁盤上的 archive 文件。結合 SAE 對 Dragonwell 的無縫集成,JVM 配置、archive 文件的分發都被自動化。客戶可以輕松享受應用加速帶來的技術紅利。

作者介紹:

梁希,來自阿里雲 Java 虛擬機團隊,負責 Java Runtime 方向。主導了 Java 協程、啟動優化等技術的研發和大規模落地。

代序,來自阿里雲 SAE 團隊,負責 Runtime 演進、 彈性能力與效率方向。主導應用彈性、Java 加速、鏡像加速等技術的研發落地。

原文鏈接
本文為阿里雲原創內容,未經允許不得轉載。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM