1. 程式人生 > >《深入理解java虛擬機器》學習筆記之編譯優化技術

《深入理解java虛擬機器》學習筆記之編譯優化技術

鄭重宣告:本片部落格是學習<深入理解Java虛擬機器>一書所記錄的筆記,內容基本為書中知識.
Java程式設計師有一個共識,以編譯方式執行原生代碼比解釋方式更快,之所以有這樣的共識,除去虛擬機器解釋執行位元組碼時額外消耗時間的原因外,還有一個很重要的原因就是虛擬機器設計團隊幾乎把對程式碼的所有優化措施都集中在了即時編譯器之中(在JDK 1.3之
後,Javac就去除了-O選項,不會生成任何位元組碼級別的優化程式碼了),因此一般來說,即時編譯器產生的原生代碼會比Javac產生的位元組碼更加優秀[1]。本篇部落格,我們將一起學習HotSpot虛擬機器的即時編譯器在生成程式碼時採用的程式碼優化技術。

優化技術概覽

在Sun官方的Wiki上,HotSpot虛擬機器設計團隊列出了一個相對比較全面的、 在即時編譯器中採用的優化技術列表,其中有不少經典編譯器的優化手段,也有許多針對Java語言(準確地說是針對執行在Java虛擬機器上的所有語言)本身進行的優化技術。
這裡寫圖片描述

這裡寫圖片描述

下面舉一個簡單的例子,即通過Java程式碼變化來展示其中幾種優化技術是如何發揮作用的。

優化前的原始程式碼

static class B{
int value;
final int get(){
return value;
} } p
ublic void foo(){
y=b.get();
//……do stuff……
z=b.get(); sum=y+z; }

首先需要明確的是,這些程式碼優化變換是建立在程式碼的某種中間表示或機器碼之上,絕不是建立在Java原始碼之上的,為了展示方便,這裡使用了Java語言的語法來表示這些優化技術所發揮的作用。

上述程式碼已經非常簡單了,但是仍有許多優化的餘地。 第一步進行方法內聯(Method Inlining),方法內聯的重要性要高於其他優化措施,它的主要目的有兩個,一是去除方法呼叫的成本(如建立棧幀等),二是為其他優化建立良好的基礎,方法內聯膨脹之後可以便於在更大範圍上採取後續的優化手段,從而獲取更好的優化效果。 因此,各種編譯器一般都會把內聯優化放在優化序列的最靠前位置。 內聯後的程式碼下所示

內聯後的程式碼

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

第二步進行冗餘訪問消除(Redundant Loads Elimination),假程式碼中間註釋掉的“dostuff……”所代表的操作不會改變b.value的值,那就可以把“z=b.value”替換為“z=y”,因為上一句“y=b.value”已經保證了變數y與b.value是一致的,這樣就可以不再去訪問物件b的區域性變量了。 如果把b.value看做是一個表示式,那也可以把這項優化看成是公共子表示式消除
(Common Subexpression Elimination),優化後的程式碼如下所示。

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

第三步我們進行復寫傳播(Copy Propagation),因為在這段程式的邏輯中並沒有必要使用一個額外的變數“z”,它與變數“y”是完全相等的,因此可以使用“y”來代替“z”。 複寫傳播之後程式如下所示。

複寫傳播的程式碼

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

第四步我們進行無用程式碼消除(Dead Code Elimination)。 無用程式碼可能是永遠不會被執行的程式碼,也可能是完全沒有意義的程式碼,因此,它又形象地稱為“Dead Code”,在上面程式碼
清單中,“y=y”是沒有意義的,把它消除後的程式如下所示

進行無用程式碼消除的程式碼

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

經過四次優化之後,省略了許多語句(體現在位元組碼和機器碼指令上的差距會更大),執行效率也會更高。

接下來,我們將繼續檢視如下的幾項最有代表性的優化技術是如何運作的,它們分別是:

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

公共子表示式消除

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

簡單的例子來說明它的優化過程:

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

如果這段程式碼交給Javac編譯器則不會進行任何優化,那生成的程式碼將如下所示,是完全遵照Java原始碼的寫法直譯而成的。

未做任何優化的位元組碼

iload_2//b
imul//計算b * c
bipush 12//推入12
imul//計算(c * b)*12
iload_1//a
iadd//計算(c * b)*12+a
iload_1//a
iload_2//b
iload_3//c
imul//計算b * c
iadd//計算a+b * c
iadd//計算(c * b)*12+a+(a+b * c)
istore 4

當這段程式碼進入到虛擬機器即時編譯器後,它將進行如下優化:編譯器檢測到“c * b”與“b* c”是一樣的表示式,而且在計算期間b與c的值是不變的。 因此,這條表示式就可能被視為:

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

這時,編譯器還可能(取決於哪種虛擬機器的編譯器以及具體的上下文而定)進行另外一種優化:代數化簡,把表示式變為:

int d=E*13+a*2

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

陣列邊界檢查消除

陣列邊界檢查消除(Array Bounds Checking Elimination)是即時編譯器中的一項語言相關的經典優化技術。 我們知道Java語言是一門動態安全的語言,對陣列的讀寫訪問也不像C、 C++那樣在本質上是裸指標操作。 如果有一個數組foo[],在Java語言中訪問陣列元素foo[i]的時候系統將會自動進行上下界的範圍檢查,即檢查i必須滿足i>=0&&i<foo.length這個條件,否則將丟擲一個執行時異常:java.lang.ArrayIndexOutOfBoundsException。 但是對於虛擬機器的執行子系統來說,每次陣列元素的讀寫都帶有一次隱含的條件判定操作,對於擁有大量陣列訪問的程式程式碼,這無疑也是一種效能負擔。

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

將這個陣列邊界檢查的例子放在更高的角度來看,大量的安全檢查令編寫Java程式比編寫C/C++程式容易很多, 但這些安全檢查也導致了相同的程式,Java要比C/C++做更多的事情(各種檢查判斷),這些事情就成為一種隱式開銷,如果處理不好它們,就很可能成為一個Java語言比C/C++更慢的因素。 要消除這些隱式開銷,
除了如陣列邊界檢查優化這種儘可能把執行期檢查提到編譯期完成的思路之外,另外還有一種避免思路——隱式異常處理,Java中空指標檢查和算術運算中除數為零的檢查都採用了這種思路。 舉個例子,例如程式中訪問一個物件(假設物件叫foo)的某個屬性(假設屬性叫value),那以Java虛擬碼來表示虛擬機器訪問foo.value的過程如下。

if(foo!=null){
return foo.value;
}else{
throw new NullPointException();
}

在使用隱式異常優化之後,虛擬機器會把上面虛擬碼所表示的訪問過程變為如下虛擬碼。

try{
return foo.value;
}catch(segment_fault){
uncommon_trap();
}

虛擬機器會註冊一個Segment Fault訊號的異常處理器(虛擬碼中的uncommon_trap()),這樣當foo不為空的時候,對value的訪問是不會額外消耗一次對foo判空的開銷的。 代價就是當foo真的為空時,必須轉入到異常處理器中恢復並丟擲NullPointException異常,這個過程必須從使用者態轉到核心態中處理,結束後再回到使用者態,速度遠比一次判空檢查慢。 當foo極少為空的時候,隱式異常優化是值得的,但假如foo經常為空的話,這樣的優化反而會讓程式更慢,還好HotSpot虛擬機器足夠“聰明”,它會根據執行期收集到的Profile資訊自動選擇最優方案。

方法內聯

未做任何優化的位元組碼

public static void foo(Object obj){
if(obj!=null){
System.out.println("do something");
}}p
ublic static void testInline(String[]args){
Object obj=null;
foo(obj);
}

例子程式碼揭示了內聯對其他優化手段的意義:事實上testInline()方法的內部全部都是無用的程式碼,如果不做內聯,後續即使進行了無用程式碼消除的優化,也無法發現任何“Dead Code”,因為如果分開來看,foo()和testInline()兩個方法裡面的操作都可能是
有意義的。

無法內聯的原因是,只有使用invokespecial指令呼叫的私有方法、 例項構造器、 父類方法以及使用invokestatic指令進行呼叫的靜態方法才是在編譯期進行解析的,除了上述4種方法之外,其他的Java方法呼叫都需要在執行時進行方法接收者的多型選擇,並且都有可能存在多於一個版本的方法接收者(最多再除去被final修飾的方法這種特殊情況,儘管它使用invokevirtual指令呼叫,但也是非虛方法,Java語言規範中明確說明了這點),簡而言之,Java語言中預設的例項方法是虛方法。

對於一個虛方法,編譯期做內聯的時候根本無法確定應該使用哪個方法版本,如果以上述程式碼中把“b.get()”內聯為“b.value”為例的話,就是不依賴上下文就無法確定b的實際型別是什麼。 假如有ParentB和SubB兩個具有繼承關係的類,並且子類重寫了父類的get()方法,那麼,是要執行父類的get()方法還是子類的get()方法,需要在執行期才能確定,編譯期無法得出結論。

為了解決虛方法的內聯問題,Java虛擬機器設計團隊想了很多辦法,首先是引入了一種名為“型別繼承關係分析”(Class Hierarchy Analysis,CHA)的技術,這是一種基於整個應用程式的型別分析技術,它用於確定在目前已載入的類中,某個介面是否有多於一種的實現,某個類是否存在子類、 子類是否為抽象類等資訊。

編譯器在進行內聯時,如果是非虛方法,那麼直接進行內聯就可以了,這時候的內聯是有穩定前提保障的。 如果遇到虛方法,則會向CHA查詢此方法在當前程式下是否有多個目標版本可供選擇,如果查詢結果只有一個版本,那也可以進行內聯,不過這種內聯就屬於激進優化,需要預留一個“逃生門”(Guard條件不成立時的Slow Path),稱為守護內聯(GuardedInlining)。 如果程式的後續執行過程中,虛擬機器一直沒有載入到會令這個方法的接收者的繼
承關係發生變化的類,那這個內聯優化的程式碼就可以一直使用下去。 但如果載入了導致繼承關係發生變化的新類,那就需要拋棄已經編譯的程式碼,退回到解釋狀態執行,或者重新進行編譯。

如果向CHA查詢出來的結果是有多個版本的目標方法可供選擇,則編譯器還將會進行最後一次努力,使用內聯快取(Inline Cache)來完成方法內聯,這是一個建立在目標方法正常入口之前的快取,它的工作原理大致是:在未發生方法呼叫之前,內聯快取狀態為空,當第一次呼叫發生後,快取記錄下方法接收者的版本資訊,並且每次進行方法呼叫時都比較接收者版本,如果以後進來的每次呼叫的方法接收者版本都是一樣的,那這個內聯還可以一直用下去。 如果發生了方法接收者不一致的情況,就說明程式真正使用了虛方法的多型特性,這時才會取消內聯,查詢虛方法表進行方法分派。

逃逸分析

逃逸分析(Escape Analysis)是目前Java虛擬機器中比較前沿的優化技術,它與型別繼關係分析一樣,並不是直接優化程式碼的手段,而是為其他優化手段提供依據的分析技術。

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

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

棧上分配(Stack Allocation):Java虛擬機器中,在Java堆上分配建立物件的記憶體空間幾乎是Java程式設計師都清楚的常識了,Java堆中的物件對於各個執行緒都是共享和可見的,只要持有這個物件的引用,就可以訪問堆中儲存的物件資料。 虛擬機器的垃圾收集系統可以回收堆中不再使用的物件,但回收動作無論是篩選可回收物件,還是回收和整理記憶體都需要耗費時間。
如果確定一個物件不會逃逸出方法之外,那讓這個物件在棧上分配記憶體將會是一個很不錯的主意,物件所佔用的記憶體空間就可以隨棧幀出棧而銷燬。 在一般應用中,不會逃逸的區域性物件所佔的比例很大,如果能使用棧上分配,那大量的物件就會隨著方法的結束而自動銷燬了,垃圾收集系統的壓力將會小很多。

標量替換(Scalar Replacement):標量(Scalar)是指一個數據已經無法再分解成更小的資料來表示了,Java虛擬機器中的原始資料型別(int、 long等數值型別以及reference型別等)都不能再進一步分解,它們就可以稱為標量。 相對的,如果一個數據可以繼續分解,那它就稱作聚合量(Aggregate),Java中的物件就是最典型的聚合量。 如果把一個Java物件拆散,根據程式訪問的情況,將其使用到的成員變數恢復原始型別來訪問就叫做標量替換。 如果逃
逸分析證明一個物件不會被外部訪問,並且這個物件可以被拆散的話,那程式真正執行的時候將可能不建立這個物件,而改為直接建立它的若干個被這個方法使用到的成員變數來代替。 將物件拆分後,除了可以讓物件的成員變數在棧上(棧上儲存的資料,有很大的概率會被虛擬機器分配至物理機器的高速暫存器中儲存)分配和讀寫之外,還可以為後續進一步的優化手段建立條件。

Sun JDK 1.6才實現了逃逸分析,而且直到現在這項優化尚未足夠成熟,仍有很大的改進餘地。 不成熟的原因主要是不能保證逃逸
分析的效能收益必定高於它的消耗。 如果要完全準確地判斷一個物件是否會逃逸,需要進行資料流敏感的一系列複雜分析,從而確定程式各個分支執行時對此物件的影響。 這是一個相對高耗時的過程,如果分析完後發現沒有幾個不逃逸的物件,那這些執行期耗用的時間就白白浪費了,所以目前虛擬機器只能採用不那麼準確,但時間壓力相對較小的演算法來完成逃逸分析。 還有一點是,基於逃逸分析的一些優化手段,如上面提到的“棧上分配”,由於HotSpot虛
擬機目前的實現方式導致棧上分配實現起來比較複雜,因此在HotSpot中暫時還沒有做這項優化。

如果有需要,並且確認對程式執行有益,使用者可以使用引數-XX:+DoEscapeAnalysis來手動開啟逃逸分析,開啟之後可以通過引數-XX:+PrintEscapeAnalysis來檢視分析結果。 有了逃逸分析支援之後,使用者可以使用引數-XX:+EliminateAllocations來開啟標量替換,使用+XX:+EliminateLocks來開啟同步消除,使用引數-XX:+PrintEliminateAllocations檢視標量的替換情況。
儘管目前逃逸分析的技術仍不是十分成熟,但是它卻是即時編譯器優化技術的一個重要的發展方向,在今後的虛擬機器中,逃逸分析技術肯定會支撐起一系列實用有效的優化技術。

Java與C/C++的編譯器對比

Java虛擬機器的即時編譯器與C/C++的靜態優化編譯器相比,可能會由於下列這些原因而導致輸出的原生代碼有一些劣勢(下面列舉的也包括一些虛擬機器執行子系統的效能劣勢):

第一,因為即時編譯器執行佔用的是使用者程式的執行時間,具有很大的時間壓力,它能提供的優化手段也嚴重受制於編譯成本。 如果編譯速度不能達到要求,那使用者將在啟動程式或程式的某部分察覺到重大延遲,這點使得即時編譯器不敢隨便引入大規模的優化技術,而編譯的時間成本在靜態優化編譯器中並不是主要的關注點。

第二,Java語言是動態的型別安全語言,這就意味著需要由虛擬機器來確保程式不會違反語言語義或訪問非結構化記憶體。 從實現層面上看,這就意味著虛擬機器必須頻繁地進行動態檢查,如例項方法訪問時檢查空指標、 陣列元素訪問時檢查上下界範圍、 型別轉換時檢查繼承關係等。 對於這類程式程式碼沒有明確寫出的檢查行為,儘管編譯器會努力進行優化,但是總體上仍然要消耗不少的執行時間。

第三,Java語言中雖然沒有virtual關鍵字,但是使用虛方法的頻率卻遠遠大於C/C++語言,這意味著執行時對方法接收者進行多型選擇的頻率要遠遠大於C/C++語言,也意味著即時編譯器在進行一些優化(如前面提到的方法內聯)時的難度要遠大於C/C++的靜態優化編譯器。

第四,Java語言是可以動態擴充套件的語言,執行時載入新的類可能改變程式型別的繼承關係,這使得很多全域性的優化都難以進行,因為編譯器無法看見程式的全貌,許多全域性的優化措施都只能以激進優化的方式來完成,編譯器不得不時刻注意並隨著型別的變化而在執行時撤銷或重新進行一些優化。

第五,Java語言中物件的記憶體分配都是堆上進行的,只有方法中的區域性變數才能在棧上分配。 而C/C++的物件則有多種記憶體分配方式,既可能在堆上分配,又可能在棧上分配,如果可以在棧上分配執行緒私有的物件,將減輕記憶體回收的壓力。 另外,C/C++中主要由使用者程式程式碼來回收分配的記憶體,這就不存在無用物件篩選的過程,因此效率上(僅指執行效率,排除了開發效率)也比垃圾收集機制要高。

但從另外的角度來說,還有許多優化是Java的即時編譯器能做而C/C++的靜態優化編譯器不能做或者不好做的。 例如,在C/C++中,別名分析(Alias Analysis)的難度就要遠高於Java。 Java的型別安全保證了在類似如下程式碼中,只要ClassA和ClassB沒有繼承關係,那物件objA和objB就絕不可能是同一個物件,即不會是同一塊記憶體兩個不同別名。

void foo(ClassA objA,ClassB objB){
objA.x=123;
objB.y=456;
//只要objB.y不是objA.x的別名,下面就可以保證輸出為123
print(objA.x);
}

確定了objA和objB並非對方的別名後,許多與資料依賴相關的優化才可以進行(重排序、 變數代換)。 具體到這個例子中,就是無須擔心objB.y其實與objA.x指向同一塊記憶體,這樣就可以安全地確定列印語句中的objA.x為123。

Java編譯器另外一個紅利是由它的動態性所帶來的,由於C/C++編譯器所有優化都在編譯期完成,以執行期效能監控為基礎的優化措施它都無法進行,如呼叫頻率預測、 分支頻率預測、 裁剪未被選擇的分支等,這些都會成為Java語言獨有的效能優勢