程式扎記: [ JVM 應用 ] 垃圾收集器與記憶體分配策略 - Part1

標籤

2012年6月28日 星期四

[ JVM 應用 ] 垃圾收集器與記憶體分配策略 - Part1



前言 : 
說起 GC (Garbage Collection), 大多數會以為這項技術是因為 Java 而存在. 事實上 GC 的歷史遠比 Java 久遠, 1960 年誕生於 MIT 的 Lisp 是第一門使用記憶體動態分配與 GC 技術的語言. 而在使用 GC 需要思考的是 : 
* 那些記憶體需要回收
* 什麼時候回收
* 如何回收

經過半世紀的演進, GC 技術已經相當成熟. 至於為什麼我們需要去了解 GC? 因為當需要排查各種記憶體溢出, 洩漏問題, 當 GC 成為系統達到更高運行量的瓶頸時, 我們就需要對這些 "自動化" 的技術實施必要的監控與調整. (相對於 C/C++, 記憶體回收時機都是在Compile 前就決定.

物件已死 : 
Heap 存放著 Java 的物件實例, GC 在對 Heap 進行記憶體回收前第一件事就是要確定那些物件還 "存活" 哪些已經 "死去" (既不可能再被任何途徑使用的物件.), 底下我們要來介紹一些常見的演算法, 說明如何進行這樣的工作. 

- 參考計數演算法 
很多教科書判斷物件是否存活的演算法是這樣 : 給物件中增加一個參考計數器, 每當有一個物件參考它, 計數器就加一 ; 當參考失效, 計數器就減一 ; 任何時刻計數器為 0 的物件就是不可能再被使用的. 客觀地說參考計數演算法 (Reference Counting)的實現簡單, 判斷效率也高. 但是 Java 語言沒有選用這個演算法來管理記憶體, 其中最主要的原因是它很難解決物件之間的互動迴圈參考的問題. 

舉個簡單的例子, 請看下面程式碼 : 對象 objA 和 objB 都有字段 instance, 代碼 objA.instance=objB 和 objB.instance=objA, 除此之外這兩個對象再無 
任何參考, 實際上這兩個物件已經不可能再被訪問, 但是因為他們互相參考著對方, 導致它們的參考計數都不為零, 於是參考計數演算法無法通知 GC 回收它們 : 
  1. package ch03;  
  2.   
  3. public class Ex3_1 {  
  4.     protected String name = "";  
  5.     public Object instance = null;  
  6.       
  7.     public Ex3_1(String name)  
  8.     {  
  9.         this.name = name;  
  10.     }  
  11.     @Override  
  12.     public void finalize() throws Throwable  
  13.     {  
  14.         System.out.printf("\t[Info] %s being GC!\n", name);  
  15.     }  
  16.   
  17.     /** 
  18.      * Goal : 
  19.      *  main() 方法執行後, objA 和 objB 會被會被 GC? 
  20.      * @param args 
  21.      */  
  22.     public static void main(String[] args) throws Exception{  
  23.         Ex3_1 objA = new Ex3_1("objA");  
  24.         Ex3_1 objB = new Ex3_1("objB");  
  25.   
  26.         objA.instance = objB;  
  27.         objB.instance = objA;  
  28.           
  29.         objA = null; objB = null;  
  30.         System.gc();  
  31.         // 等  GC 收集  objA, objB.  
  32.         Thread.sleep(1000);  
  33.         System.out.printf("\t[Info] Byebye!\n");  
  34.     }  
  35. }  
但從執行結果知道在主程式離開之前, 它們就已經被回收, 說明 JVM 並不是使用參考計數演算法來判斷物件是否存活 : 
[Info] objA being GC!
[Info] objB being GC!
[Info] Byebye!

- 根搜尋演算法 
在主流的商業程式語言中 (Java, C# etc) 都是使用 根搜尋演算法 (GC Roots Tracing) 判段物件是否存活. 這個演算法的基本思路就是透過一系列的名為 "GC Roots" 的物件作為起始點, 從這個節點開始向下搜尋, 搜尋所走過的路徑稱為參考鏈 (Reference Chain). 當一個物件到 GC Roots 沒有任何參考鍊相連 (用圖解的話就是從 GC 到這個物件沒有路徑可以到達)時, 則說明此物件無法再被參考或使用. 在 Java 語言中, 可以做為 GC Roots 的對象包括 : 
* 虛擬器堆疊 (堆疊 Frame 中的本地變數表) 中參考的物件.
* 方法區中的類別靜態屬性參考物件
* 方法區中的常數參考的物件
* 本地方法堆疊中 JNI (一般說的 Native 方法) 的參考物件.

再談參考 : 
無論是透過 參考計數演算法 判斷物件的參考數, 還是透過 根搜尋演算法 判斷物件的參考鍊是否可達, 判斷物件手否存活都與 "參考" 有關. 在 JDK 1.2 之前, Java 中的參考定義是 : 如果 reference 類型資料中存儲的數值代表是一塊記憶體的起始位址, 就稱這塊記憶體代表一個參考. 一個物件在這種定義下只有被參考或者沒有參考兩種狀態, 對於如何描述一些較細緻的參考行為並不足以應付後續的應用. 我們希望能描述這麼一類的物件 : 當記憶體空間還足夠時, 能保留在記憶體中 ; 如果記憶體在 GC 後還是不敷使用, 則可以拋棄這些物件. 在 JDK 1.2 之後 Java 對參考的概念進行了延伸, 將參考分為 Strong Reference, Soft Reference, Weak Reference 與 Phantom Reference 四種 : 
* Strong Reference 
Strong Reference 就是指在程式碼中普遍存在, 類似 Object obj = new Object() 這類的參考, 只要 Strong Reference 存在, GC 永遠不會回收該物件.

* Soft Reference 
Soft Reference 用來描述一些還有用, 但是非必須的物件. 對於 Soft Reference 的物件, 在系統即將發生記憶體溢出之前, 將會把這些物件列入回收清單中並進行第二次回收. 如果這次回收過後還是沒有足夠記憶體, 才會跳出記憶體異常. 在 JDK 1.2 後提供了 SoftReference 類別來實現 Soft Reference.

* Weak Reference 
Weak Reference 也是用來描述非必須物件, 但是它的強度比 Soft Reference 更弱一點, 被弱參考關聯的物件只能生存到下一次的 GC 前. 當 GC 工作時, 無論當前記憶體是否足夠, 都會回收掉只被 Weak Reference 的物件. 在 JDK 1.2 後, 提供了 WeakReference 類來實現.

* Phantom Reference 
Phantom Reference 也稱為 幽靈參考或者幻影參考, 它是最弱的一種參考關係. 一個物件是否有 Phantom Reference 完全不會對其生存時間構成影響, 也無法透過它來取的一個物件實例. 為物件設置 Phantom Reference 的唯一目的就是希望能在這個物件被 GC 時收到一個系統通知. 在 JDK 1.2 提供 PhantomRefernce 類別來實現.

生存還是死亡 : 
在根搜尋演算法中不可達的物件, 也並非馬上會被 GC, 這時候它們處於 "緩刑" 階段, 要宣告一個物件被 GC, 至少要經歷兩次標記過程 : 如果物件在進行根搜尋後沒有發現與 GC Roots 相連接的參考鍊, 那它將被第一次標記並進行一次篩選, 篩選的條件是此物件是否有必要執行 finalize() 方法. 當物件沒有覆蓋 finalize() 方法, 或者 finalize() 已經被 JVM 呼叫過, JVM 將這兩種期況視為 "沒有必要執行". 

如果這個物件被判定有必要執行 finalize() 方法, 那麼這個物件將會被放置在一個名為 F-Queue 的佇列中, 並在稍後由一條由 JVM 自動建立, 低優先順序的 Finalizer 執行序去執行. finalize() 方法是物件逃脫被 GC 的最後一次機會, 稍後 GC 將對 F-Queue 中的物件進行第二次小規模的標記, 如果物件要在 finalize() 中成功擺脫被 GC - 只要重新與參考鏈上的任何一個物件建立關聯即可, 那在第二次標記時就會將它移除 "即將回收" 的集合 ; 如果物件這時候還沒逃脫, 那它真的就會被 GC 了, 下面代碼我們可以看到一個物件的 finalize() 被執行, 但是它仍然沒被 GC : 
  1. package ch03;  
  2.   
  3. public class Ex3_2 {  
  4.     public static Ex3_2 SAVE_HOOK = null;  
  5.       
  6.     public void isAlive(){System.out.printf("\t[Info] Yes, I am still alive :)\n");}  
  7.   
  8.     @Override  
  9.     public void finalize() throws Throwable{  
  10.         super.finalize();  
  11.         System.out.printf("\t[Info] finalize method executed!\n");  
  12.         Ex3_2.SAVE_HOOK = this;  
  13.     }  
  14.       
  15.     /** 
  16.      * Goal : 
  17.      *  1. Object can be saved from GC 
  18.      *  2. The chance only once. Because the finalize() will at most being called once. 
  19.      * @param args 
  20.      */  
  21.     public static void main(String[] args) throws Exception{  
  22.         SAVE_HOOK = new Ex3_2();  
  23.         test(); // 第一次 finalize() 執行  
  24.         test(); // finalize 只會被執行一次!  
  25.     }  
  26.       
  27.     public static void test() throws Exception  
  28.     {  
  29.         SAVE_HOOK = null;  
  30.         System.gc();  
  31.         // Finalizer has low priority, sleep 0.5 sec to wait.  
  32.         Thread.sleep(500);  
  33.         if(SAVE_HOOK!=null)  
  34.         {  
  35.             SAVE_HOOK.isAlive();  
  36.         }  
  37.         else  
  38.         {  
  39.             System.out.printf("\t[Info] No, I am dead :(\n");  
  40.         }  
  41.     }  
  42. }  
執行結果如下 : 
[Info] finalize method executed!
[Info] Yes, I am still alive :)
[Info] No, I am dead :(

可以看到SAVE_HOOK 的對象 finalize() 被 GC 執行並且成功逃脫 GC, 但是因為 finalize() 只能被執行一次, 故第二次就被 GC 了. 

回收方法區 : 
很多人認為方法區 (或者在 HotSpot JVM 中的 Permanent Generation) 是不會進行 GC 的, JVM 規範中確實說過可以不要求 JVM 在方法區中進行 GC, 而且在方法區進行 GC 的效率一般比較低 : 在 Heap 中, 尤其是新生代中, 常規應用進行一次 GC 一般可以回收 70%~95% 空間, 而永久代效率遠低於此. 

永久代的 GC 主要回收兩部分內容 : 廢棄常數和無用的類別. 回收廢棄常數與回收 Java Heap 中的物件非常類似. 以常數池中字面變數回收為例, 假如一個字串 "abc" 已經進入常數池, 但是當前系統沒有任何一個 String 物件內容是 "abc" 的, 換句話說就是沒有任何 String 物件參考常數池中的 "abc" 常數, 也沒有其他地方參考了這個字面參數, 如果發生 GC 且有必要, 這個 "abc" 常數就會被系統請出 常數池. 而判斷一個常數是否為 "廢棄常數" 比較簡單, 而要判斷一個類別是否為 "無用的類別" 的條件相對複雜許多. 類別需要同時滿足以下 3 個條件才能算是 "無用的類別" : 
* 該類別所有的實例都已經被回收, 也就是 Java Heap 中不存在該類別的任何實例.
* 載入該類別的 ClassLoader 已經被回收
* 該類別對應的 java.lang.Class 物件已經沒有在任何地方被參考, 無法在任何地方透過反射訪問該類別方法.

JVM 可以對滿足上述 3 個條件的類別進行回收, 這裡僅是 "可以", 而不是和物件一樣不使用就必然會回收. 是否對類別進行回收, HotSpot JVM 提供了 -Xnoclassc 參數進行控制, 還可以使用 -verbose:class 及 -XX:+TraceClassLoading-XX:+TraceClassUnLoading 查看類別的載入與卸載訊息. 在大量使用反射, 動態代理, CGLib 等 bytecode 框架的場景以及動態產生 class 的 JSP, OSGi 這類頻繁自訂 ClassLoader 的場景都需要 JVM 具備類別卸載的功能, 以保證永久代不會溢出. 

補充說明 : 
[ Gossip in Java(2) ] 反射 : 檢視類別 (簡介 Class)
[ JVM 應用 ] 垃圾收集器與記憶體分配策略 - Part2

沒有留言:

張貼留言

網誌存檔

關於我自己

我的相片
Where there is a will, there is a way!