1. 程式人生 > 實用技巧 >深入理解java虛擬機器筆記Chapter11

深入理解java虛擬機器筆記Chapter11

執行期優化

即時編譯

什麼是即時編譯?

  • 當虛擬機發現某個方法或某段程式碼執行的特別頻繁時,會把這段程式碼認為成熱點程式碼;
  • 在執行時,虛擬機器會將這段程式碼編譯成平臺相關的機器碼,並進行各種層次的優化。

HotSpot 虛擬機器內的即時編譯器運作過程

我們主要通過以下 5 個問題來了解 HotSpot 虛擬機器的即時編譯器。

為什麼要使用直譯器與編譯器並存的架構?

  • 直譯器的優點:可以提高程式的響應速度(省去了編譯的時間),並且節約記憶體。
  • 編譯器的優點:可以提高執行效率。
  • 虛擬機器引數設定:
    • 強制運行於解析模式:-Xint,編譯器完全不工作;
    • 強制運行於編譯模式:-Xcomp
      ,當編譯器編譯失敗時,解釋執行還是會介入的。

為什麼虛擬機器要實現兩個不同的 JIT 編譯器?

  • Client Compiler(C1):不激進優化;
  • Server Compiler(C2):激進優化,如果激進優化不成立,再退回為解釋執行或者 C1 編譯器執行。

什麼是虛擬機器的分層編譯?

分層編譯就是根據編譯器編譯、優化的規模與耗時,劃分出不同的編譯層次,在程式碼執行的過程中,可以動態的選擇將某一部分程式碼片段提升一個編譯層次或者降低一個編譯層次。

C1 與 C2 編譯器會同時工作,許多程式碼可能會被多次編譯。

目的: 在程式的啟動響應時間和執行效率間達到平衡。

編譯層次的劃分:

  • 第 0 層:解釋執行,不開啟效能監控;
  • 第 1 層:將位元組編譯為機器碼,但不進行激進優化,有必要時會加入效能監控;
  • 第 2 層及以上:將位元組編譯為機器碼,會根據效能監控資訊進行激進優化。

如何判斷熱點程式碼,觸發編譯?

1、什麼是熱點程式碼?

  • 被多次呼叫的方法;
  • 被多次執行的迴圈體;
    • 雖然被判斷為熱點程式碼的是迴圈體,不過因為虛擬機器的即時編譯是以方法為單位的,所以編譯器還是會將迴圈體所在的方法整個作為編譯物件。

我們發現,判斷熱點程式碼的一個要點就是: 多次執行 。那麼虛擬機器是如何知道一個方法或者一個迴圈體被多次執行的呢?

2、什麼是 “多次” 執行?

  • 基於取樣的熱點探測
    • 虛擬機器週期檢查各個執行緒的棧頂,如果發現一個方法經常出現在棧頂,則該方法為熱點方法。
    • 優點: 容易獲取方法的呼叫關係,且簡單高效。
    • 缺點: 無法精準的判斷一個方法的熱度,並且容易受到執行緒阻塞的影響,如果一個方法由於它所在的執行緒被阻塞的緣故而一直出現在棧頂,我們並不能認為這個方法被呼叫的十分頻繁。
  • 基於計數器的熱點探測
    • 虛擬機器為每一個方法(或程式碼塊)建立一個計數器,一旦執行次數超過一定閾值,就將其判為熱點程式碼。
    • 優點: 精確嚴謹。
    • 缺點: 不能直接獲取方法的呼叫關係,且實現複雜。
    • HotSpot 使用的是這個,並且還為每個方法建立了兩個計數器。

2.1、HotSpot 中每個方法的 2 個計數器

  • 方法呼叫計數器
    • 統計方法被呼叫的次數,處理多次呼叫的方法的。
    • 預設統計的不是方法呼叫的絕對次數,而是方法在一段時間內被呼叫的次數,如果超過這個時間限制還沒有達到判為熱點程式碼的閾值,則該方法的呼叫計數器值減半。
      • 關閉熱度衰減:-XX: -UseCounterDecay(此時方法計數器統計的是方法被呼叫的絕對次數);
      • 設定半衰期時間:-XX: CounterHalfLifeTime(單位是秒);
      • 熱度衰減過程是在 GC 時順便進行。
  • 回邊計數器
    • 統計一個方法中 “回邊” 的次數,處理多次執行的迴圈體的。
      • 回邊:在位元組碼中遇到控制流向後跳轉的指令(不是所有迴圈體都是回邊,空迴圈體是自己跳向自己,沒有向後跳,不算回邊)。
    • 調整回邊計數器閾值:-XX: OnStackReplacePercentage(OSR比率)
      • Client 模式:回邊計數器的閾值 = 方法呼叫計數器閾值 * OSR比率 / 100;
      • Server 模式:回邊計數器的閾值 = 方法呼叫計數器閾值 * ( OSR比率 - 直譯器監控比率 ) / 100;

2.2、HotSpot 熱點程式碼探測流程

熱點程式碼編譯的過程?

虛擬機器在程式碼編譯未完成時會按照解釋方式繼續執行,編譯動作在後臺的編譯執行緒執行。

禁止後臺編譯:-XX: -BackgroundCompilation,開啟後這個開關引數後,交編譯請求的執行緒會等待編譯完成,然後執行編譯器輸出的原生代碼。

經典優化技術介紹

公共子表示式消除【語言無關】

如果一個表示式 E 已經計算過了,並且從先前的計算到現在,E 中所有變數值都沒有發生變化,則 E 為公共子表示式,無需再次計算,直接用之前的結果替換。

舉例

有表示式int d = (c * b) * 12 + a + (a + b * c),這段程式碼交給Javac編譯器不會進行任何優化。

當這段程式碼進入到虛擬機器即時編譯器後,編譯器檢測到c * b和b * c是一樣的表示式,因此這條公式變為int d = E * 12 + a + (a + E)

還可能變為int d = E * 13 + a * 2

陣列範圍檢查消除【語言相關】

在迴圈中使用迴圈變數訪問陣列,如果可以判斷迴圈變數的範圍在陣列的索引範圍內,則可以消除整個迴圈的陣列範圍檢查

方法內聯【最重要】

目的是:去除方法呼叫的成本(如建立棧幀等),併為其他優化建立良好的基礎,所以一般將方法內聯放在優化序列最前端,因為它對其他優化有幫助。

型別繼承關係分析(Class Hierarchy Analysis,CHA):用於確定在目前已載入的類中,某個介面是否有多於一種的實現,某個類是否存在子類、子類是否為抽象類等。

  • 對於非虛方法:
    • 直接進行內聯,其呼叫方法的版本在編譯時已經確定,是根據變數的靜態型別決定的。
  • 對於虛方法: (激進優化,要預留“逃生門”)
    • 向 CHA 查詢此方法在當前程式下是否有多個目標可選擇;
      • 只有一個目標版本:
        • 先對這唯一的目標進行內聯;
        • 如果之後的執行中,虛擬機器沒有載入到會令這個方法接收者的繼承關係發生改變的新類,則該內聯程式碼可以一直使用;
        • 如果載入到導致繼承關係發生變化的新類,就拋棄已編譯的程式碼。
      • 有多個目標版本:
        • 使用內聯快取,未發生方法呼叫前,內聯快取為空;
        • 第一次呼叫發生後,記錄呼叫方法的物件的版本資訊;
        • 之後的每次呼叫都要先與內聯快取中的物件版本資訊進行比較;
          • 版本資訊一樣,繼續使用內聯程式碼;
          • 版本資訊不一樣,說明程式使用了虛方法的多型特性,這時取消內聯,查詢虛方法進行方法分派。

逃逸分析【最前沿】

基本行為

分析物件的作用域,看它有沒有能在當前作用域之外使用:

  • 方法逃逸:物件在方法中定義之後,能被外部方法引用,如作為引數傳遞到了其他方法中。
  • 執行緒逃逸:賦值給 static 變數,或可以在其他執行緒中訪問的例項變數。

對於不會逃逸到方法或執行緒外的物件能進行優化

  • 棧上分配: 對於不會逃逸到方法外的物件,可以在棧上分配記憶體,這樣這個物件所佔用的空間可以隨棧幀出棧而銷燬,減小 GC 的壓力。
  • 標量替換(重要):
    • 標量:基本資料型別和 reference。
    • 不建立物件,而是將物件拆分成一個一個標量,然後直接在棧上分配,是棧上分配的一種實現方式。
    • HotSpot 使用的是標量替換而不是棧上分配,因為實現棧上分配需要更改大量假設了 “物件只能在堆中分配” 的程式碼。
  • 鎖消除: 不會逃逸到執行緒外的方法不需要進行同步。

虛擬機器引數

  • 開啟逃逸分析:-XX: +DoEscapeAnalysis
  • 開啟標量替換:-XX: +EliminateAnalysis
  • 開啟鎖消除:-XX: +EliminateLocks
  • 檢視分析結果:-XX: PrintEscapeAnalysis
  • 檢視標量替換情況:-XX: PrintEliminateAllocations

優化案例

原始程式碼:

static class B {
    int value;
    final int get() {
        return value;
    }
}

public void foo() {
y = b.get();
// ...do stuff...
z = b.get();
sum = y + z;
}

第一步優化: 方法內聯(一般放在優化序列最前端,因為對其他優化有幫助)

目的:

  • 去除方法呼叫的成本(如建立棧幀等)
  • 為其他優化建立良好的基礎

    public void foo() { y = b.value; // ...do stuff... z = b.value; sum = y + z; }

第二步優化: 公共子表示式消除

public void foo() {
    y = b.value;
    // ...do stuff...  // 因為這部分並沒有改變 b.value 的值
                       // 如果把 b.value 看成一個表示式,就是公共表示式消除
    z = y;             // 把這一步的 b.value 替換成 y
    sum = y + z;
}

第三步優化: 複寫傳播

public void foo() {
    y = b.value;
    // ...do stuff...
    y = y;             // z 變數與以相同,完全沒有必要使用一個新的額外變數
                       // 所以將 z 替換為 y
    sum = y + z;
}

第四步優化: 無用程式碼消除

無用程式碼:

  1. 永遠不會執行的程式碼
  2. 完全沒有意義的程式碼
public void foo() {
    y = b.value;
    // ...do stuff...
    // y = y; 這句沒有意義的,去除
    sum = y + y;
}