在《深入理解 Java 虛擬機器》中有這樣一段話:
“隨著 JIT 編譯器的發展和逃逸分析技術的逐漸成熟,棧上分配、標量替換優化技術將會導致一些微妙的變化,所有的物件分配到堆上也漸漸不那麼絕對了”。
逃逸分析
在編譯期間,JIT 會對程式碼做很多優化,其中有一部分優化的目的就是減少記憶體堆分配的壓力,其中一項重要的技術叫做逃逸分析。
-
方法逃逸
逃逸分析基本行為就是分析物件動態作用域:當一個物件在方法中被定義後,它可能會被外部方法呼叫,例如作為呼叫引數傳遞到其他方法中,稱為方法逃逸。 -
執行緒逃逸
甚至還有可能被外部執行緒訪問到,譬如複製給類變數或者在其他執行緒中訪問的例項變數。成為執行緒逃逸。 -
目的
通過逃逸分析,HotSpot 編譯器能夠分析出一個新的物件的引用的使用範圍,從而決定是否要將這個物件分配到堆上。 -
開啟、關閉與檢視
在JDK 6 Update 23
版本之後,HotSpot 中預設就開啟了逃逸分析。
開啟: -XX: +DoEscapeAnalysis (只能在 server 模式下開啟:-server)
關閉: -XX: -DoEscapeAnalysis
檢視分析結果:-XX: +PrintEscapeAnalysis
優化手段
如果能證明一個物件不會逃逸到方法或執行緒之外,也就是別的方法或執行緒無法通過任何途徑訪問到這個物件,則可能為這個變數進行一些高效優化,如下所示:
1. 棧上分配(Stack Alloction)
-
JVM中,在Java堆上分配建立物件的記憶體空間。Java堆中的物件對於各個執行緒都是共享和可見的,只要持有這個物件的引用,就可以訪問堆中儲存的物件資料。
-
JVM中垃圾收系統可以回收堆中不再使用的物件,但回收動作無論是篩選可回收物件,還是回收和整理記憶體都需要耗費時間。
-
如果確定一個物件不會逃逸出方法之外,那讓這個物件在棧上分配記憶體將會是一個不錯的主意,物件所佔用的記憶體空間就可以隨棧幀出棧而銷燬。在一般應用中,不會逃逸的區域性變數所佔的比例很大,如果能使用棧上分配,那大量的物件就會隨著方法的結束而自動銷燬了,垃圾收集系統的壓力將會小很多。
TLAB 上分配
- Thread Local Allocation Buffer,執行緒本地分配快取。
- 為了加速物件分配。由於物件一般在堆上,而堆是共享的,需要同步。
- 佔用 eden 區空間,在 TLAB 啟用情況下,虛擬機器會為每一個執行緒分配一塊 TLAB 空間。預設很小(2048)。
- 開啟/ 檢視: -XX: +UserTLAB / -XX: +PrintTLAB
- -XX:TLABSize 指定大小
- -XX:-ResizeTLAB 大小會一直調整,可以禁止調整,一次性設定值。
- 物件分配流程圖
1. 編譯器通過逃逸分析,確定物件是在棧上分配還是在堆上分配。如果是在堆上分配,則進入選項 2.如果 tlab_top + size <= tlab_end,則在在 TLAB 上直接分配物件並增加 tlab_top 的值,如果現有的 TLAB 不足以存放當前物件則 3.重新申請一個 TLAB,並再次嘗試存放當前物件。如果放不下,則 4 4.在 Eden 區加鎖(這個區是多執行緒共享的),如果 eden_top + size <= eden_end 則將物件存放在 Eden 區,增加 eden_top 的值,如果 Eden 區不足以存放,則 5 5.執行一次 Young GC(minor collection)。 6. 經過 Young GC 之後,如果 Eden 區仍然不足以存放當前物件,則直接分配到老年代。 複製程式碼
2. 同步消除(Synchronization Elimination)
- 執行緒同步
圖11-2描述了兩個執行緒讀寫相同變數的假設例子。 在這個例子中,執行緒 A 讀取變數然後給這個變數賦予一個新的值,但寫操作需要兩個儲存器週期。 當執行緒 B 在這兩個儲存器寫週期中間讀取這個相同的變數時,它就會得到不一致的值。 為了解決這個問題,執行緒不得不使用鎖,在同一時間只允許一個執行緒訪問該變數。 圖 11-3 描述了這種同步。 如果執行緒 B 希望讀取變數,它首先要獲取鎖; 同樣地,當執行緒 A 更新變數時,也需要獲取這把同樣的鎖。 因而執行緒 B 線上程 A 釋放鎖以前不能讀取變數。 複製程式碼
- 執行緒同步本身是一個相對耗時的過程,如果逃逸分析能夠確定一個變數不會逃逸出執行緒,無法被其他執行緒訪問,那這個變數的讀寫肯定就不會有競爭,對這個變數實施的同步措施也就可以消除掉。
- 開啟:-XX: +EliminateAllocations
- 檢視標量的替換情況:-XX: +PrintEliminateAllocations
3.標量替換(Scalar Replacement)
- 標量(Scalar)
是一個資料已經無法再分解成更小的資料來表示了,JVM中的原始資料型別(int、long等數值型別以及reference型別等)都不能進一步分解,他們就可以成為標量。 - 聚合量(Aggregation)
相對的,如果一個資料可以繼續分解,那它就稱為聚合量(Aggregation),Java中的物件就是最典型的聚合量。 - 標量替換
如果把一個Java物件拆解,根據程式訪問的情況,將其使用到的成員變數恢復原始型別來訪問就叫做標量替換。 - 條件
如果逃逸分析證明一個物件不會被外部訪問,並且這個物件可以被拆解的話,那程式真正執行的時候將可能不在建立這個物件,而改為直接建立它的若干個被這個方法使用到的成員變數來代替。 - 優點
將物件拆分後,除了可以讓物件的成員變數在棧上(棧上儲存的資料,有很大的概率會被JVM分配至物理機的告訴暫存器中儲存)分配合讀寫之外,還可以為後續進一步優化手段建立條件。 - 開啟: -XX: +EliminateLocks
- 關閉: -XX: -EliminateLocks
並不成熟
關於逃逸分析的論文在 1999 年就已經發表了,但直到 JDK 1.6 才有實現,而且這項技術到如今也並不是十分成熟的。
在很長的一段時間裡,即使是Server Compiler,也預設不開啟逃逸分析,甚至在某些版本(如 JDK 1.6 Update 18
)中還曾經短暫地完全禁止了這項優化。
其根本原因就是無法保證逃逸分析的效能消耗一定能高於他的消耗。雖然經過逃逸分析可以做標量替換、棧上分配、和鎖消除。但是逃逸分析自身也是需要進行一系列複雜的分析的,這其實也是一個相對耗時的過程。
一個極端的例子,就是經過逃逸分析之後,發現沒有一個物件是不逃逸的。那這個逃逸分析的過程就白白浪費掉了。
雖然這項技術並不十分成熟,但是他也是即時編譯器優化技術中一個十分重要的手段,從效能分析中來看,使用逃逸分析的優化還是很有必要的。
參考來源:
周志明 《深入理解Java虛擬機器》
Java 中的逃逸分析和 TLAB 以及 Java 物件分配
關於棧上分配和 TLAB 的理解