JVM筆記-後端編譯與優化

1. 概述

前面分析了 JVM 的前端編譯器 Javac,本文分析後端編譯器:即時編譯器(JIT 編譯器)和提前編譯器(AOT 編譯器)。

其實二者都不是 JVM 必需的組成部分。但是,後端編譯器編譯性能的好壞、代碼優化質量的高低,卻是衡量一款商用 JVM 優秀與否的關鍵指標之一,也是其核心所在。

2. 即時編譯器

目前主流的兩款商用 JVM(HotSpot、OpenJ9)中,Java 程序最初都是通過「解釋器(Interpreter)」解釋執行的,當 JVM 發現某個方法或代碼塊的執行特別頻繁,就會認爲它們是“熱點代碼(Hot Spot Code)”。

爲了提高熱點代碼的執行效率,JVM 會在「運行時」把這部分代碼編譯成本地機器碼,並用各種手段去優化代碼。運行時完成這個任務的後端編譯器被稱爲「即時編譯器」。

這種機制可以類比我們平時調用接口查詢數據:

  • 某個接口如果查詢比較簡單、且訪問量較少,就沒必要使用緩存,直接查詢數據庫就行;

  • 當該接口訪問量很大時,爲了提高查詢效率,可以使用緩存提高效率。

HotSpot VM 內置了三個即時編譯器,分別爲:

  • 客戶端編譯器(Client Compiler),簡稱 C1 編譯器。

  • 服務端編譯器(Server Compiler),簡稱 C2 編譯器,或 Opto 編譯器。

  • Graal 編譯器(JDK 10 出現,長期目標是替代 C2 編譯器)。

2.1 解釋器與編譯器

2.1.1 執行流程

解釋器的執行流程大致如下:

輸入的代碼 -> [ 解釋器 解釋執行 ] -> 執行結果

即時編譯器的執行流程大致如下:

輸入的代碼 -> [ 編譯器 編譯 ] -> 編譯後的代碼 -> [ 執行 ] -> 執行結果

此處引用了 RednaxelaFX 大佬在知乎的回答,鏈接:https://www.zhihu.com/question/37389356/answer/73820511 。若想了解更深層次的內容,要去看編譯原理相關的書了。

2.1.2 對比分析

目前主流的商用 JVM 內部都同時包含解釋器與編譯器,二者各有優勢:

  • 程序需要迅速啓動和執行時,解釋器可以省去編譯時間,立即執行。

  • 程序啓動後,編譯器逐漸發揮作用,把越來越多的代碼編譯成本地代碼,可以減少解釋器的中間消耗,提高執行效率。

  • 若運行環境的內存資源限制較大,可使用解釋器執行節約內存;反之可使用編譯執行來提升效率。

總結起來就是:

  1. 解釋器啓動較快,佔用內存較小,但是執行效率稍低。

  2. 編譯器啓動較慢,佔用內存較大,但執行效率較高。

此外,解釋器還可以作爲編譯器激進優化時後備的“逃生門”,也就是給編譯器來“兜底”,反之則不行。

凡事有利弊。這裏仍以查詢接口爲例做類比:

  • 解釋執行可以理解爲直接查詢數據庫,也就是不使用緩存。程序啓動起來比較快(無需連接緩存服務器),但後面運行的時候由於每次都要去查數據庫,會有磁盤 IO 開銷,會相對慢一些。

  • 而編譯執行就相當於使用了緩存。雖然啓動會稍慢一些(需要連接緩存服務器,初次查詢時既要查詢數據庫,又要存入緩存),而且需要額外的開銷(需要緩存服務器),但是後續的查詢效率會提高很多,因爲可以直接從緩存獲取,不必再查詢數據庫。

因此,使用緩存其實就是“空間換時間”,編譯器與解釋器也可以類比來理解。

2.1.3 運行模式

解釋器與編譯器配合使用的方式在虛擬機中被稱爲“混合模式(Mixed Mode)”,比如我們查看 JDK 版本時:

$ java -version
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

最後面的 mixed mode 就表示混合模式。

此外,也可以使用參數 -Xint 強制虛擬機運行於“解釋模式(Interpreter Mode)”:

$ java -Xint -version
java version ...
...
... (build 25.191-b12, interpreted mode)

還可以使用參數 -Xcomp 強制虛擬機運行於“編譯模式(Compiled Mode)”:

$ java -Xcomp -version
java version ...
...
... (build 25.191-b12, compiled mode)

2.2 分層編譯

JIT 編譯器的編譯過程是在「運行期」,這就不可避免會佔用應用程序的資源。而且,想要把代碼優化得更好,就要花費更多的時間。而且可能還需要解釋器幫忙收集一些性能監控信息,又降低了解釋器的效率。這可怎麼辦?

那找個折衷的方案?其實就是分層編譯(Tiered Compilation)。

分了哪幾個層次呢?主要包括:

  1. 程序純解釋執行,且解釋器不開啓性能監控功能。

  2. 使用 C1 編譯器將字節碼編譯爲本地代碼來執行,進行簡單可靠的穩定優化,不開啓性能監控功能。

  3. 使用 C1 編譯器執行,僅開啓一部分性能監控功能(方法及回邊次數統計等)。

  4. 使用 C1 編譯器執行,開啓全部性能監控(在第二層之外,還會收集如分支跳轉、虛方法調用版本等全部的統計信息)。

  5. 使用 C2 編譯器將字節碼編譯爲本地代碼(相比 C1 編譯器,C2 編譯器會啓用更多編譯耗時更長的優化,還會根據性能監控信息進行一些不可靠的激進優化)。

這幾個層次並非固定不變,可以根據不同的運行參數靈活使用。

2.3 熱點代碼

運行時會被即時編譯器編譯的目標是“熱點代碼”,主要包括下面兩類:

  1. 被多次調用的方法。

  2. 被多次執行的循環體。

前者比較容易理解:一個方法被調用的次數多了,自然就成了熱點代碼。

後者是什麼場景呢?當一個方法被調用的次數雖然不多,但方法體內部存在循環次數較多的循環體。這種代碼也是“熱點代碼”(可以理解爲方法的一部分是熱點代碼)。比如:

public void test() {
  // 一些其他代碼...
  
  // 即便 test() 方法被調用的次數不多,但當 N 足夠大時,該部分代碼也會成爲“熱點代碼”
  for (int i=0; i<N; i++) {
    // 執行一些操作...
  }
  
  // 一些其他代碼...
}

前者是 JVM 標準的即時編譯。

至於後者,雖然熱點代碼只是方法的一部分,但編譯器仍會把「整個方法」作爲編譯對象,只是入口不同(並非從方法的第一行代碼開始)。由於該情況發生在方法執行的過程中,也被稱爲棧上替換(On Stack Replacement,OSR)。也就是方法的棧幀還在棧上,但方法已經被替換了(“狸貓換太子”)。

PS: 每個方法被執行時,虛擬機棧都會創建一個棧幀(Stack Frame)用於存儲局部變量表、操作數棧等信息。每個方法從被調用直至執行完畢的過程,就對應着一個棧幀在虛擬機棧中從入棧到出棧的過程。

2.4 熱點探測

關於熱點代碼的判定,前面一直提的都是“多次”,到底多少次才叫“多”呢?這個問題不僅要“定性”,還要“定量”。

要判定一段代碼是不是熱點代碼、是否觸發即時編譯的行爲稱爲“熱點探測(Hot Spot Code Detection)”。

2.4.1 定量方法

熱點探測的主流方法有以下兩種:

  • 基於採樣的熱點探測(Sample Based Hot Spot Code Detection)

就是每隔一段時間去檢查一下所有線程的調用棧頂,若發現某個(或某些)方法經常出現在棧頂,該方法就會被認爲是“熱點代碼”。J9 虛擬機使用過該方法。

這種做法的優缺點如下:

  1. 優點:實現簡單高效,而且可以通過堆棧信息獲取到方法之間的調用關係;

  2. 缺點:難以精確的確定方法熱度,容易受到線程阻塞的干擾(即方法阻塞時可能長時間處於棧頂,可能產生誤判)。

  • 基於計數器的熱點探測(Counter Based Hot Spot Code Detection)

爲每個方法(或代碼塊)建立計數器來統計方法的執行次數,當次數超過一定的閾值就認爲是“熱點代碼”。HotSpot 虛擬機就是使用該方法進行探測的。

該方法的同樣也有優缺點:

  1. 優點:統計結果更加精確嚴謹;

  2. 缺點:統計起來稍麻煩(要爲每個方法建立並維護計數器),而且不能直接獲取到方法的調用關係。

2.4.2 兩種計數器

HotSpot 爲每個方法準備了兩類計數器,下面分別介紹。

2.4.2.1 方法調用計數器

方法調用計數器(Invocation Counter)用來統計方法被調用的次數。它在客戶端和服務端模式下的默認閾值分別爲 1500 次和 10000 次。

該計數器觸發即時編譯的流程圖如下:

PS: 方法調用計數器統計的並非方法被調用的絕對次數,而是是一個相對的執行頻率。

什麼意思呢?

也就是在一段時間內,如果方法的調用次數未到達閾值,計數器就會減少爲原先的一半。該過程被稱爲熱度衰減(Counter Decay),這段時間則被稱爲半衰週期(Counter Half Life Time)。

比如,若閾值是 10000,半衰週期是 1 小時。如果在 1 小時內,某個方法被調用了 8000 次(未達到即時編譯的條件),計數器就會認爲該方法沒那麼“熱”,就要給它“潑冷水”,把次數降爲 4000 (純屬個人理解)。

當然,有 JVM 參數可以對此進行調整,如下:

# 指定計數器的閾值
-XX:CompileThreshold

# 關閉熱度衰減
-XX:-UseCounterDecay

# 設置半衰期時間(秒)
-XX:CounterHalfLifeTime

2.4.2.2 回邊計數器

回邊計數器(Back Edge Counter)用來統計方法中循環體代碼執行的次數(字節碼中遇到控制流向後跳轉的指令稱爲“回邊”),目的是爲了觸發棧上替換。

回邊計數器觸發即時編譯的流程如下:

與此相關的幾個 JVM 參數:

# OSR 比率,默認 933
-XX:OnStackReplacePercentage

# 解釋器監控比率,默認 33
-XX:InterpreterProfilePercentage

3. 提前編譯器

對提前編譯的研究主要有下面兩條分支。

3.1 靜態翻譯

第一條就是在程序運行之前,把程序代碼“翻譯”成機器碼。

JIT 編譯器的主要缺點在於:它是在「運行期」進行編譯的。這就不可避免地要佔用應用程序的運行資源(CPU、內存等),進而影響程序的執行性能。

而這種提前編譯就是把這個編譯階段放到程序的「運行期」之前,這樣就可以不佔用應用程序的資源。

3.2 即時編譯緩存

其實就是把 JIT 編譯器要做的編譯工作先做好,並保存下來,當觸發 JIT 編譯時,直接調用這裏的代碼就好了。本質上就是給 JIT 編譯做緩存。

這種方式也被稱爲動態提前編譯(Dynamic AOT)或者即時編譯緩存(JIT Caching)。

3.3 即時編譯&提前編譯

從上面對提前編譯器的分析來看,似乎提前編譯比 JIT 編譯運行效率更高。那它就沒缺點了嗎?當然不是,否則還要 JIT 編譯器幹嘛。

相比提前編譯器,JIT 編譯器的優勢在哪裏呢?

  • 性能分析制導優化

解釋器或客戶端編譯器在運行的過程中,會不斷收集性能監控信息(方法版本選擇、條件判斷等),這些信息可以幫助 JIT 編譯器對代碼進行集中優化。

這一點在靜態分析時是很難做到的。

  • 激進預測性優化

也就是 JIT 編譯器可以進行一些稍微“激進”的優化行爲,即便這些行爲失敗了,也有解釋器可以“兜底”。而靜態優化就做不到了。

此外,提前編譯還會破壞 Java 平臺中立性、產生字節膨脹等問題。

4. 編譯器優化技術

前面分析了 JIT 編譯器和提前編譯器,它們做的都是“翻譯”工作。但關鍵問題不在於“能不能”翻譯,而是翻譯的“好不好”。也就是編譯出來的代碼質量高不高。

那麼,它們用什麼手段來提升“翻譯”的質量呢?

HotSpot VM 的 JIT 編譯器使用了不少優化技術(可參考:https://wiki.openjdk.java.net/display/HotSpot/PerformanceTacticIndex),下面介紹幾個非常重要的。

PS: JIT 編譯器對代碼的優化,這裏的“代碼”並非我們編寫的源代碼,而是被編譯後的字節碼或者機器碼。畢竟已經通過類加載器把 Class 文件加載到 JVM 了。

4.1 方法內聯

方法內聯是編譯器最重要的優化手段,業內戲稱爲“優化之母”。是其他優化手段的基礎。

它的行爲理解起來其實很簡單:就是在方法調用中,把目標方法的代碼“複製”到調用的方法之中,避免發生真實的方法調用。示例代碼如下:

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

public static void testInline() {
  Object obj = null;
  foo(obj);
}

該段代碼實際是無用代碼(Dead Code),經過方法內聯(把 foo 方法的代碼代入到 testInline 方法中)之後可以發現。

但若不做內聯,後續即便進行了無用代碼消除的優化,也無法發現該無用代碼。

4.2 逃逸分析

逃逸分析(Escape Analysis)是目前 JVM 中比較前沿的優化技術。但它並不直接優化代碼,而是一種爲其他優化措施提供依據的分析技術。

它的基本原理是分析對象的動態作用域,當一個對象在方法中被定義後,按照逃逸程度從低到高可分爲:

  • 不逃逸:對象只能在本方法內使用。

  • 方法逃逸:對象可能被外部方法引用(例如作爲調用參數傳遞到其他方法)。

  • 線程逃逸:對象可能被外部線程訪問到(例如賦值給線程共享的變量)。

若一個對象未發生逃逸,或者逃逸程度較低,可以爲這個對象採取不同程度的優化。

4.2.1 棧上分配

JVM 中,對象的內存空間分配在堆上似乎是一個常識。當對象不再使用時,垃圾收集器會將其內存空間回收,這個過程其實是要消耗大量資源的。

假如……把對象的內存空間分配到棧上呢?

What ???這簡直是顛覆認知!

但是,不妨沿着這個思路考慮一下:如果這樣做了有什麼好處呢?

這樣一來對象佔用的內存空間就會隨着棧幀出棧而銷燬,不必再由垃圾收集器費時費力地去回收了,可以節省不少資源。這樣一想似乎也是不是不可以。

這就是所謂的棧上分配(Stack Allocations),它可以支持「方法逃逸」,但不支持線程逃逸。

PS:由於複雜度等原因,HotSpot 目前暫未做這項優化,但有些 JVM(例如 Excelsior JET)已經在使用了。

4.2.2 標量替換

先看一下標量(Scalar)和聚合量(Aggregate)的概念:

  • 標量:無法再分解爲更小數據的數據,例如 JVM 中的原始數據類型(int、long、reference 等)。

  • 聚合量:可以繼續分解的數據,例如 Java 中的對象。

所謂「標量替換(Scalar Replacement)」,就是根據實際訪問情況,將一個對象“拆解”開,把用到的成員變量恢復爲原始類型來訪問。

簡單來說,就是把聚合量替換爲標量。

若一個對象不會逃逸出「方法」,且可以被拆散,那麼程序真正執行時就可能不去創建這個對象,而是直接創建它的若干個被該方法使用的成員變量代替。

還有這操作?

其實細想一下,這個操作跟前面的「棧上分配」還是有些類似的:棧上分配的是對象,而標量替換則是在棧上分配對象的一部分成員變量,連對象都懶得創建了。

4.2.3 同步消除

線程同步本身相對耗時,如果逃逸分析能夠確定一個變量不會逃逸出線程,則該變量的讀寫就不會有線程安全問題,對該變量的同步措施就可以安全的消除了。

換句話說,如果對線程安全的數據加了鎖,JVM 就可以把它優化消除。示例代碼如下:

public void t1() {
    // 變量 o 不會逃逸出線程。因此,對它加的鎖就可以被消除
    Object o = new Object();
    synchronized (o) {
        System.out.println(o.toString());
    }
}

4.3 代碼示例

上面介紹了方法內聯和逃逸分析的相關優化手段,這裏以僞代碼的形式演示它們優化的過程。

  • 原始代碼

// 原始代碼
public class Point {
  private int x;
  private int y;
  // getter/setter ...
}

public int test(int x) {
  int xx = x + 2;
  Point p = new Point(xx, 42);
  return p.getX();
}
  • 方法內聯

首先,將 Point 的構造函數和 getX() 方法進行內聯:

// 方法內聯優化後
public int test(int x) {
  int xx = x + 2;
  Point p = point_memory_alloc(); // 堆中分配內存示意方法
  p.x = xx; // Point 構造函數內聯後
  p.y = 42;
  return p.x; // p.getX() 方法內聯後
}
  • 逃逸分析

經過逃逸分析,發現 Point 對象不會逃逸出 test() 方法,可以進行「標量替換」,如下:

// 標量替換優化後
public int test(int x) {
  int xx = x + 2;
  int px = xx; // 標量替換
  int py = 42;
  return px;
}
  • 無效代碼消除

經過數據流分析,發現變量 py 對方法不會造成任何影響,可以進行消除,如下:

// 無效代碼消除優化後
public int test(int x) {
  return x + 2;
}

可以看到,原始代碼經過一系列的優化,最終結果簡潔了很多。

更少的代碼也意味着佔用的內存空間更少,執行起來效率也更高,這也是優化的意義所在。

4.3 公共子表達式消除

4.3.1 公共子表達式

所謂公共子表達式,就是當有一個表達式 E 在以前被計算過,而且下次再遇到的時候 E 的所有變量都未改變,則這次 E 的出現就被稱爲「公共子表達式」。也就是不必再花功夫重新計算,直接拿來用就好了。有木有“緩存”的感覺?

根據作用域,公共子表達式的消除可分爲兩種:局部公共子表達式消除和全局公共子表達式消除。

4.3.2 示例代碼

若有如下代碼:

public class Test {
    public int t1() {
        int a=1, b=2, c=3;
        int d = (c * b) * 12 + a + (a + b * c);
        return d;
    }
}

Javac 編譯後生成的字節碼如下:

  public int t1();
    descriptor: ()I
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=5, args_size=1
         # ...
         6: iload_3
         7: iload_2
         8: imul    # 計算 b*c
         9: bipush        12
        11: imul    # 計算 (c * b) * 12
        12: iload_1
        13: iadd    # 計算 (c * b) * 12 + a
        14: iload_1
        15: iload_2
        16: iload_3
        17: imul    # 計算 b*c
        18: iadd    # 計算 (a + b * c)
        19: iadd    # 計算 (c * b) * 12 + a + (a + b * c)
        20: istore        4
        22: iload         4
        24: ireturn
        # ...

Javac 編譯器並未做任何優化,每次都會重新計算。

這段代碼進入即時編譯器後,將進行如下優化:

編譯器檢測到 c * b 與 b * c 是一樣的表達式,且在計算期間 b 和 c 的值不變,因此:

int d = E * 12 + a + (a + E);

此時,編譯器還可能進行代數化簡(Algebraic Simplification),如下:

int d = E * 13 + a + a;

這樣計算起來就可以節省一些時間。

4.4 數組邊界檢查消除

假如有一個數組 array,當我們訪問數組下標在 [0, array.length) 範圍之外的元素時,就會拋出 java.lang.ArrayIndexOutOfBoundsException 異常,也就是數組越界了,例如:

public void test1() {
  String[] array = new String[]{"a", "b", "c"};
  // 數組越界
  String s = array[3];
}

其實是 JVM 在執行的時候隱含了一次邊界判斷(運行期)。當這樣的判斷很多時,肯定對性能有一定的影響。

但這個判斷看起來似乎又是必要的,就不能優化了嗎?

實際上也並非不能,如果把這些判斷放在編譯期呢?代碼在編譯的時候,就根據控制流分析(可參考前文的前端編譯)是否會產生數組越界,那麼在運行期間不是就不用判斷了嗎?

5. 小結

本文主要分析了即時編譯器和提前編譯器,主要內容梳理如下:

相關閱讀:

JVM筆記-前端編譯與優化

JVM筆記-類加載機制

JVM筆記-運行時內存區域劃分


本文內容就到這裏,希望對大家有所幫助~

【覺得不錯,鼓勵一下~】

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