首頁>技術>

學習JVM相關的知識,必然繞不開即時編譯器,因為它太重要了。瞭解了它的基本原理及最佳化手段,在程式設計過程中可以讓我們有種開啟任督二脈的感覺。比如,很多朋友在面試當中還會遇到這樣的問題:Java是基於編譯執行還是基於解釋執行?當你瞭解了Java的即時編譯器,不僅能夠輕鬆回答上述問題,還能如數家珍的講出JVM在即時編譯器上採用的最佳化技術,而且在實踐過程中更深刻的理解程式碼背後的原理。本文便帶大家全面的瞭解Java即時編譯器。

即時編譯器

在部分的商用虛擬機器中,比如HotSpot中,Java程式先透過直譯器(Interceptor)進行解釋執行。這也是為什麼稱Java是基於解釋執行的原因。但當虛擬機發現某塊程式碼或方法執行的特別頻繁,便會將其標記為“熱點程式碼”(Hot Spot Code)。

針對熱點程式碼,虛擬機器會採用各種措施來提升其執行效率,因為執行比較頻繁,如果能夠提升其執行效率,價效比還是比較高的。為此,在執行時,虛擬機器會把這些程式碼編譯成與本地平臺相關的機器碼,並進行各層次的深度最佳化。而這些最佳化操作便是透過編譯器來完成的,也稱作即使編譯器(Just In Time Compiler,簡稱 JIT 編譯器)。

因此,準確的來說,像HotSpot等虛擬機器,Java是基於解釋執行和編譯執行的。下面用一張圖來解釋該過程:

直譯器與編譯器的並存

首先,我們需要知道並不是所有的Java虛擬機器都採用直譯器與編譯器並存的架構,但許多主流的商用虛擬機器(如HotSpot),都同時包含直譯器和編譯器。

既然即時編譯器進行了各層次的最佳化,那麼為什麼Java還使用直譯器來“拖累”程式的效能呢?這是因為,直譯器與編譯器兩者各有優勢:當程式需要迅速啟動和執行的時候,直譯器可以首先發揮作用,省去編譯的時間,立即執行。當程式執行環境中記憶體資源限制較大(如部分嵌入式系統中),可以使用直譯器執行節約記憶體,反之可以使用編譯執行來提升效率。此外,如果編譯後出現“罕見陷阱”,可以透過逆最佳化退回到解釋執行。

Java虛擬機器執行時,直譯器和即時編譯器能夠相互協作,取長補短。無論採用直譯器進行解釋執行,還是採用即使編譯器進行編譯執行,最終位元組碼都需要被轉換為對應平臺的本地機器碼指令。某些服務並不看重啟動時間,而某些服務卻非常看重,這就需要採用直譯器與即時編譯器並存來換取一個平衡點。

我們可以從直譯器和編譯器的編譯時間開銷和編譯空間開銷兩方面進行對比。首先,看編譯的時間開銷。

我們所說的JIT比直譯器快,僅限於對“熱點程式碼”編譯之後的程式碼執行起來要比直譯器解釋執行的快。透過上圖可以看出,如果是隻是單次執行的程式碼,JIT編譯比直譯器要多出一步“執行編譯”,因此,只執行一次時,JIT是要比直譯器慢的。只執行一次的程式碼通常包括只被呼叫一次的程式碼(比如構造器)、沒有迴圈的程式碼等,此時使用JIT顯然得不償失。

其次,再來看看編譯空間方面的開銷。對一般的Java方法而言,編譯後代碼的大小相對於位元組碼,膨脹比達到10倍是很正常的。只有對執行頻繁的程式碼才值得編譯,如果把所有程式碼都編譯則會顯著增加程式碼所佔空間,導致“程式碼爆炸”。這就是為什麼有些JVM不會單一使用JIT編譯,而是選擇用直譯器+JIT編譯器的混合執行引擎。

HotSpot的兩種即時編譯器

HotSpot虛擬機器為了使用不同的應用場景,內建了兩個即時編譯器:Client Complier和Server Complier,簡稱為C1、C2編譯器,分別用在客戶端和服務端。Client Complier可獲取更高的編譯速度,Server Complier可獲取更好的編譯質量。

JVM Server模式與client模式最主要的差別在於:-server模式啟動時,速度較慢,但是一旦執行起來後,效能將會有很大的提升。原因是:當虛擬機器執行在-client模式時,使用的是一個代號為C1的輕量級編譯器,而-server模式啟動的虛擬機器採用相對重量級代號為C2的編譯器。C2比C1編譯器編譯的相對徹底,服務起來之後,效能更高。

預設情況下,使用C1還是C2編譯器,要取決於虛擬機器執行的模式。HotSpot虛擬機器會根據自身版本與宿主機器的硬體效能自動選擇執行模式,使用者也可以使用“-client”或“-server”引數去強制指定虛擬機器執行在Client模式或Server模式。

目前主流的HotSpot虛擬機器中預設是採用直譯器與其中一個編譯器配合的方式工作,這種配合稱作混合模式(Mixed Mode)。使用者可以使用引數-Xint強制虛擬機器運行於 “解釋模式”(Interpreted Mode),這時候編譯器完全不介入工作。使用-Xcomp強制虛擬機器運行於 “編譯模式”(Compiled Mode),這時將優先採用編譯方式執行,但是直譯器仍然要在編譯無法進行的情況下接入執行過程。透過虛擬機器java -version命令可以檢視當前預設的執行模式。

在上述示例中我們不僅能夠看到採用的模式為mixed mode,還能看到出採用的是Server模式。

熱點探測

上面解釋了JIT編譯器的基本功能,那麼它是如何判斷熱點程式碼的呢?判斷一段程式碼是不是熱點程式碼的行為,也叫熱點探測(Hot Spot Detection),通常有兩種方法:基於取樣的熱點探測和基於計數器的熱點探測(HotSpot使用此方式)。

基於取樣的熱點探測(Sample Based Hot Spot Detection):虛擬機器會週期的對各個執行緒棧頂進行檢查,如果某些方法經常出現在棧頂,會被定義為“熱點方法”。實現簡單、高效,很容易獲取方法呼叫關係。但很難確認方法的reduce,容易受到執行緒阻塞或其他外因擾亂。

基於計數器的熱點探測(Counter Based Hot Spot Detection):為每個方法(甚至是程式碼塊)建立計數器,執行次數超過閾值就認為是“熱點方法”。統計結果精確嚴謹,但實現麻煩,不能直接獲取方法的呼叫關係。

HotSpot虛擬機器預設採用基於計數器的熱點探測,有兩種計數器:方法呼叫計數器和回邊計數器。當計數器數值大於預設閾值或指定閾值時,方法或程式碼塊會被編譯成原生代碼。

方法呼叫計數器,記錄方法呼叫的次數。Client模式預設閾值是1500次,在Server模式下是10000次,可以透過 -XX:CompileThreadhold來設定。如果不做任何設定,方法呼叫計數器統計的並不是方法被呼叫的絕對次數,而是一個相對的執行頻率,即一段時間之內的方法被呼叫的次數。當超過一定的時間限度,但呼叫次數仍然未達到閾值,那麼該方法的呼叫計數器就會被減半,稱為方法呼叫計數器熱度的衰減(Counter Decay),這段時間稱為此方法的統計半衰週期( Counter Half Life Time)。進行熱度衰減的動作是在虛擬機器進行垃圾收集時順便進行的,可以使用虛擬機器引數 -XX:CounterHalfLifeTime引數設定半衰週期的時間,單位是秒。JIT編譯互動圖如下:

回邊計數器,統計一個方法中迴圈體程式碼執行的次數。在位元組碼中遇到控制流向後跳轉的指令稱為“回邊”(Back Edge),建立回邊計數器統的目的是為了觸發OSR編譯。計數器的閾值, HotSpot提供了-XX:BackEdgeThreshold來進行設定,但當前的虛擬機器實際上使用了-XX:OnStackReplacePercentage來間接調整閾值,計算公式如下:

在Client模式下, 公式為“方法呼叫計數器閾值(CompileThreshold)X OSR比率(OnStackReplacePercentage)/ 100” 。其中OSR比率預設為933,那麼,回邊計數器的閾值為13995。在Server模式下,公式為“方法呼叫計數器閾值(Compile Threashold)X (OSR 比率(OnStackReplacePercentage) - 直譯器監控比率(InterpreterProfilePercent))/100”。其中onStackReplacePercentage預設值為140,InterpreterProfilePercentage預設值為33,如果都取預設值,那麼Server模式虛擬機器回邊計數器閾值為10700。

對應的流程圖如下:

與方法計數器不同,回邊計數器沒有計數熱度衰減的過程,因此統計的就是該方法迴圈執行的絕對次數。當計數器溢位時,它還會把方法計數器的值也調整到溢位狀態,這樣下次再進入該方法的時候就會執行標準編譯過程。

不同模式的效能對比

瞭解JVM的不同編譯模式,下面寫一個簡單的測試例子來測試一下不同編譯器的效能。需要注意的是以下測試程式和場景並不夠嚴謹,只是從大方向上帶大家瞭解一下不同模式之間的區別。如果需要精準的測試,最好的方式應該是在嚴格的基準測試下測試。

public class JitTest {    private static final Random random = new Random();    private static final int NUMS = 99999999;    public static void main(String[] args) {        long start = System.currentTimeMillis();        int count = 0;        for (int i = 0; i < NUMS; i++) {            count += random.nextInt(10);        }        System.out.println("count: " + count + ",time cost : " + (System.currentTimeMillis() - start));    }}

在測試的過程中,透過新增虛擬機器引數“-XX:+PrintCompilation”來列印編譯資訊。

首先,來看純解釋執行模式,JVM引數新增“-Xint -XX:+PrintCompilation”,然後執行main方法,列印資訊如下:

count: 449945612,time cost : 33989

花費了大概34秒。同時,控制檯並未打印出編譯資訊,側面證明了即時編譯器沒有參與工作。

下面採用編譯器模式執行,修改虛擬機器引數:“-Xcomp -XX:+PrintCompilation”,執行main方法,列印如下資訊:

其中,程式碼中相關消耗時間列印資訊為:

count: 450031537,time cost : 10593

只用了10秒,同時會產生大量的編譯資訊。

最後,採用混合模式再測試一次,修改虛擬機器引數為“-XX:+PrintCompilation”,執行main方法:

列印了編譯資訊,同時發現執行同樣的程式碼只需要不到1秒的時間。

經過上述粗略的測試,會發現在上述示例中耗時由小到大順序為:混合模式<純編譯模式<純解釋模式。當然,如果需要更精準和更準確的測試,還需要嚴格的基準測試條件。

編譯最佳化技術

即時編譯器之所以快,還有另外一個原因:在編譯原生代碼時,虛擬機器設計團隊幾乎把所有的最佳化措施都使用上了。所以,即時編譯器產生的原生代碼會比 javac 產生的位元組碼更優秀。下面看一下即時編譯器在生產原生代碼時都採用了哪些最佳化技術。

第一,語言無關的經典最佳化技術之一:公共子表示式消除。如果一個表示式E已經計算過了,並且從先前的計算到現在E中所有變數的值都沒有發生變化,那麼E的這次出現就成為了公共子表示式。對於這種表示式,沒必要花時間再對它進行計算,只需要直接使用前面計算過的表示式結果代替 E 就可以了。例子:int d = (c*b) * 12 + a + (a+ b * c) -> int d = E * 12 + a + (a+ E)。

第二,語言相關的經典最佳化技術之一:陣列範圍檢查消除。在Java語言中訪問陣列元素的時候系統將會自動進行上下界的範圍檢查,超出邊界會丟擲異常。對於虛擬機器的執行子系統來說,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量陣列訪問的程式程式碼,這無疑是一種效能負擔。Java 在編譯期根據資料流分析可以判定範圍進而消除上下界檢查,節省多次的條件判斷操作。

第三,最重要的最佳化技術之一:方法內聯。簡單的理解為把目標方法的程式碼“複製”到發起呼叫的方法中,消除一些無用的程式碼。只是實際的JVM中的內聯過程很複雜,在此不分析。

第四,最前沿的最佳化技術之一:逃逸分析。逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞到其他方法中,稱為方法逃逸。甚至可能被外部執行緒訪問到,譬如賦值給類變數或可以在其他執行緒中訪問的例項變數,稱為執行緒逃逸。如果能證明一個物件不會逃逸到方法或執行緒之外,也就是別的方法或執行緒無法透過任何途徑訪問到它,則可進行一些高效的最佳化:

棧上分配:將不會逃逸的區域性物件分配到棧上,那物件就會隨著方法的結束而自動銷燬,減少垃圾收集系統的壓力。同步消除:如果該變數不會發生執行緒逃逸,也就是無法被其他執行緒訪問,那麼對這個變數的讀寫就不存在競爭,可以將同步措施消除掉。標量替換:標量是指無法在分解的資料型別,比如原始資料型別以及reference型別。而聚合量就是可繼續分解的,比如Java中的物件。標量替換如果一個物件不會被外部訪問,並且物件可以被拆散的話,真正執行時可能不建立這個物件,而是直接建立它的若干個被這個方法使用到的成員變數來代替。這種方式不僅可以讓物件的成員變數在棧上分配和讀寫,還可以為後後續進一步的最佳化手段建立條件。小結

透過上面的學習,想必大家已經對即時編譯的運作原理、使用場景、使用流程、判斷程式碼、最佳化技術項等有了更深刻的瞭解。當了解了這些底層的原理,在寫程式碼、排查問題、效能調優方面均有幫助。對於本文中提到的內容,也建議大家實踐、體驗一下,以便加深印象。

18
  • BSA-TRITC(10mg/ml) TRITC-BSA 牛血清白蛋白改性標記羅丹明
  • Runtime快遊戲呼叫copyfile介面臨時檔案踩坑記錄