Java虛擬機器11:執行期優化

五月的倉頡發表於2015-10-07

前言

http://www.cnblogs.com/xrq730/p/4839245.html,HotSpot採用的是直譯器+編譯器並存的架構,之前的這篇文章裡面已經講過了,本文只是把即時編譯器這塊再講得具體一點而已。當然,其實本文的內容也沒多大意義,90%都是概念上的東西,對於實際開發、實際解決專案裡面的疑難問題並沒有什麼太大的幫助,只要看過就好了。

 

編譯物件與觸發條件

之前講過,Sun使用的虛擬機器之所以被叫做"HotSpot",就是因為執行過程中會檢測熱點程式碼,那麼執行過程中,會被即時編譯器編譯的"熱點程式碼"有兩類,即:

  • 被多次呼叫的方法
  • 被多次執行的迴圈體

前者很好理解,一個方法被呼叫得多了,方法體內程式碼執行的次數自然就多,他成為"熱點程式碼"也是理所當然。而後者則是為了解決一個方法只被呼叫過一次或者少量的幾次,但是方法體內部存在迴圈次數較多的迴圈體問題,這樣迴圈體的程式碼也被重複執行多次,因此這些程式碼也應該認為是"熱點程式碼"。

那上面的問題描述中,所謂"多次"都不是一個具體、嚴謹的用語,那麼多少次才算"多次"?還有,虛擬機器如何統計一個方法或一段程式碼被執行過多少次呢?

判斷一段程式碼是不是熱點程式碼,是不是需要觸發即時編譯,這樣的行為稱為"熱點探測",其實熱點探測並不一定要知道方法具體被呼叫了多少次,目前主要的熱點探測判定方式有兩種:

  • 基於取樣的熱點探測
  • 基於計數器的熱點探測

HotSpot虛擬機器中使用的是第二種基於計數器的熱點探測方法,它為每個方法準備了兩類計數器:方法呼叫計數器和回邊計數器。在確定虛擬機器執行引數的前提下,這兩個計數器都有一個確定的閾值,當計數器超過閾值溢位了,就會觸發JIT編譯,分別看一下:

1、方法呼叫計數器

顧名思義,這個計數器就是用於統計方法被呼叫的次數,它的預設閾值在Client模式下是1500次,在Server模式下是10000次。這個閾值可以通過引數-XX:CompileThreshold來人為設定。當一個方法被呼叫時,會檢查方法是否存在被JIT編譯過的版本,如果存在,則優先使用編譯後的原生程式碼來執行。如果不存在已被編譯過的版本,則將此方法的呼叫計數器值加1,然後判斷方法呼叫計數器和回邊計數器值之和是否超過方法呼叫計數器的閾值。如果已經超過閾值,那麼將會向即時編譯器提交一個該方法的程式碼編譯請求。

如果這個引數不做任何設定,那麼方法呼叫計數器統計的並不是方法被呼叫的絕對次數,而是一個相對的執行頻率,即一段時間之內方法被呼叫的次數。當超過一定的時間限度,如果方法的呼叫次數仍然不足以讓它提交給即時編譯器編譯,那這個方法的呼叫計數器就會少一半,這個過程稱為方法的呼叫計數器熱度的衰減,而這段時間就稱為此方法統計的半衰週期。進行熱度衰減的動作實在虛擬機器進行垃圾回收時順便進行的,可以使用虛擬機器引數-XX:-UseCounterDecay來關閉熱度衰減,讓方法計數器統計方法呼叫的絕對次數,這樣,只要系統執行時間足夠長,絕大部分方法都會被編譯成原生程式碼。另外,可以使用-XX:CounterHalfLifeTime引數設定半衰週期的時間,單位是秒。

那如果引數不設定的話,執行引擎並不會同步等待編譯請求完成,而是直接進入直譯器按照解釋方法執行位元組碼,直到提交的請求被編譯器編譯完成。當編譯工作完成之後,這個方法的呼叫入口地址就會被系統自動改寫成新的,下一次呼叫該方法時就會使用已編譯的版本。

2、回邊計數器

它的作用是統計一個方法中迴圈體程式碼執行的次數,在位元組碼中遇到控制流向後跳轉的指令稱為"回邊"。顯然,建立回邊技術其統計的目的就是為了觸發OSR編譯。關於回邊計數器的閾值,雖然HotSpot也提供了一個類似於方法呼叫計數器閾值-XX:CompileThreshold的引數-XX:BackEdgeThreshold供使用者設定,但是當前虛擬機器實際上並未使用此引數,因此我們需要設定另外一個引數-XX:OnStackReplacePercentage來間接調整回邊計數器的閾值,其計算公式如下:

(1)Client模式

方法呼叫計數器閾值 × OSR比率 / 1000,其中OSR比率預設值933,如果都取預設值,Client模式下回邊計數器的閾值應該是13995

(2)Server模式

方法呼叫計數器閾值 × (OSR比率 - 直譯器監控比率) / 100,其中OSR比率預設140,直譯器監控比率預設33,如果都取預設值,Server模式下回邊計數器閾值應該是10700

當直譯器遇到一條回邊指令時,會先查詢將要執行的程式碼片段中是否有已經編譯好的版本,如果有,它將會優先執行已編譯好的程式碼,否則就把回邊計時器的值加1,然後判斷方法呼叫計數器與回邊計數器值之和是否已經超過回邊計數器的閾值。當超過閾值之後,將會提交一個OSR編譯請求,並且把回邊計數器的值降低一些,以便繼續在直譯器中執行迴圈,等待編譯器輸出編譯結果。

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

 

編譯過程

很簡單過一下這塊編譯過程的內容,因為這主要是編譯原理和程式碼優化中的內容。

在預設設定下,無論是方法呼叫產生的即時編譯請求,還是OSR編譯請求,虛擬機器在程式碼編譯器還未完成的時候,都仍然按照解釋方式繼續執行,而編譯動作則在後臺的編譯執行緒中進行。使用者可以通過-XX:-BackgroundCompilation來禁止後臺編譯,在禁止後臺編譯後,一旦達到JIT的編譯條件,執行執行緒向虛擬機器提交編譯請求後將會一直等待,直到編譯過程完成後再開始執行編譯器輸出的原生程式碼。

對於Client Compiler(C1編譯器)來說,它是一個簡單快速的三段式編譯,主要關注點在於區域性性的優化,而放棄了許多耗時間長的全域性優化手段。

對於Sever Compiler(C2編譯器)來說,它則是專門面向服務端的典型應用併為服務端的效能配置特別調整過的編譯器,也是一個充分優化過的高階編譯器,幾乎能達到GNU C++編譯器使用-O2引數時的優化強度,它會執行所有經典的優化動作,如無用程式碼消除、迴圈展開、常量傳播、基本塊重排序等,還會實施一些與Java語言特性密切相關的優化技術,如範圍檢查消除、空值檢查消除等,另外,還有可能根據直譯器或Client Compiler提供的效能監控資訊,進行一些不穩定的激進優化,如守護內聯、分支頻率預測等,下一部分將講解上述的一部分優化手段。

Server Compiler從即時編譯的標準來看,無疑是比較緩慢的,但它的編譯速度依然遠遠超過傳統的靜態優化編譯器,而且它相對於Client Compiler編譯輸出的程式碼質量有所提高,可以減少原生程式碼的執行時間,從而抵消了額外的編譯時間開銷,所以也有很多非服務端的應用選擇使用Server模式的虛擬機器執行。

 

優化技術

在Sun官方的Wiki上,HotSpot虛擬機器設計團佇列出了一個相對比較全面、在即時編譯器中採用的優化技術列表,其中有不少經典編譯器的優化手段,也有許多針對Java語言(準確地說是執行在Java虛擬機器上得所有語言)本身繼續擰的優化技術,下面主要看幾項最有代表性的優化技術:

  • 語言無關的經典優化技術之一:公共子表示式消除
  • 語言無關的經典優化技術之一:陣列範圍檢查消除
  • 最重要的優化技術之一:方法內聯
  • 最前沿的優化技術之一:逃逸分析

1、公共子表示式消除

公共子表示式消除消除的含義是:如果一個表示式E已經計算過了,並且從先前的計算到現在E中的所有變數值都沒有發生變化,那麼E的這次出現就成為了公共子表示式。對於這種表示式,沒有必要花時間再去對它進行計算,只需要直接用前面計算過的表示式結果替代E就可以了。如果這種優化僅限於程式的基本塊內,便稱為區域性公共子表示式消除;如果這種優化的範圍涵蓋了多個基本塊,便稱為全域性公共子表示式消除。舉個簡單的例子,假設存在以下程式碼:

int d = (c * b) * 12 + a + (a + b * c);

如果這段程式碼交給Javac編譯器則不會進行任何優化。但是這段程式碼進入到虛擬機器即時編譯器之後,它將會進行如下優化,編譯器檢測到"c * b"和"b * c"是一樣的表示式,而且在計算期間b與c的值是不變的,因此這條表示式將被視作:

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

這時,編譯器還可能進行另一種叫做代數簡化的優化,把表示式變為:

int d = E * 13 + a * 2;

表示式進行變換之後,在計算起來就可以節省一些時間了

2、陣列範圍檢查消除

我們知道Java語言是一門動態安全的語言,對陣列的讀寫訪問也不像C、C++那樣在本質上是裸指標操作,如果有一個陣列foo[],在Java語言中訪問陣列元素foo[i]的時候將會自動進行上下界的範圍檢查,即檢查i>=0&&i<foo.length這個條件,否則將會丟擲一個陣列下標越界異常。這對開發者來說是一件好事,即使程式設計師沒有專門編寫防禦程式碼,也可以避免大部分的溢位攻擊,但是對於虛擬機器來說,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量陣列訪問的程式程式碼,無疑也是一種效能負擔。

無論如何,為了安全,陣列邊界檢查肯定是必須做的,但陣列邊界檢查是不是必須在執行期間一次不漏地檢查則是可以商量的。比如陣列下標是一個常量,只要在編譯期間根據資料流分析來確定foo.length的值,並判斷下標有沒有越界,執行的時候就不需要判斷了。更加常見的情況是陣列訪問發生在迴圈之中,並且使用迴圈變數來進行陣列訪問,如果編譯器只要通過資料流分析儘可以判定迴圈變數的取值範圍永遠在區間[0, foo.length)之間,那整個迴圈中就可以把陣列的上下界檢查消除,這可以節省很多次的條件判斷操作。

3、方法內聯

最重要的優化手段之一。它的目的主要有兩個:去除方法呼叫的成本(如建立棧幀等)、為其他優化建立了良好的基礎,方法內聯膨脹之後可以便於在更大範圍上採取後續的優化手段。方法內聯舉個例子:

public final int getA()
{
getA()語句1;
getA()語句2;
getA()語句3;
getA()語句4;
getA()語句5
}
public static void main(String[] args)
{
    main語句1;
    main語句2;
    int i = getA();
    main語句3;
    main語句4
}

優化之後變為:

public static void main(String[] args)
{
    main語句1;
    main語句2;
    getA()語句1;
    getA()語句2;
    getA()語句3;
    getA()語句4;
    getA()語句5;
    main語句3;
    main語句4
}

從效果上看,無非是把getA()方法中的內容原封不動地拿到main函式中,但這樣卻少了保護現場、恢復執行緒、建立棧幀等一系列的工作,並且程式碼一膨脹,原來方法A有5行程式碼,方法B有6行程式碼,方法C有7行程式碼,對於三個方法各自執行來說可能沒什麼好優化的,但是三個方法合起來放到main函式之中,就有了很大的優化空間了。

講到這裡,我們是否理解為什麼要儘量把方法宣告為final?因為Java有多型的存在,執行時呼叫的是哪個方法可以根據實際的子類來確定,極大地增強了靈活性,但是這樣的話,編譯期間同樣也無法確定應該使用的是哪個版本,所以無法被內聯。但是被宣告為final的方法不一樣,這些方法無法被重寫,所以呼叫類A的B方法,執行時呼叫的必然是類A的B方法,可以被內聯。

4、逃逸分析

目前Java虛擬機器中比較前沿的優化技術,它並不是直接優化程式碼的手段,而是為其他優化手段提供了分析技術。

逃逸分析的基本行為就是分析物件動態作用域:當一個物件在方法中被定義後,它可能被外部方法所引用,例如作為呼叫引數傳遞到其他方法中去,稱為方法逃逸。甚至可能被外部執行緒訪問到,比如賦值給類變數或可以在其他執行緒中訪問到的例項變數,稱為執行緒逃逸。

如果能證明一個物件不會逃移到方法外或者執行緒之外,也就是別的方法或執行緒無法通過任何途徑訪問到這個物件,則可能為這個變數進行一些高效的優化:

(1)棧上分配

Java虛擬機器中,物件在堆上分配這個眾所周知。虛擬機器的垃圾收集系統可以回收堆中不再使用的物件,但回收動作無論是篩選可回收物件還是回收和整理記憶體都要耗費時間。如果確定一個物件不會逃逸出方法之外,那麼讓這個物件在棧上分配將會是一個不錯的主意,物件所佔用的記憶體空間就可以隨著棧幀出棧而銷燬,這樣垃圾收集系統的壓力將會小很多

(2)同步消除

執行緒同步本身是一個相對耗時的過程,如果逃逸分析能夠確定一個變數不會逃逸出執行緒,無法被其他執行緒訪問,那麼這個變數的讀寫肯定不會有頸枕,對這個變數實施的同步措施也就可以消除掉

(3)標量替換

標量是指一個資料已經無法再分解成更小的資料來表示了,Java中的基本資料型別即引用型別都不能進一步分解,因此,它們可以稱為標量。相對的,一個資料如果還可以繼續分解,那麼就稱為聚合量,Java中的物件就是最典型的聚合量。如果逃逸分析證明一個物件不會被外部訪問,並且這個物件可以被拆散的話,那程式真正執行的時候將可能不建立這個物件,而改為直接建立它的若干個被這個方法使用到的成員變數來代替。將物件拆分後,除了可以讓物件的成員在棧上分配和讀寫外,還可以為後續進一步的優化手段建立條件。

關於逃逸分析的論文1999年就已經發表,但直到Sun JDK1.6才實現了逃逸分析而且直到現在這項優化尚未足夠成熟,仍有很大改進餘地。不成熟的原因主要是不能保證逃逸分析的效能收益必定能高於它的消耗。雖然在實際測試結果中,實施逃逸分析後的程式往往能執行出不錯的成績,但是在實際的應用程式,尤其是大型程式中反而發現實施逃逸分析可能出現效果不穩定的情況,或因分析過程耗時但卻無法有效判別出非逃逸物件而導致效能有所下降。

如果有需要,並且確認對程式執行有益,可以使用引數-XX:+DoEscapeAnalysis來手動開啟逃逸分析,開啟之後可以通過引數-XX:+PrintEscapeAnalysis來檢視分析結果。有了逃逸分析支援之後,就可以使用引數-XX:+EliminateAllocations來開啟標量替換,使用引數-XX:+EliminatLocks來開啟同步消除,使用引數-XX:+PrintEliminateAllocations檢視標量的替換情況。

儘管目前逃逸分析技術仍不是十分成熟,但是在今後的虛擬機器中,逃逸分析技術肯定會支撐起一系列實用有效的優化技術。

相關文章