Java虛擬機11:運行期優化

前言

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的值,並判斷下標有沒有越界,執行的時候就不需要判斷了。更加常見的情況是數組訪問發生在循環之中,並且使用循環變量來進行數組訪問,如果編譯器只要通過數據流分析儘可以判定循環變量的取值範圍永遠在區間[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查看標量的替換情況。

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

相关文章