1. 程式人生 > 其它 >探究 Java 應用的啟動速度優化

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

簡介:Java 的執行效率非常高,約為最快的C語言的一半。這在主流的程式語言中,僅次於C、Rust 和 C++。但在高效能的背後,Java 的啟動效能差也令人印象深刻,大家印象中的 Java 笨重緩慢的印象也大多來源於此。高效能和快啟動速度似乎有一些相悖,本文將和大家一起探究兩者是否可以兼得。

作者 | 樑希
來源 | 阿里技術公眾號

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

Java 作為一門面向物件程式語言,在效能方面的卓越表現獨樹一幟。

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

從表中,我們可以看到,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

    • 通過 JarIndex 解決 JAR 包遍歷問題,不過該技術過於古老,很難在現代的囊括了tomcat、fatJar的專案裡使用起來
    • 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|
|             |         +-------------------------+
+-------------+

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 技術就力不從心了。

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*等元資訊,每次執行之間都存在一定的變化,因此需要更加複雜的機智來獲得高效的物件持久化。

Heap Archive簡介

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

物件圖被提前被構建好後放進archive,我們將這個階段稱為dump;而使用archive裡的資料稱為執行時。dump和執行時通常不是一個程序,但在某些場景下也可以是同一個程序。

回憶下使用AppCDS後的記憶體佈局,物件的Klass*指標指向了SharedArchive中的的資料。AppCDS對InstanceKlass這個元資訊進行了持久化,如果想要複用持久化的物件,那麼物件頭的型別指標必須也要指向一塊被持久化過的元資訊,因此HeapArchive技術是依賴AppCDS的。

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

上圖是允許的引用關係:

  • Closed Archive

    • 不允許引用Open Archive 和Heap中的物件
    • 可以引用Closed Archive內部的物件
    • 只讀,不可寫
  • Open Archive

    • 可以引用任何物件
    • 可寫

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

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

利用 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的方式。

AOT特性初體驗

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

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 網盤:支援跨例項加速,在新包部署時,加速新啟動例項/分批發布啟動速度

2 加速效果

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

  • 類載入多(spring-petclinic 啟動載入約 12000+ classes)
  • 依賴外部資料越少

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 加速、映象加速等技術的研發落地。

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