深入理解JVM(3)—垃圾回收

火貪三刀發表於2016-03-20

Java記憶體分為五個部分,其中程式計數器、虛擬機器棧、本地方法棧三個區域是執行緒私有的,隨執行緒生而生,隨執行緒滅而滅。方法區和Java堆的分配和回收則是動態的,記憶體的回收也是針對這部分記憶體的。

1、如何確定物件已死?
要對物件進行回收,就需要判斷物件是否已經“死去”(即接下來程式不再使用它或者很長一段時間不再使用它)。常用的方法是:

(1)引用計數演算法
給物件新增一個引用計數器,每當有地方使用它,就將計數器加1,不再使用時,計數器減1,當物件的引用計算器值為0時,表明不再有地方使用它,就表示該物件”已死”。
由於引用計數器很難解決物件間相互引用的情況,如物件A引用物件B,而物件B同時又引用了A,當兩物件失效時,兩者的引用計數器由於存在相互引用仍互不為0無法被回收,所以Java虛擬機器並沒有採用引用計數演算法來管理記憶體。
但是引用計數方法簡單,效率也高,在大部分情況下有著很好的應用,如Python語言、FlashPlayer等。
(2)可達性分析演算法
在主流商業程式語言,如Java、C#的主流實現中,都是通過可達性分析來判斷物件是否存活的。演算法思想是,從被稱作“GC Roots”的物件開始向下搜尋物件,所走過的路徑稱為引用鏈,若物件與“GC Roots”間沒有引用鏈存在時說明該物件已死。如下圖,物件5,6,7之間存在相互引用,但由於它們沒有到GC Roots的引用鏈存在,故可以判定為可回收物件。
這裡寫圖片描述

在Java語言中,可作為GC Roots的物件包括
Ⅰ、虛擬機器棧(棧幀中的區域性變數表)中引用的物件
Ⅱ、方法區中類靜態屬性引用的物件
Ⅲ、方法區常量引用的物件
Ⅳ、本地方法引用的物件

2、生存還是死亡?
通過可達性分析標記的不可達物件,也不一定會被回收。判斷物件是否真正死亡,至少需要兩次標記過程:首先在可達性分析中沒有發現引用鏈,將會對物件進行第一次標記並判斷此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法或者虛擬機器已經呼叫過finalize()方法,那麼就可以判斷“沒有必要執行”。
當物件被判斷“沒有必要執行”finalize()方法時,就會將物件放置在F-Queue佇列中,並在稍後由一個虛擬機器建立的Finalizer執行緒去執行它。第二次標記是GC對F-Queue佇列進行標記,此時,物件要想存活,則必須在finalize()方法中實現自救—重新與引用鏈關聯,如把自己(this關鍵字)賦值給某個類變數或者物件的成員變數,這樣在GC進行第二次標記時就會將該物件移除“即將回收”集合,從而避免被回收。例如:

/**
*程式碼演示了兩點:
*1、物件可以在GC時自救;
*2、其自救機會只有一次,因為一個物件的finalize()方法最多被系統呼叫一次
*/
public class FinalizeEscape {  

    public static FinalizeEscape FC = null;  

    @Override  
    protected void finalize() throws Throwable {  
        super.finalize();  
        System.out.println("finalize method invoke");  
        //通過賦值進行自救,只能利用finalize()自救
        FC = this;  
    }  

    public static void main(String[] args) throws Exception {  
        FC = new FinalizeEscape();  
        //第一次自救成功  
        FC = null;  
        System.gc();  
        Thread.sleep(500);  
        if(FC != null)  
            System.out.println("i am alive");  
        else   
            System.out.println("i am dead");  
        //由於finalize()已經被呼叫一次,故這次自救失敗了  
        FC = null;  
        System.gc();  
        Thread.sleep(500);  
        if(FC != null)  
            System.out.println("i am alive");  
        else   
            System.out.println("i am dead");  
    }  
}  

3、方法區的回收
方法區中的垃圾回收主要是廢棄字面量及無用類。判斷字面量是否廢棄與判斷堆中物件十分相似。例如,若常量池中存在字串“abc”,而系統中並沒有任何String物件的值為“abc”的,也就是沒有任何物件引用它,那麼它就可以被回收了。無用類的判定稍微複雜點,需要滿足
(1)該類的所有物件例項已經被回收;
(2)載入該類的ClassLoader已經被回收;
(3)該類的類物件Class沒有在任何地方被引用,無法使用反射來訪問該類的方法。
當方法區中的類滿足以上條件時,就可以對無用類進行回收了。

4、垃圾收集演算法
(1)標記-清除演算法
標記-清除(Mark-Sweep)演算法是最基礎的垃圾回收演算法,演算法分為標記和清除兩階段。首先對需要回收的物件進行標記(即判斷物件死亡的兩次標記),當標記完成後對這些物件統一回收。該演算法簡單容易實現,是回收演算法的基礎,但是該演算法由於標記和清除過程的效率都不高,還易產生空間碎片。演算法示意如下:
這裡寫圖片描述

(2)複製演算法
為了提高回收效率,複製演算法將記憶體劃分為大小相等的兩塊,每次只使用其中一塊,當此塊記憶體用完了,就將此記憶體中仍存活的物件複製的另一塊記憶體中,然後將該記憶體整個回收。該演算法實現簡單,執行高效,但是對記憶體使用存在很大的浪費。演算法示意如下:
這裡寫圖片描述
現在的商業虛擬機器都採用複製演算法來回收新生代。IBM公司的專門研究表明,新生代中的物件有98%都是“朝生夕死”的,所以不需要按上面說的平分記憶體,而是按約8:1:1的比例劃分為Eden區和兩塊Survivor區,每次使用Eden區和其中一塊Survivor區,即使用了90%的記憶體空間,當記憶體不足需要回收時,將這兩塊區域中仍存活的物件複製到另一塊Survivor區中,然後回收這兩塊記憶體。當然,可能會出現回收時仍存活的物件超過Survivor區的大小,這時候就需要通過記憶體的分配擔保機制將物件複製到老年代中儲存。

(3)標記-整理演算法
Java堆中老年代的物件存活率比較高,使用複製演算法會出現很多的複製操作或分配擔保問題,所以針對老年代的特點,提出了“標記-整理(Mark-Compact)”演算法。分為標記和整理過程,標記過程跟“標記-清除”演算法的標記過程一致,整理過程則是將存放物件移動到同一端,然後直接清除掉端邊界外的記憶體。演算法示意如下:
這裡寫圖片描述

5、Java虛擬機器HotSpot處理GC的上下文
(1)列舉GC Roots
前面講到回收記憶體的物件存活判定的可達性分析演算法,需要從被稱為“GC Roots”的物件出發搜尋引用鏈,但是在很多實際運用中,僅方法區就有數百M,對裡面的所有引用進行遍歷顯然不合理。而且該演算法要求分析工作在確保一致性的快照中進行——“一致性”指的是演算法在分析期間,系統看起來被凍結在某個時間點上,也即保證分析期間不再出現物件引用關係的變化。因此需要在發生GC時必須停頓所有的Java執行執行緒。
在GC時,如何快速查詢作為”GC Roots”的引用,從而確定物件的存活狀態,在HotSpot中是通過一組稱為OopMap的資料結構實現的。OopMap可以這樣理解,在原始碼裡面每個變數都是有型別的,但是編譯之後的程式碼就只有變數在棧上的位置了,oopMap就是一個附加的資訊,告訴你棧上哪個位置本來是個什麼型別。在Java編譯時,會在安全點(下面會講到)記錄下棧和暫存器中哪些位置是引用,這樣,GC在掃描時就可以直接確定“GC Roots”引用。
(2)安全點
在OopMap的協助下,可以快速地列舉GC Roots,但是由於引起OopMap內容變化的指令非常多,如果為每一條指令都生成對應的OopMap,則需要大量額外空間,導致GC成本變高。
為了解決這個問題,Java虛擬機器定義了安全點(Safepoint),只有位元組碼執行到安全點位置,才能暫停啟動GC,生成OopMap。安全點的選定要求既不能過少導致GC等待時間太長,也不能太多增加執行時的負荷,安全點主要設定在以下幾個位置
1、迴圈的末尾;
2、方法臨返回前 / 呼叫方法的call指令後;
3、可能拋異常的位置 。
如何保證當發生GC時,執行緒都處於安全點,主要是通過主動式中斷來實現的:在安全點設定一個輪詢標誌,各個執行緒執行時主動去輪詢這個標誌,發現中斷標誌為真的時就自己中斷掛起。
(3)、安全區域
安全點可以很好地解決執行緒執行時進入GC的問題,但對處於睡眠和阻塞狀態的執行緒卻無能為力,為了解決這個問題,java虛擬機器設定了安全區域(Safe Region),安全區域是指在一段程式碼片中,引用關係不會變化,在這個區域任何地方都可以發生GC,可以看作為擴充套件了的安全點。只有當執行的執行緒進入安全點,非執行態的執行緒處於安全區域時,才會觸發GC。
當執行緒執行到安全區域後, 首先會標示自己進入了安全區域, 那樣, 當在這段時間內發生GC時, 就不用管這樣的執行緒了,當執行緒要離開該區域時, 要檢查系統是否已經完成了根節點列舉(或整個GC過程),如果已完成,則執行緒繼續執行, 否則, 它就必須等待直到收到可以安全離開安全區域的訊號。

相關文章