物件並不一定都是在堆上分配記憶體的

ens發表於2018-12-15

在《深入理解 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 的理解

相關文章