JVM之GC演算法和種類
垃圾收集演算法
這裡說說5種GC演算法,在說GC演算法前,先談談判斷物件是否活著的可達性分析演算法
- 可達性分析演算法
- 引用計數演算法
- 標記-清除演算法
- 標記-整理演算法
- 複製演算法
- 分代收集演算法
可達性分析演算法
垃圾回收,首先要判斷物件是否還活著.
Java通過可達性分析(Reachability Analysis)來判定物件是否存活
演算法基本思路:
- 通過一系列稱為”GC Roots”的物件作為起點,從這些節點開始向下搜尋,搜尋走過的路徑,叫做引用鏈(Reference Chain)
- 當一個物件到GC Roots沒有任何引用鏈相連,從圖論來說,就是從GC Roots到物件不可達,此時判定物件不可用
Java中,可作為GC Roots的物件包括四種
- VM Stack(棧幀中的本地變量表)中引用的物件
- 本地方法棧中JNI(即Navtive方法)引用的物件
- 方法區中類靜態屬性引用的物件
- 方法區中常量引用的物件
建立–>可達<–>可恢復–>不可達–>垃圾回收
強引用StringReference
建立物件,並把物件賦給一個引用變數,程式通過這個引用變數來操作物件,物件和陣列都採用了強引用。
一個物件被一個或以上引用變數引用,則處於可達狀態,不會被GC
軟引用SoftReference
- 要用java.lang.ref.SoftReference實現
- 描述有用但非必需的物件
- 記憶體空間足夠時,不會被GC;否則,可能會被GC
- 常用於對記憶體敏感的程式中
- get()獲取引用的物件
弱引用WeakReference
- java.lang.ref.WeakReference實現
- 描述非必需物件
- 不管記憶體是否足夠,都會被GC
- get()獲取引用的物件
虛引用PhantomReference
- java.lang.ref.PhantomRefernce必須和java.lang.ref.RefernceQueue引用佇列聯合使用
- 類似於沒有引用,和沒有引用的效果大致相同
- 主要用於跟蹤物件被垃圾回收的狀態
- !!! get()無法獲取引用的物件
- GC後,虛引用會被放入引用佇列
即使在可達性分析演算法中不可達的物件,也並非是“非死不可”,這時還處於“緩刑”狀態,真正標記一個物件的死亡,至少經歷兩次標記過程
- 物件在可達性分析後,發現沒有GC Roots相連線的引用鏈,此時被第一次標記,且進行一次篩選
- 篩選的條件是:是否有必要執行物件的finalize()方法
- 物件沒有重寫finalze()方法,或者finalize()已經被虛擬機器呼叫過–>沒必要執行
- 如果被判定為“有必要執行”,那麼物件會被放到一個叫做F-Queue的佇列中,並在稍後由一個JVM自動建立的、低優先順序的Finalizer執行緒去執行finalize()方法.
- 稍後,GC將對F-Queue中的物件進行第二次小規模的標記. 如果物件沒有在finalize()中復活,那基本真的被回收了
引用計數演算法(Reference Counting)
老牌的垃圾回收演算法
給物件新增一個引用計數器,有一個地方引用它時,計數器+1;引用失效時,-1;計數器為0,物件就是不可能再被使用
客觀來說,實現簡單,判定效率高
應用如:微軟的Component Object Model技術,Python,ActionScript3
但主流Java虛擬機器沒有用,因為很難解決物件間的迴圈引用問題
- 引用和去引用伴隨+,-,影響效能
這涉及到如何判定物件是否可達,或者說是否活著,被引用著
標記-清除演算法(Mark-Sweep)
- 最基礎的演算法,分為“標記”,“清除”兩個階段
- 這是所有收集演算法的基礎,後續演算法都是基於這種思路,並對其不足進行改進而得到的。
- 首先,標記出需要回收的物件
接著,標記完成後,統一回收所有標記的物件
主要問題:
- 效率: 標記和清除兩個過程的效率都不高
- 空間碎片: 標記清除後會產生大量不連續的記憶體碎片,碎片過多會導致大物件無法分配到足夠的連續記憶體,從而不得不提前觸發GC,甚至Stop The World
複製演算法(Copying)
為解決效率問題,複製演算法出現了
將記憶體分為大小相等的兩塊,每次只用一塊
- 在一塊記憶體中,當它用完了,就把還或者的物件複製到另外一塊大小相等的記憶體中,連續儲存;同時,一次過清理已經使用過的記憶體空間
- 相當於每次都回收整個半區,不用考慮碎片問題,只需移動堆頂指標,按順序分配,實現簡單,執行高效
- 主要問題
- 效率: 在物件存活率較高時,複製操作次數多,效率降低
- 空間: 記憶體縮小了一半;需要額外空間做分配擔保(老年代)
- From Survivor, To Survivor使用的就是複製演算法。老年代不使用這種演算法
標記-整理(Mark-Compact)
根據老年代的特點,提出了標記-整理/壓縮演算法
標記階段與標記-清除演算法一樣
- 後續,讓存活物件都向一端移動,對齊,直接清理掉端邊界以外的記憶體
- 老年代使用的演算法
分代收集演算法(Generational Collection)
當前的商業虛擬機器的垃圾回收都採用 分代收集 演算法。根據物件存活週期的不同,將記憶體劃分為幾塊。
一般將Java堆分為新生代和老年代。
新生代: 每次GC都有大批物件死去,只有少量存活
- 使用複製演算法,只需要複製少量存活的物件
老年代:對此存活率高,沒有額外空間做分配擔保
- 使用 標記-清除,標記-整理兩種演算法
HotSpot的演算法實現
首先,要解決的,是可達性分析,從GC Roots中找物件是否有可達的引用鏈。
列舉GC Roots根節點
可做GC Roots節點的,上文說過,主要有 全域性性的引用(包括常量或類靜態屬性),執行上下文(棧幀中的本地變量表和JNI引用的物件). 問題是,現在的應用,僅方法區就數百Megabyte,如果逐個檢查這裡邊的引用,必然消耗很多時間
可達性分析必須在一個能確保一致性的記憶體快照中進行,分析期間,記憶體系統不能出現變化,否則,會出現分析不正確的情況,例如,前腳這邊標記了A物件不可達,此時記憶體系統還在執行,萬一A又被引用了,但A已經被標記為不可達。所以,得有GC停頓,號稱幾乎不會GC停頓的CMS收集器,在列舉根節點時,也必須停頓
目前主流VM使用準確式GC,所以不需要一個不漏地檢查完所有的全域性和上下文的引用的位置,虛擬機器有辦法直接得知哪些地方存放著物件的引用
HotSpot的實現中,使用一組叫做OopMap的資料結構來達到目的。
(1)類載入完成時,HotSpot就把物件內什麼偏移量上是什麼型別的資料計算出來。
(2)在JIT編譯過程中,會在特定的位置記錄下棧和暫存器中,哪些位置是引用
這樣,GC掃描時,就可以直接得知這些資訊了
安全點(SafePoint)
在OopMap(Ordinary Object Pointer Map)的協助下,HotSpot可以快速準確完成GC Roots列舉。
問題是,引用關係變化,OopMap內容變化的指令非常多,如果為每一條指令生成對應的OopMap,那會需要有大量的空間,GC的空間成本會變得很高
事實上,HotSpot沒有為每條指令生成OopMap,前面說了,是在“特定位置”,這個特定位置,就是安全點SafePoint. 程式執行不是所有地方都停頓下來GC,而是到安全點,才停頓。
安全點選定
所以,安全點的選定,不能太過少,否則GC等待時間太長,產生OOM;也不能太過頻繁,否則會增大執行時的負荷。
因此,安全點選擇的準則:
- 是否具有讓程式長時間執行的特徵
具有這個特徵的就是指令序列複用,例如方法呼叫,迴圈跳轉,異常跳轉。所以,具有這些功能的指令才會產生SafePoint
讓所有執行緒跑到最近的安全點
另一個問題,發生GC時,如何讓所有執行緒跑到最近的安全點,除了JNI呼叫的執行緒。
兩種方案
- 搶佔式中斷(Preemptive Suspension)
- GC來時,先中斷執行緒,若發現執行緒不在安全點上,恢復執行緒,讓它跑的安全點
- 現在,幾乎沒有VM使用這種方式
- 主動式中斷(Voluntary Suspension)
- 不主動操作執行緒,GC來時,簡單地設個標記,各個執行緒執行時主動輪詢這個標記,發現為真時,自己中斷掛起
- 輪詢標記的地方和安全點重合,另外加上建立物件時需要分配記憶體的地方
安全區域(Safe Region)
SafePoint只是解決獲得CPU執行時間的執行緒,在不太長的時間內,可以遇到GC的SafePoint的問題。
但沒有分配到CPU執行時間的執行緒,如Sleep,Block狀的執行緒,它們無法響應JVM的中斷請求,跑到SafePoint中斷掛起,JVM顯然也不會等待執行緒重新獲得CPU時間
對這一問題,需要安全區域Safe Region
安全區域
- 一段程式碼片段中,引用關係不會發生變化
- 這個區域之中,任意地方開始GC,都是安全的
- 可以看作SafePoint的擴充套件
當執行緒執行到安全區域中的程式碼時,首先標識自己已經進入Safe Region。那麼,這段時間裡JVM發起GC時,就不用管標識自己在Safe Region狀態的執行緒了,因為這些執行緒的引用關係不會發生變化。
執行緒離開Safe Region時,要檢查系統是否已經完成GC Roots列舉或者整個GC過程。如果沒有,就必須等待,直到收到可以安全離開的訊號為止;如果完成了,執行緒繼續執行。