1. 程式人生 > >JVM優化之逃逸分析與分配消除

JVM優化之逃逸分析與分配消除

要了解逃逸分析背後的基本原理,我們先來看下這段有問題的C程式碼——當然這個是沒法用Java來寫的:

這段C程式碼在棧上建立了一個int型別的變數,然後把它的指標作為函式的返回值返回了。這樣做是有問題的,因為當gettheint()函式返回的時候,int所在的棧幀就已經被銷燬了,後面你再去訪問這個地址的話,就不知道里面儲存的到底是什麼了。

Java平臺設計的一個主要目標就是要消除這種型別的bug。從設計上,JVM就不具備這種低階的“根據位置索引來讀記憶體”的能力。這類操作對應的Java位元組碼是putfield和getfield。

來看下這段Java程式碼:

這段程式碼建立了一億對隨機大小的矩形,並去計算有多少對是大小一樣的。每次迭代都會建立一對新的矩形。你可能會認為main方法裡會建立2億個Rect物件:一億個r1,一億個r2。

不過,如果某個物件只是在方法內部建立並使用的話——也就是說,它不會傳遞到另一個方法中或者作為返回值返回——那麼執行時程式就還能做得更聰明一些。你可以說這個物件是沒有逃逸出去的,因此執行時(其實就是JIT編譯器)做的這個分析又叫做逃逸分析。

如果一個物件沒有逃逸出去,那也就是說JVM可以針對這個物件做一些類似“棧自動分配”的事情。在這個例子當中,這個物件不會從堆上分配空間,因此它也不需要垃圾回收器來回收。一旦使用這個“棧分配(stack-allocated)”物件的方法返回了,這個物件所佔用的記憶體也就自動被釋放掉了。

事實上,HotSpot VM的C2編譯器做的事情要比棧分配要複雜得多。我們現在就來看一下。

在HotSpot VM的原始碼中,可以看到逃逸分析系統是如何對物件的使用進行分類的:

第一類說明這個物件可以用標量來代替。這種分配消除技術叫標量替換(scalar replacement)。這意味著這個物件會被拆解成它的構成欄位,這就相當於分配物件的操作變成了在方法內部建立多個區域性變數。完成這個之後,另一項HotSpot VM的JIT技術會參與進來,它會將這些欄位(事實上已經是區域性變量了)儲存到CPU的暫存器中(如果有必要就儲存在棧上)。

Java平臺的主要挑戰是執行模型非常複雜。在上述例子中,如果只看原始碼,你會認為r1物件是不會逃逸出main方法外的,但r2會作為引數傳給r1的sameArea方法,因此它逃逸出了main方法外。

根據上面的分類,乍一看的話r1應該歸類為NoEscape,而r2應該歸為ArgEscape;不過這個結論是錯誤的,原因有幾點。

第一,回想一下,Java中的方法呼叫最終會通過編譯器替換為位元組碼invoke。它會把呼叫目標(也就是接收物件,注:即要呼叫的物件)和入參填充到棧中,然後查詢到這個方法,再分發給它(也就是執行這個方法)。

這意味著接收物件也被傳入了呼叫的方法中(它就是呼叫的方法裡的this物件)。因此接收物件也逃逸出了當前域;在這個例子中,這意味著如果逃逸分析分析完這段Java程式碼,r1和r2都會歸類為ArgEscape。

如果就只是這樣的話,那麼分配消除的使用場景就很有限了。所幸的是,HotSpot VM能做得更好。我們來仔細看一下它的位元組碼,看看能發現什麼。

sameArea()方法很小(只有17個位元組的位元組碼),在本例中也會被頻繁呼叫,因此它是方法內聯(method inlined)的一個理想物件。

這個方法又呼叫了兩次area()方法(這個也是可以內聯的):

通過JITWatch或者PrintCompilation可以看到,area()方法的呼叫的確被內聯進了呼叫方sameArea()方法裡,而sameArea()又被內聯到了main()方法的迴圈體中。JITWatch為內聯方法提供了一個很方便的圖形化展示(如圖一所示)。

圖一

請記住Java HotSpot VM的JIT編譯器的優化順序也是很重要的。方法內聯是最早的優化,也被稱為閘道器優化(gateway optimization),因為它首先把相關聯的程式碼都聚合在了一起,為其它優化打開了大門。

現在sameArea()方法和area()方法都被內聯進來了,方法域的問題不復存在,所有的變數都只在main方法的作用域內了。也就是說逃逸分析不會再把r1和r2視作ArgEscape型別:方法內聯之後,它們現在都被歸類為NoEscape。

這個結果看起來可能有悖常理,不過你需要記住的是JIT編譯器並不是通過原始程式碼來進行優化的。如果不知道這點,就搞不清楚哪些情況能夠進行逃逸分析。

前面的例子中,這些物件的分配都不會在堆上進行了,會把它們的欄位拆解成獨立的值。暫存器分配器通常會把拆解出來的欄位直接放到暫存器中,不過如果沒有足夠可用的暫存器,那剩下的欄位會被儲存到棧上。這種情況被稱為棧溢位(stack spill,注:和stack overflow不同)。

在逃逸分析開啟和關閉的模式下分別執行這個程式,再觀察下GC的活動,你就能看到密集迴圈中堆分配消除的巨大威力。

在現代JVM中逃逸分析是預設開啟的,得通過JVM引數-XX:-DoEscapeAnalysis來關掉它。

下面是開啟了逃逸分析之後的GC日誌(一些細節刪除了):

從日誌中可以看到根本沒有發生GC事件——只是在程序退出時往日誌裡記錄了下堆的摘要資訊。如果再看下關閉逃逸分析後的執行日誌,情況就截然不同了:

這裡可以很清楚地看到,由於Eden區空間滿了,導致了記憶體分配失敗、需要進行垃圾回收,因此觸發了GC事件。

結論

逃逸分析是Java HotSpot VM引入的一項非常有用的升級。這項功能仍在開發階段時,實際測試中它帶來的效能提升就有3%到6%。

對於那些對平臺特性的實現過程和原理感興趣的開發人員來說,逃逸分析有個很有意思的特點:這項特性依賴於其它優化(自動內聯),