深入理解Java虛擬機-第十一章 晚期(運行期)優化

第十一章 晚期(運行期)優化

11.1 概述

本章講述 JIT(Just In Time Compiler,即時編譯器)。Java 虛擬機規範沒有具體的約束規則區限制即時編譯器應該如何實現,但是 JIT 編譯性能的好壞、代碼優化程度的高低卻是衡量一款商用虛擬機優秀與否的最關鍵的指標之一,他也是虛擬機中最核心且最能體現虛擬機技術水平的部分。

如無特殊說明,本章提及的編譯器、即時編譯器都是指的 HotSpot 虛擬機內的 JIT ,虛擬機也是特指 HotSpot 虛擬機。

11.2 HotSpot 虛擬機內的即時編譯器

本節中,我們將要了解 HotSpot 虛擬機內的 JIT 的運作過程,同時還要解決以下幾個問題:

  • 爲何 HotSpot 虛擬機要使用解釋器與編譯器並存的架構?
  • 爲何 HotSpot 虛擬機要實現兩個不同的即時編譯器?
  • 程序何時使用解釋器執行?何時使用編譯器執行?
  • 哪些程序代碼會被編譯爲本地代碼?如何編譯爲本地代碼?
  • 如何從外部觀察即時編譯器的編譯過程和編譯結果?

11.2.1 解釋器與編譯器

解釋器和編譯器各有優勢:當程序需要迅速啓動和執行的時候,解釋器可以首先發揮作用,省去編譯時間立即執行。在程序運行後,隨着時間的推移,編譯器逐漸發揮作用,把越來越多的代碼編譯成本地代碼之後可以獲得更高的執行效率。當程序運行環境中內存資源限制較大(如部分嵌入式系統),可以使用解釋執行節約內存,反之則可以使用編譯執行來提升效率。同時解釋器還可以作爲一個編譯器激進優化的逃生門,當激進優化後出現 “罕見陷阱”(Uncommon Trap)時可以通過 “逆優化”(Deoptimization)退回到解釋狀態繼續執行(部分沒有解釋器的虛擬機中也會採用不進行激進優化的 C1 編譯器擔任 逃生門 的角色)。所以解釋器和編譯器兩者經常配合工作,如圖所示:
解釋器與編譯器的交互
HotSpot 虛擬機中默認內置了兩個 JIT 編譯器,分別稱爲 Client Compiler 和 Server Compiler,也被簡稱爲 C1 編譯器和 C2 編譯器。關於 HotSpot 兩個編譯器的解釋,引用一篇博客中相關的描述。博客的名稱叫《Java 面試-即時編譯( JIT )》

在 HotSpot 虛擬機中,內置了兩種 JIT,分別爲C1 編譯器和C2 編譯器,這兩個編譯器的編譯過程是不一樣的。

  • C1 編譯器是一個簡單快速的編譯器,主要的關注點在於局部性的優化,適用於執行時間較短或對啓動性能有要求的程序,也稱爲Client Compiler,例如,GUI 應用對界面啓動速度就有一定要求。
  • C2 編譯器是爲長期運行的服務器端應用程序做性能調優的編譯器,適用於執行時間較長或對峯值性能有要求的程序,也稱爲Server Compiler,例如,服務器上長期運行的 Java 應用對穩定運行就有一定的要求。

在 JDK 7 之前,需要根據程序的特性來選擇對應的 JIT,虛擬機默認採用解釋器和其中一個編譯器配合工作。Java7 引入了分層編譯,這種方式綜合了 C1 的啓動性能優勢和 C2 的峯值性能優勢,我們也可以通過參數 -client或者-server 強制指定虛擬機的即時編譯模式。
分層編譯將 JVM 的執行狀態分爲了 5 個層次:

  • 第 0 層:程序解釋執行,默認開啓性能監控功能(Profiling),如果不開啓,可觸發第二層編譯;
  • 第 1 層:可稱爲 C1 編譯,將字節碼編譯爲本地代碼,進行簡單、可靠的優化,不開啓 Profiling;
  • 第 2 層:也稱爲 C1 編譯,開啓 Profiling,僅執行帶方法調用次數和循環回邊執行次數 profiling 的 C1 編譯;
  • 第 3 層:也稱爲 C1 編譯,執行所有帶 Profiling 的 C1 編譯;
  • 第 4 層:可稱爲 C2 編譯,也是將字節碼編譯爲本地代碼,但是會啓用一些編譯耗時較長的優化,甚至會根據性能監控信息進行一些不可靠的激進優化。

對於 C1 的三種狀態,按執行效率從高至低:第 1 層、第 2層、第 3層。
通常情況下,C2 的執行效率比 C1 高出30%以上。
在 Java8 中,默認開啓分層編譯,-client 和 -server 的設置已經是無效的了。如果只想開啓 C2,可以關閉分層編譯(-XX:-TieredCompilation),如果只想用 C1,可以在打開分層編譯的同時,使用參數:-XX:TieredStopAtLevel=1。

書中只提到了 0-1-4 3 層,而博客中細分爲 5 層。1.8 前可通過 -client 和 -server 來設置使用 C1 還是 C2 ,但1.8後,在 Java8 中,默認開啓分層編譯,-client 和 -server 的設置已經是無效的了。
無論採用的編譯器是 C1 還是 C2 ,解釋器與編譯器搭配使用的方式在虛擬機中稱爲 混合模式(Mixed Mode),也可通過 -Xint 和 -Xcomp 來將模式改爲 解釋模式 和 編譯模式。可以通過 -version 命令數出結果顯示這三種模式,如下圖所示:
虛擬機執行模式

11.2.2 編譯對象與觸發條件

熱點代碼分兩類:

  • 被多次調用的方法
  • 被多次執行的循環體

前者很好理解,一個方法被調用多了,方法體內代碼執行的次數自然就多,它成爲熱點代碼是理所當然的。而後者則是爲了解決一個方法只會被少量調用但是方法體內部存在循環次數較多的循環體的問題,這樣循環體的代碼也被重複執行多次,也應當被認爲是熱點代碼。
兩種情況都是以整體方法作爲編譯對象,第一種情況比較好理解,這種編譯也是虛擬機中標準的 JIT 編譯方式。而對於後一種情況,儘管編譯動作是由循環體所觸發,但是編譯器仍然會編譯整個方法。這種編譯方式因爲編譯發生在方法執行過程中,因此形象地稱之爲 棧上替換(On Stack Replacement,簡稱爲 OSR 編譯,即方法棧幀還在棧上,方法就被替換了)。
判斷一段代碼是不是熱點代碼,是不是需要觸發即時編譯,這樣的行爲稱爲熱點探測(Hot Spot Detection),目前主要的熱點探測判定方式有兩種:

  • 基於採樣的熱點探測(Sample Based Hot Spot Detection):採用這種方式的虛擬機會週期性的檢查各個線程的棧頂,如果發現某個(某些)方法經常出現在站定,那這個方法就是 “熱點方法”。有點事簡單高效,很容易地獲取方法調用關係,缺點就是很難精確的確定一個方法的熱度
  • 基於計數器的熱點探測(Counter Based Hot Spot Detection):採用這種方法的虛擬機會爲每個方法(甚至是代碼塊)建立計數器,統計方法的執行次數,如果超過一定閾值則認爲它是 “熱點方法”。優點是能夠精確和嚴禁的確定熱點方法,缺點則是需要爲每個方法建立並維護計數器,且不能直接獲取到方法的調用關係。

在 HotSpot 虛擬機中,使用的是第二種——基於計數器的熱點探測方法,所以他爲每個方法準備了兩類計數器:

  • 方法調用計數器:在 Client 模式下,方法計數器默認閾值是 1500 次,而在 Server 模式下則是 10,000 次,這個閾值可以通過 -XX:CompileThreshold 來人爲設定。而在分層編譯的情況下-XX: CompileThreshold指定的閾值將失效,此時將會根據當前待編譯的方法數以及編譯線程數來動態調整。當方法計數器和回邊計數器之和超過方法計數器閾值時,就會觸發 JIT 編譯器。整體流程如下:
    方法調用計數器觸發即時編譯
    如果不做設置,這裏的方法調用器計數並不是一個絕對的調用次數,而是一段時間內的調用次數。當超過一定時間限度後,如果仍然不滿足提交 JIT 編譯時,那這個計數器就會被減少一半,這個過程被稱作方法調用計數器熱度的衰減,而這段時間就稱爲半衰週期。進行熱度衰減的動作是在虛擬機進行垃圾回收時順便進行的,可以使用 -XX:-UseCounterDecay 來關閉熱度衰減,讓方法計數器統計方法調用的絕對次數,這樣隨着運行時間的增長,絕大多數方法都會被編譯成本地代碼提高運行速度。可以使用 -XX:CounterHalfLifeTime 參數設置半衰週期的時間,單位是秒。

  • 回邊計數器:回邊計數器用於統計一個方法中循環體代碼執行的次數,在字節碼中遇到控制流向後跳轉的指令稱爲“回邊”(Back Edge)。雖然 HotSpot 也提供了一個 -XX:BackEdgeThreshold 供用戶設置回邊計數器閾值,但是當前虛擬機並未用到,所以要設置另一個參數 -XX:OnStackReplacePercentage 來間接調整回邊計數器的閾值計算公式如下:

    • Client 模式下,計算公式爲:
      方法調用計數器閾值 * OSR 比率 / 100
      OSR 比例默認爲 933,如果都取默認值,那麼 Client 模式虛擬機的回邊計數器的閾值爲 13995
    • Server 模式下,計算公式爲:
      方法調用計數器閾值 * (OSR 比率 - 解釋器監控比率 (InterpreterProfilePercentage))/ 100
      其中 OSR 比例默認值爲 140,InterpreterProfilePercentage 默認值爲 33,都取默認值,則 Server 模式下的虛擬機回邊計數器的閾值爲 10700。

    說這些其實僅用作了解,在分層編譯的情況下,-XX: OnStackReplacePercentage指定的閾值同樣會失效,此時將根據當前待編譯的方法數以及編譯線程數來動態調整。具體整個流程如下:
    回邊計數器觸發即時編譯
    與方法計數器不同,回邊計數器沒有技術熱度衰減的過程。因此這個計數器統計的就是該方法循環執行的絕對次數。當計數器溢出的時候他還會吧方法計數器的值也調整到溢出狀態,這樣下次在進入該方法的時候就會執行標準編譯過程。

這裏需要提醒一點,上面兩個流程圖僅僅介紹的是 Client VM 的即時編譯方式,對於 Server VM 來說,執行情況會比上面的描述更復雜一些。

11.2.3 編譯過程

在默認設置下,無論是哪種情況產生的即時編譯請求,虛擬機代碼編譯器還未完成編譯之前,都仍然按照解釋方式繼續進行,而編譯動作則在後臺的編譯線程中進行。用戶可以通過參數 -XX:-BackgroundCompilation 來禁止後臺編譯,這樣一旦達到 JIT 的條件,執行線程向虛擬機提交編譯請求後會一直等待,知道編譯完成。
對於 Client Compiler 來說,他是一個簡單的三段式編譯器:

  • 第一階段:首先會完成一些基礎優化,如方法內聯、常量傳播等。之後,一個平臺獨立的前端,將字節碼構造成一種高級中間代碼表示(High-Level Intermediate Representation,HIR)。HIR使用靜態分配(Static Single Assignment,SSA)的方式代表代碼值,這可以使一些在HIR構造之中和之後進行的優化更容易實現
  • 第二階段:首先在HIR上完成另一些優化,如空值檢查消除、範圍檢查消除等,以便讓HIR達到更高效的代碼表示形式。之後,一個平臺相關的後端,會從HIR中產生低級中間代碼(Low-Level Intermediate Representation,LIR)
  • 最終階段:在平臺相關的後端上使用線性掃描法(Linear Scan Register Allocation)在LIR上分配寄存器,並在LIR上做窺孔(Peephole)優化,然後產生機器代碼。大體流程如下所示:
    Client Compiler 架構
    對於 Server Compiler ,引用書上的話作總結:

Server Compiler則是專門面向服務端的典型應用併爲服務端的性能配置特別調整過的編譯器,也是一個充分優化過的高級編譯器,它會執行所有經典的優化動作:無用代碼消除(Dead Code Elimination)、循環展開(Loop Unrolling)、循環表達式外提(Loop Expression Hoisting)、公共子表達式消除(Common Subexpression Elimination )、常量傳播(Constant Propagation)、基本塊重排序(Basic Block Recording)等。還會實施一些與Java語言特性密切相關的優化技術,如範圍檢查消除(Range Check Elimination)、空值檢查消除(Null Check Elimination),不過並非所有的空值檢查消除都是依賴編譯器進行優化的,有一些是在代碼的運行過程中自動優化了)等。另外,還可能根據解釋器或Client Compiler提供的性能監控信息,進行一些不穩定的激進優化,如守護內聯(Guarded Inlining)、分支頻率預測(Branch Frequency Prediction)等。
Server Compiler的寄存器分配器是一個全局圖着色分配器,它可以充分利用某些處理器架構(如RISC)上的大寄存器集合。以即時編譯的標準來看,Server Compiler無疑是比較緩慢的,但它的速度仍然遠遠超過傳統的靜態優化編譯器,而且它相對於Client Compiler編譯輸出的代碼質量有所提高,可以減少本地代碼的執行時間,從而抵消了額外的編譯時間開銷,所以也有很多非服務端的應用選擇使用Server模式的虛擬機運行。

11.2.4 查看及分析即時編譯結果

11.3.1 優化技術概覽

話不多說直接上圖:
1
2
3
書中舉例說明了幾個簡單的優化,此處不一一贅述。

11.3.2 公共子表達式消除

這個其實跟我們中學數學學的提取公因數非常相似,它的含義是:如果一個表達式 E 已經計算過了,並且從先前的計算到現在 E 中所有變量的值都沒有發生過,那麼這個 E 的這次出現就成爲了公共子表達式。對於這種表達式沒有必要再次計算,只需要直接用前面計算過的表達式結果代替 E 就可以了。例如:

	public static void main(String[] args) {
        int a = 10, b = 11, c = 12;
        int d = (c * b) * 12 + a + (a + b * c);
    }

這段代碼進入到 JIT 後,編譯器檢測到 c * b 和 b * c 沒區別,並且在計算時沒改變,則替換成如下僞代碼

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

這時編譯期可能還會做一次代數化簡優化:

    int d = E * 13 + a * 2;

當然這裏舉的例子我們其實自己就可以優化成這樣,生產中需要優化的代碼可能比這裏麻煩得多,

11.3.3 數組邊界檢查消除

如果有一個數組 foo[],在 Java 語言中訪問數組元素 foo[i] 的時候一定會進行上下界範圍檢查,即檢查 i 必須滿足 i >= 0 && i < foo.length 這個條件,否則會拋出一個 RuntimeException:java.lang.ArrayIndexOutOfBoundsException。無論如何爲了安全,數組邊界檢查肯定是必須做的,但數組邊界檢查是不是必須在運行期間一次不漏的檢查是可以“商量”的事情。例如數組的下標是一個常量,如 foo[3],只要在編譯器根據數據流分析來確定 foo.length 的值,並判斷下標 “3” 沒有越界,執行的時候就無須判斷了。更加常見的情況是數組訪問發生在循環之中,並且使用循環變量來進行數組訪問,如果編譯器只要通過數據流分析就可以判定循環變量的取值範圍永遠在 [0, foo.length) 之內,那在整個循環中就可以把數組的上下界檢查消除,這可以節省很多次的條件判斷操作。

11.3.4 方法內聯

先僞代碼舉例:

	static class B{
        int value;

        final int getValue() {
            return value;
        }
    }

    public void foo() {
        B b = new B();
        y = b.getValue();
        // do something...
        z = b.getValue();
        sum = y + z;
    }

優化後

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

方法內聯看起來只不過是將目標方法的代碼複製到發起調用的方法中,避免發生真實的方法調用而已.但實際上Java虛擬機中的內聯過程遠遠沒有那麼簡單,因爲如果不是即時編譯器做了一些特別的努力,按照經典編譯原理的優化理論,大多數的Java方法都無法進行內聯
無法內聯的原因前面講過,只有使用 invokespecial 指令調用的私有方法、實例構造器、父類方法以及使用 invokestatic 指令進行調用的靜態方法纔是在編譯器進行解析的。除了上述四種方法,其餘方法都是要在運行期時進行方法接收者多態選擇後纔可以調用。對於一個虛方法來說,編譯期做內聯時根本沒法判斷用哪個方法版本。爲了解決這個問題,虛擬機團隊首先引入了一種名爲 “類型繼承關係分析”(Class Hierarchy Analysis,CHA)的技術。
編譯器在進行內聯時,對於非 virtual 方法,直接進行內聯,如果是 invokevirtual時,則先向 CHA 查詢此方法在當前程序下是否有多個目標版本,如果查詢結果只有一個,則也可以內聯,不過這種內聯就屬於前文提到過的激進優化了,需要留有逃生門。
如果向 CHA 查詢出來的結果是有多個版本的目標方法可供選擇,則編譯器還會進行最後一次努力,使用內聯緩存(Inline Cache)來完成方法內斂。在未發生方法調用之前,內聯緩存狀態爲空,當第一次調用發生後,緩存記錄下方法接受者的版本信息,並且每次進行方法調用時都比較接受者版本,如果以後進來的每次調用的方法接受者版本都是一樣的,那這個內聯還可以一直用下去。如果發生了方法接受者不一致的情況,就說明程序真正使用了虛方法的多態特性,這時纔會取消內斂,查找虛方法表進行方法分派。

11.3.5 逃逸分析

逃逸分析的基本行爲就是分析對象動態作用域:當一個對象在方法中被定義後,他可能被外部方法所引用,例如作爲調用參數傳遞到其他方法中,稱爲方法逃逸。甚至還有可能被外部線程訪問到,譬如賦值給類變量或可以在其他線程中訪問的實例變量,稱爲線程逃逸。
如果能證明一個對象不會逃逸,則可能爲這個變量進行一些高效的優化:

  • 棧上分配(Stack Allocation):如果一個對象被分析到不會出現在別的線程和方法中時,其實可以直接在棧上分配這個變量,隨着虛擬機棧幀出棧,變量隨之銷燬。這樣大大減少了 GC 所需要清理的垃圾
  • 同步消除(Synchronization Elimination):線程同步本身是一個相對耗時的過程,如果逃逸分析能夠確定一個變量不會逃逸出線程,那麼這個變量的讀寫肯定是安全的,那麼針對這個變量實施的同步措施也就可以消除掉了。
  • 標量替換(Scalar Replacement):標量是指一個數據已經無法再分解爲更小的數據來表示了。Java虛擬機中原始數據類型(int,long等數值類型以及reference類型等)都不能再進一步分解,它們就可以稱爲標量。相對的,如果一個數據可以繼續分解,那麼它就稱作聚合量,Java中的對象就是最典型的聚合量。如果把一個Java對象拆散,根據程序訪問的情況,將其使用到的成員變量恢復原始數據來訪問就叫做標量替換。如果逃逸分析證明一個對象不會被外部訪問,並且這個對象可以被拆散的話,那程序真正執行的時候將可能不創建這個對象,而改爲直接創建它的若干個被這個方法使用到的成員變量來代替。將對象拆分後,除了可以讓對象的成員變量在棧上(棧上存儲的數據,有很大概率會被虛擬機分配至物理機器的高速寄存器中存儲)分配和讀寫之外,還可以爲後續進一步的優化手段創建條件。

如果要完全準確的判斷一個對象是否會逃逸,需要進行數據流敏感的一系列複雜分析,從而確定程序各分支執行對目標對象影響。過程耗時較長不說,如果分析完後沒有幾個不逃逸的對象,那麼這段時間就白白浪費了。而 JDK 8 中已經默認開啓了逃逸分析說明現在的虛擬機團隊對逃逸分析已經有了十足的把握,但是還是要放上幾個關閉逃逸分析的參數:

  • 開啓逃逸分析(JDK8中,逃逸分析默認開啓。)
    -XX:+DoEscapeAnalysis
  • 關閉逃逸分析
    -XX:-DoEscapeAnalysis
  • 逃逸分析結果展示
    -XX:+PrintEscapeAnalysis
  • 開啓標量替換
    -XX:+EliminateAllocations
  • 開啓同步消除
    -XX:+EliminateLocks
  • 查看標量替換結果
    -XX:PrintEliminateAllocations

關於逃逸分析講解的找到一篇很好的文章,有興趣的可以去看下:《Java-JVM-逃逸分析》

讀書越多越發現自己的無知,Keep Fighting!

本文僅是在自我學習 《深入理解Java虛擬機》這本書後進行的自我總結,有錯歡迎友善指正。

歡迎友善交流,不喜勿噴~
Hope can help~

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