Java JVM 虛擬機編譯器性能增強優化技術

專欄原創出處:github-源筆記文件 github-源碼 ,歡迎 Star,轉載請附上原文出處鏈接和本聲明。

Java JVM-虛擬機專欄系列筆記,系統性學習可訪問個人覆盤筆記-技術博客 Java JVM-虛擬機

前言

介紹 Oracle HotSpot 虛擬機技術的性能增強部分案例。

優化技術手段非常之多,可參考官方列出 openjdk-優化技術概覽

字符串壓縮

字符串壓縮功能( JEP 254: Compact Strings )是爲了節省內部空間。

字符串是 Java 堆用法的主要組成部分,並且大多數 String 對象僅包含 Latin-1 字符。此類字符僅需要存儲一個字節。
結果,String 沒有使用對象內部字符數組中一半的空間。JDK 9 中引入的壓縮字符串功能減少了內存佔用,並減少了垃圾回收活動。如果您在應用程序中發現性能下降問題,則可以禁用此功能。

它將 String 類的內部表示形式從 UTF-16(兩個字節)字符數組修改爲帶有附加字段以標識字符編碼的字節數組。其他字符串相關的類,如 AbstractStringBuilder,StringBuilder 和 StringBuffer 更新使用類似的內部表示。

在 JDK 9 中,默認情況下啓用了壓縮字符串功能。因此,String 該類將每個字符的字符存儲爲一個字節,編碼爲 Latin-1。附加字符編碼字段指示所使用的編碼。HotSpot VM 字符串內在函數已更新和優化以支持內部表示。

可以通過 -XX:-CompactStrings 在 java 命令行中使用該標誌來禁用緊湊字符串功能。禁用此功能後,String 該類將字符存儲爲兩個字節(編碼爲 UTF-16),並且將 HotSpot VM 字符串內部函數存儲爲使用 UTF-16 編碼。

分層編譯

由於即時編譯器編譯本地代碼需要佔用程序運行時間,通常要編譯出優化程度越高的代碼,所花費的時間便會越長;而且想要編譯出優化程度更高的代碼,解釋器可能還要替編譯器收集性能監控信息,這對解釋執行階段的速度也有所影響。

爲了在程序啓動響應速度與運行效率之間達到最佳平衡,HotSpot 虛擬機在編譯子系統中加入了分層編譯的功能:

  • 第 0 層。程序純解釋執行,並且解釋器不開啓性能監控功能(Profiling)。

  • 第 1 層。使用客戶端編譯器將字節碼編譯爲本地代碼來運行,進行簡單可靠的穩定優化,不開啓性能監控功能。

  • 第 2 層。仍然使用客戶端編譯器執行,僅開啓方法及回邊次數統計等有限的性能監控功能。

  • 第 3 層。仍然使用客戶端編譯器執行,開啓全部性能監控,除了第 2 層的統計信息外,還會收集如分支跳轉、虛方法調用版本等全部的統計信息。

  • 第 4 層。使用服務端編譯器將字節碼編譯爲本地代碼,相比起客戶端編譯器,服務端編譯器會啓用更多編譯耗時更長的優化,還會根據性能監控信息進行一些不可靠的激進優化。

以上層次並不是固定不變的,根據不同的運行參數和版本,虛擬機可以調整分層的數量。

通過 -XX:-TieredCompilation 在 java 命令中使用該標誌來禁用分層編譯。

Graal:基於 Java 的 JIT 編譯器

Graal 是用 Java 編寫的高性能優化的即時的編譯器,與 Java HotSpot VM 集成在一起。是一個可自定義的動態編譯器,我們可以從 Java 調用它。

要啓用 Graal 作爲 JIT 編譯器,VM 參數配置:

-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler

注意:Graal 是一項實驗性功能,僅在 Linux-x64 上受支持。

提前編譯

提前(AOT,JEP 295: Ahead-of-Time Compilation )編譯通過在啓動虛擬機之前將 Java 類編譯爲本地代碼來縮短大小型 Java 應用程序的啓動時間。

儘管即時(JIT)編譯器速度很快,但是編譯大型 Java 程序仍需要時間。此外,當反覆解釋某些未編譯的 Java 方法時,性能會受到影響。AOT 解決了這些問題。

語法如下:

jaotc <options> <list of classes or jar files>
jaotc <options> <--module name>

示例:

jaotc --output libHelloWorld.so HelloWorld.class
jaotc --output libjava.base.so --module java.base

java -XX:AOTLibrary=./libHelloWorld.so,./libjava.base.so HelloWorld ————執行應用程序時指定生成的 AOT 庫

注意:提前(AOT)編譯是一項實驗性功能,僅在 Linux-x64 上受支持。

壓縮普通對象指針

在 HotSpot 中,oop 或普通對象指針是指向對象的託管指針。openjdk-CompressedOops

1. 爲什麼要進行指針壓縮?

32 位內最多可以表示 4GB,64 位地址分爲堆的基地址+偏移量,當堆內存 <32GB 時候,在壓縮過程中,把偏移量/8 後保存到 32 位地址。在解壓再把 32 位地址放大 8 倍,所以啓用 CompressedOops 的條件是堆內存要在 4GB*8=32GB 以內。

所以壓縮指針之所以能改善性能,是因爲它通過對齊(Alignment),還有偏移量(Offset)將 64 位指針壓縮成 32 位。換言之,性能提高是因爲使用了更小更節省空間的壓縮指針而不是完整長度的 64 位指針,CPU 緩存使用率得到改善,應用程序也能執行得更快。

壓縮與解壓過程,指針向右移動 3 位放大 8 倍解壓。因此 oops 最後 3 位始終爲 0

2. 哪些信息不會被壓縮?

壓縮也不是萬能的,針對一些特殊類型的指針,JVM 是不會優化的。 比如指向 PermGen 的 Class 對象指針,本地變量,堆棧元素,入參,返回值,NULL 指針不會被壓縮。

3. 零基壓縮優化

零基壓縮是針對壓解壓動作的進一步優化。 它通過改變正常指針的隨機地址分配特性,強制堆地址從零開始分配(需要 OS 支持),進一步提高了壓解壓效率。

要啓用零基壓縮,你分配給 JVM 的內存大小必須控制在 4G 以上,32G 以下。如果 GC 堆大小在 4G 以下,直接砍掉高 32 位。

4. 指針壓縮配置及版本支持
Java SE 6u23 和更高版本默認情況下支持並啓用壓縮的 oops。

在 Java SE 7 中,默認情況下,-Xmx 未指定時對 64 位 JVM 進程以及-Xmx 小於 32 GB 的值啓用壓縮 oop 。

對於早於 6u23 發行版的 JDK 版本,將該 -XX:+UseCompressedOops 標誌與 java 命令一起使用以啓用壓縮的 oops。

逃逸分析

逃逸分析( openjdk-EscapeAnalysis )的基本原理是:

  • 分析對象動態作用域,當一個對象在方法裏面被定義後,它可能被外部方法所引用,例如作爲調用參數傳遞到其他方法中,這種稱爲方法逃逸;
  • 甚至還有可能被外部線程訪問到,譬如賦值給可以在其他線程中訪問的實例變量,這種稱爲線程逃逸;

根據不同的逃逸程度使用不同的優化手段:

  • 棧上分配:直接在棧幀上進行對象分配。(不支持線程逃逸)
  • 標量替換:類似 java 裏面的基礎類型不能進一步分解了,被稱爲標量,如果還能被分解稱爲聚量。
  • 同步狀態消除:如果確定一個變量僅被一個線程訪問,直接取消同步狀態。
@Data
class Person {
    private String name;
    private int age;

    public Person(String personName, int personAge) {
        name = personName;
        age = personAge;
    }

    public Person(Person p) {
        this(p.getName(), p.getAge());
    }
}

class Employee {
    private Person person;

    // person 可能被修改,如果進一步分析調用沒有修改 person ,可以直接使用原始的對象
    public Person getPerson() {
        return new Person(person);
    }

    public void printEmployeeDetail(Employee emp) {
        Person person = emp.getPerson();
        // person 不會被修改,我們只需要 person.name/age 兩個變量即可
        System.out.println("Employee's name: " + person.getName() + "; age: " + person.getAge());
    }
    
    public void printEmployeeDetail() {
        Person person = new Person("name", 18);
        String name = person.getName();
        // person 不會逃逸出方法,可以直接優化爲 String name = "name"
    }
}

方法內聯

public static void foo(Object obj) {
     if (obj != null) {
         System.out.println("do something");
     }
 }

public static void testInline(String[] args) {
     Object obj = null;
     foo(obj);
}

我們可以看到 testInline 方法裏面的都是無用的代碼,但是單獨看兩個方法時他們又是有意思的。

有關內聯的實現此處不進行敘述,感興趣的可以參考文章末位《參考內容》

公共子表達式消除

int d = (c * b) * 12 + a + (a + b * c); -- 原始表達式
    
int d = E * 12 + a + (a + E);   -- 優化爲

int d = E * 13 + a + a;         -- 另一種優化(代數簡化)

數組邊界檢查消除

if (foo != null) {
    return foo.value;
} else {
    throw new NullPointException();
}

// 可能優化爲

try {
    return foo.value;
} catch (segment_fault) {
    uncommon_trap();
}

虛擬機會註冊一個 Segment Fault 信號的異常處理器(僞代碼中的 uncommon_trap(),務必注意這裏是指進程層面的異常處理器,並非真的 Java 的 try-catch 語句的異常處理器),
這樣當 foo 不爲空的時候,對 value 的訪問是不會有任何額外對 foo 判空的開銷的,而代價就是當 foo 真的爲空時,必須轉到異常處理器中恢復中斷並拋出 NullPointException 異常。

進入異常處理器的過程涉及進程從用戶態轉到內核態中處理的過程,結束後會再回到用戶態,速度遠比一次判空檢查要慢得多。當 foo 極少爲空的時候,隱式異常優化是值得的,但假如 foo 經常爲空,這樣的優化反而會讓程序更慢。
幸好 HotSpot 虛擬機足夠聰明,它會根據運行期收集到的性能監控信息自動選擇最合適的方案。

參考

更多相關文章推薦

發佈了270 篇原創文章 · 獲贊 55 · 訪問量 38萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章