3.1 – 3.3 垃圾收集器與記憶體分配策略
如何確定對象已經無效
-
引用
-
JDK1.2之前,reference類型僅僅代表數據中存儲的數值代表的是另一塊記憶體的地址。
-
JDK1.2之後,reference類分為強引用、軟引用、弱引用和虛引用(Phantom Reference)。
-
強引用:傳統定義的引用,例如
Object obj = new Object();
這種引用賦值。- 只要強引用關係還存在,垃圾回收器就不會回收被引用的對象。
-
軟引用:指一些還有用,但是非必須的對象,JDK1.2後可以用SoftReference類來實現軟引用。
常用於小記憶體設備的 Cache 中,可以先清理掉快取以保證不發生OOM,在後續合適的時機再將它們重新載入到 Cache 中。也常用於記憶體敏感的場景中。
- 當系統要發生記憶體溢出前,會將只被軟引用的對象加入回收範圍進行二次回收。如果任然沒有足夠的記憶體,才會拋出記憶體溢出異常。
import java.lang.ref.SoftReference; public class Test { public static void main(String[] args) { SoftReference<String> sr = new SoftReference<String>(new String("softReference")); // 如果 sr 已經被回收,那麼 sr.get() 返回 null,否則返回其引用 System.out.println(sr.get()); } }
-
弱引用:同指還有用,但是非必須的對象,但是比軟引用弱,JDK1.2後可以有WeakRefernece來實現弱引用。
軟引用一般用於保存對象的引用而不影響其 GC 過程。常用於 Debug 和 記憶體監視軟體之中。
JDK的Proxy將動態生成的Class實例暫存於一個由Weakrefrence構成的Map中作為Cache。
- 其和軟引用的區別是:只被弱引用的對象在發生垃圾回收時就會被回收,而只被軟引用的對象要等到記憶體不足時才會被回收。
-
虛引用(幽靈引用、幻影引用):最弱的引用關係,不會影響被引用對象的生存時間,也無法通過虛引用獲得一個對象實例,JDK1.2後可以用PhantomReference來實現虛引用。
創建虛引用時需要和一個隊列關聯,在虛引用所引用的對象執行 finalize() 方法後該對象被放到隊列中,可以判斷隊列中是否有該對象判斷其是否執行了回收。
- 設置虛引用關聯的唯一目的是在對象被回收時收到一個系統通知。
-
-
-
引用計數法 (reference counting)
在對象中添加一個計數器,每次當一個地方引用到此對象時則計數器+1,當引用失效時則計數器-1。
計數器為0時則代表對象已經無效。
-
優點:原理簡單,判斷快速。
-
缺點:需要額外的處理才能保證正確工作。
public class ReferenceCountingGc { public Object instance = null; private static final int _1MB = 1024 * 1024; // Byte數組僅用來佔據空間 private Byte[] bigSize = new Byte[2 * _1MB]; public static void main(String[] args) { ReferenceCountingGc objA = new ReferenceCountingGc(); ReferenceCountingGc objB = new ReferenceCountingGc(); objA.instance = objB.instance; objB.instance = objA.instance; // 原本的兩個引用已經無效,但是原指的對象的引用計數並沒有清0 objA = null; objB = null; //顯示地告訴虛擬機要進行垃圾回收了,並不一定會執行回收動作。 System.gc(); } }
-
-
可達性分析演算法 (Reachability Analysis)
設置一系列”GC Roots”作為起始節點集,從這些節點根據引用關係向下搜索,搜索路徑又稱為引用鏈。
如果某個對象無法與GC Roots相連(直接或者間接),那麼這個對象被視為無效。
-
可以被固定作為GC Roots的對象:
-
虛擬機棧(幀棧中的本地變數表)中引用的對象
public class Test { public static void main(String[] args) { // 本地變數表中的對象引用 Object obj = new Object(); // obj設為null後,原來new的對象無法到達 obj = null; } }
-
方法區中靜態屬性引用、常量引用的對象
public class Test { // 靜態屬性引用 public static Test s; public static void main(String[] args) { Test a = new Test(); a.s = new Test(); a = null; } }
-
本地方法中 JNI (java native interface)引用的對象
JNI簡單地說就是java調用其他語言編寫的程式。Java會將這些方法裝入一個新的幀棧然後放入本地方法棧中,調用只是簡單的動態連接而已。JVM虛擬機和本地方法之間交換數據的介面是JNI。
-
Java虛擬機內部的引用
-
被同步鎖 (synchronized關鍵字) 持有的對象
-
反應Java虛擬機內部情況的JMXBeans、JVMTI中註冊的回調、本地程式碼快取等
-
-
根據垃圾回收器和當前回收區域的不同,還有其他對象可以「臨時性」地加入GC Roots (如發生跨代引用時老年代中的對象)
-
-
可達性分析演算法回收對象的時間:
可達性分析演算法判斷不可達的對象,並不會被立即回收,最多會進行兩次標記過程後被回收。
-
第一次標記:
-
可達性分析演算法在發現一個對象沒有與GC Roots的引用鏈時,該對象被第一次標記。隨後進行一次篩選,篩選的標準是此對象是否需要執行 finalize() 方法。
如果對象沒有實現 finalize() 方法或者 finalize() 方法已經被虛擬機調用,那麼被判斷為無須執行 finalize() 方法 —— 直接進行垃圾回收。
- finalzie() 的三種調用情況:
- 顯示地調用 finalize() 方法
- 在系統退出時為每個對象執行一次 finalize() 方法
- 發生 GC 時
不過 finalize() 可能會被重寫,在 finalize() 中重新將對象加入到引用鏈中,所以實現 finalize() 方法的情況需要第二次小規模標記。
如果被判斷為需要執行 finalize() 方法,那麼這個對象被放在 F-Queue隊列中。虛擬機會在稍後為這個F-Queue自動建立一個低優先順序的執行緒,在這個執行緒中執行它們的 finalize() 方法。
- 這個執行只是儘力而為,它並不能保證運行 finalize() 方法。因為某些 finalize() 方法可能會導致死循環,而隊列中的其他對象則一直處於等待狀態。
- finalzie() 的三種調用情況:
-
-
第二次標記:
- 在經過一段時間後,垃圾收集器對F-Queue進行第二次小規模標記。如果對象在 finalize() 中與 GC Roots中的引用鏈相連,那麼其會被移除」即將回收「的集合。否則對象將最終被回收。
-
對象自我拯救的演示程式碼:
/* finalize() 方法已經被拋棄,其存在的目的時幫助c++程式設計師從析構函數轉到Java來。 但是 finalize() 方法的開銷很大,不如使用try-finally快,已經被拋棄了。 */ public class FinalizeEscapeGC { public static FinalizeEscapeGC SAVE_HOOK = null; public void isAlive() { System.out.println("yes, i am alive :)"); } @Override protected void finalize() throws Throwable { super.finalize(); System.out.println("finalize method executed"); // 執行 finalize() 方法後拯救自己 FinalizeEscapeGC.SAVE_HOOK = this; } public static void main(String[] args) throws Throwable { SAVE_HOOK = new FinalizeEscapeGC(); // 第一次拯救成功了 SAVE_HOOK = null; System.gc(); // finalize() 方法優先順序較低,等待0.5s以執行 finalize() 方法 Thread.sleep(500); if(SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); } // 下面的程式碼和上面的一摸一樣 // 但是執行結果卻是 no, i am dead :( SAVE_HOOK = null; System.gc(); Thread.sleep(500); if(SAVE_HOOK != null) { SAVE_HOOK.isAlive(); } else { System.out.println("no, i am dead :("); } } } /* 運行結果: finalize method executed yes, i am alive :) no, i am dead :( */
- 第一次回收時,對象及時的逃脫了。
- 第二次回收時,因為對象已經執行過 finalize() 方法,所以不再執行 finalize() 方法,對象被回收。
-
-
回收方法區
方法區垃圾回收是可選擇實現的,如HotSpot虛擬機中的元空間或永久代就沒有垃圾回收行為。
因為方法區的回收性價比較低 —— 堆中進行一次垃圾回收可以回收 70% 到 99% 的記憶體。
-
方法區的記憶體回收主要有兩部分:
-
廢棄的常量:和堆中的對象回收一樣,當該常量不可達時被回收。
-
不再使用的類型:
-
當滿足下述三個條件時,JVM被允許對無用類進行回收:
-
該類所有的實例(該類、派生子類)都已經被回收。
-
載入該類的類載入器已經被回收。(這個條件一般難以達到)
classLoader負責將 .class 文件載入到JVM中,並生成 java.lang.Class 的一個實例。
-
該類對應的 java.lang.Class 對象也是不可達。
-
-
當滿足這三個條件是,JVM僅僅是被允許回收,而不一定會回收。
-
-
-
垃圾收集演算法
部分收集 Partial GC : 目標不是完整收集整個Java堆的垃圾收集。
- 新生代收集 Minor GC / Young GC : 目標時新生代的垃圾收集。
- 老年代收集 Major GC / Old GC : 目標是老年代的垃圾收集。目前只有 CMS收集器會單獨收集老年代。
- 混合收集 Mixed GC : 指目標是收集整個新生代和部分老年代的垃圾收集。目前只有G1收集器有這種行為。
整堆收集 Full GC : 目標是整個Java堆和方法去的垃圾收集。
-
分代收集理論:
當前商業虛擬機的垃圾收集器,大多都遵循了「分代收集」 (Generational Collection) 的理論進行設計。
-
分代收集實質上是一套符合大多數程式運行實際情況的經驗法則,而非理論。其建立在兩個法則之上:
- 弱分代假說 (Weak Generational Hypothesis) —— 絕大多數對象都是朝生夕滅的。
- 強分代假說 (Strong Generational Hypothesis) —— 熬過多次垃圾收集收集過程的對象就越難以滅亡。
設計原則:收集器應該將Java堆劃分出不同的區域,然後將回收對象根據其年齡 (年齡就是對象熬過垃圾回收的次數) 分配到不同的區域之中儲存。Java堆劃分為不同的區域之後,垃圾收集器每次可以只回收其中一部分區域。
-
JVM設計者至少會將Java堆劃分為 新生代(Young Generation) 和 老年代(Old Generation)。新生代和老年代的記憶體佔比為 1 : 2。
新生代:每次都有大量對象」死去「,少量存活的對象被逐步晉陞到老年代中存儲。
-
簡單劃分記憶體區域所帶來的問題:
- 對象不是孤立的,對象之間會存在跨代引用。如果需要發動一次局限在新生代內部的收集 (Minor GC),但是新生代中的對象被老年代所引用,為了找出該區域中的存活對象不得不遍歷整個老年代。反過來也是如此。
-
由簡單劃分記憶體區域所帶來的問題而添加了第三條法則:
- 跨代引用學說 (Intergenerational Reference Hypothesis) : 跨代引用相對於同代引用來說僅佔少數。
- 很容易理解,存在互相引用關係的兩個對象一般是傾向於同時生存活著同時滅亡的。如果某個新生代存在跨代引用,那麼它很快會被移動到老年代中:
- 為了避免為了少量跨代引用去遍歷整個老年代,只需要在新生代上建立一個全局的數據結構 (記憶集,Remebered Set) —— 記憶集中將老年代劃分成若干小塊,表示出老年代中哪一塊會存在跨代引用,在發生Minor GC時將記錄區域中的對象加入GC Roots中進行掃描。
- 跨代引用學說 (Intergenerational Reference Hypothesis) : 跨代引用相對於同代引用來說僅佔少數。
-
-
標記-清除演算法:標記出需要回收的對象,在標記完成後,統一回收掉所有被標記的對象。(也可以標記不需要收集的對象)
- 最基礎的垃圾收集演算法,有兩個主要的缺點:
- 執行效率不穩定,標記和清楚過程隨著對象數量增長而降低。
- 記憶體空間的碎片化問題,標記清楚後會留下大量不連續的空間碎片,在分配大記憶體空間時無法找到足夠的連續空間而觸發記憶體回收。
- 最基礎的垃圾收集演算法,有兩個主要的缺點:
-
標記-複製演算法:(主要應用於新生代)
-
半區複製 (Semispace Copying) 垃圾收集演算法:將記憶體區域劃分為相等的兩部分,每次只使用其中一塊,進行垃圾回收時將不需要回收的對象移到另一半記憶體空間中,然後將原空間全部回收。
- 優點:由弱生代假說可得,大部分對象不需要複製而只需要回收。同時這種發發不需要考慮記憶體碎片化的問題。
- 缺點:每次只有一半的記憶體可見可用,且複製開銷較大。
-
Apple 式回收:
-
新生代:分為一塊較大的Eden區域和兩塊較小的Survivor區域,每次分配記憶體只使用其中的Eden區域和一塊Survivor區域,將其中任然存活的對象複製到另一塊Survivor區域中,然後整個回收Eden區和原來的一塊Survivor區域。
-
Eden區域和 Survivor區域的記憶體佔比是 8 : 1 : 1。如果另一塊Survivor區域放不下 Minor GC後任存活的對象時,需要藉助其他區域 (老年區) 進行存儲。
- 在堆中 new 了一個大傢伙時,會發生以下幾個步驟:
- 在 Eden 區域中嘗試申請記憶體
- 記憶體不足時在 Eden 區域進行一次 Minor GC
- 如果還是放不下,在 Old 區域嘗試申請記憶體
- 如果 Old 區記憶體也不夠,則進行一次 Major GC
- 如果還是不夠,拋出OOM
- 在堆中 new 了一個大傢伙時,會發生以下幾個步驟:
-
-
-
-
標記-整理演算法 :(主要應用與老年代)
- 樸素版方法:在進行垃圾收集時,將存活的對象向記憶體空間的一端移動,然後清理掉邊界外的記憶體。
- 優點:可以保證整個程式的吞吐量,避免了記憶體空間的碎片化
- 缺點:每次移動存活對象的開銷較高,可能會增加延遲
- 「融合」版方法:在大多數時間執行 標記-清除演算法,在記憶體碎片化達到閾值時進行 標記-整理演算法。
HotSpot虛擬機中關注吞吐量的 Parallel Old收集器是標記-整理演算法的。
而關注延遲的CMS則採用了「融合」版方法。
- 樸素版方法:在進行垃圾收集時,將存活的對象向記憶體空間的一端移動,然後清理掉邊界外的記憶體。