1. 程式人生 > >Java物件"後事處理"那點事兒——垃圾回收(一)

Java物件"後事處理"那點事兒——垃圾回收(一)

1、Dead Or Alive

  我們都知道物件死亡的時候需要進行垃圾回收來回收這些物件從而釋放空間,那麼什麼樣的物件算是死亡呢,有哪些方法可以找出記憶體中的死亡物件呢?一般來說,我們可以這樣認為:如果記憶體中不存在對當前物件的引用,則此物件一定是死亡狀態;但是死亡狀態的物件並不一定沒有其他物件進行引用(可能存在死亡物件迴圈引用的情況)。這裡需要說明一下,死亡的物件並不一定會被回收釋放佔用的空間,這種情況就是常稱的"記憶體洩漏"。判定物件存活的演算法一般是以下兩種。

  1.1 引用計數法

  引用計數法,即在物件內放置一個變數來表示這個物件被引用的次數,如果其他物件引用了當前物件則變數值+1,如果失去引用則-1,當變數值為0的時候表示沒有引用,應當回收。此演算法並沒有被Java採用,因為其存在著一個致命的問題——迴圈引用。

 

  如上圖中,棧中沒有任何堆中兩個物件的引用,而堆中的兩個物件則互相持有對方的引用,如果使用引用計數法的話引用變數值永遠不會為0,從而造成記憶體洩漏,兩個互相引用的物件無法釋放空間。

public class TestForGc {

    TestForGc testInstance;

    // 模擬上圖的現象
    public static void main(String[] args) {
        TestForGc testA = new TestForGc();
        TestForGc testB = new TestForGc();
        testA.testInstance = testB;
        testB.testInstance = testA;
        testA = null;
        testB = null;

        // 建議垃圾回收器進行回收操作
        System.gc();
    }
}   

然後設定-XX:+PrintGCDetails列印GC日誌:

    最終新生代的物件全部被回收,說明JVM使用的並不是使用引用計數法來實現垃圾回收。

  1.2 可達性分析演算法(GCRoots)

  GCRoots,大意為選中一些特定的物件作為根節點,然後從這些根節點出發尋找可以引用到的所有物件,行程一條引用鏈(引用網),不在這條鏈中的物件則標記為死亡,進行回收。根節點的特定物件從下列四種產生:

  1、虛擬機器棧中引用的物件。

  2、本地方法棧中引用的物件。

  3、方法區中靜態變數引用的物件。

  4、方法區中常量引用的物件。

   使用GCRoots便不會出現迴圈引用的問題,如圖,雖然A、B相互引用,但是由於不在根節點的引用鏈中,所以會被標記為可回收物件。

   在Hotspot虛擬機器對GCRoots演算法的實現中,大致可以分為三個部分理解。

  1.2.1 列舉根節點

    如上所說,根節點的選取物件有四處,如果虛擬機器對這些位置進行全盤掃描的話,效率自然要影響不少,所以Hotspot採用一種資料結構來解決這個問題——OopMap。在類載入完成的時候,虛擬機器將物件的什麼偏移量有什麼物件計算出來,在JIT編譯過程中在特定的位置記錄下棧和暫存器中哪些位置是引用。這樣一來GC在掃描的時候就可以直接得到這些引用的資訊,從而減少GC的停頓時間。順便一提,在列舉根節點的時候,為了保持“一致性”,不能再掃描的時候還出現物件引用變化的情況,所以需要暫停所有Java執行執行緒(被稱為"STOP-THE-WORLD"),即便在具有劃時代意義、可以併發執行的CMS收集器中在列舉根節點的時候也需要STW。

  1.2.2 安全點的設定

    OopMap資料結構可以說為GC的掃描減少了不少的時間,但是隨之而來的還有一個問題,如果每條指令都生成對應的OopMap,那麼想必需要大量的額外空間,GC的空間成本將十分巨大,就是何時生成對應OopMap成為當前面臨的問題。之前說過在特定的位置會記錄下引用的位置,這個特定的位置就是OopMap的生成時機,也就是“安全點(SafePoint)”,在Sop-The-World的時候執行緒要先跑到安全點才可以進行執行緒的停頓。那該如何判斷這個特定的位置呢?如果設定的太少可能會導致GC時間變長,設定的太多會增大執行時的負荷。Hotspot給出的答案是以程式“是否具有讓程式長時間執行的特徵”為標準進行選定。"長時間執行"的明顯特徵就是指令複用,例如方法呼叫、迴圈跳轉、異常處理等,只有這些指令才能產生安全點。

    對於安全點來說,另外一個問題就是採用什麼樣的方式讓所有的執行緒跑到最近的安全點停頓。有兩種實現的方式:

  1、搶先式中斷:在GC發生的時候首先暫停所有執行緒,如果發現有執行緒沒在安全點的話,則恢復執行緒,讓其跑到最近的安全點再進行暫停。現在已經很少有使用搶先式的了。

  2、主動式中斷:GC發生的時候不強制暫停執行緒,而是設定一個標識變數,執行緒會去輪詢這個標誌,如果為true則將自己中斷掛起。這個輪詢的位置和安全點是重合的,還有建立物件時需要分配記憶體的地方。

  1.2.3 安全區域

    上面安全點的設定幾乎已經解決了問題,但是還少了一點,就是建立線上程都是執行狀態的時候,那執行緒不執行的時候呢,例如進入休眠狀態的執行緒,這時候自己不能跑到安全點也不能等待JVM分配時間。此時就需要安全區域來解決這一點。

    安全區域指的是在一段程式碼塊中,引用關係不會發生變化。當程式走到安全區域的時候,則標識當前執行緒進入了安全區域。這時候發生GC的時候則可以不用管有安全區域標識的執行緒,而這些執行緒在快離開安全區域的時候必須要檢查是否完成了根節點的列舉(或者整個GC的過程),如果完成了才可以離開安全區域,否則必須待到完成為止。

 2、垃圾回收演算法

   現在我們知道哪些物件是死亡的,哪些物件應該回收,而這個回收有許多種實現的方式(演算法),有的演算法對死亡物件進行標記最後一併清除、有的演算法將記憶體分塊然後將存活物件從一頭搬到另一頭,還有演算法在清除完死亡物件貼心的將存活的物件整放在一塊兒,這些都是我們接下來要說的。

  2.1 標記-清除演算法

   正如這個演算法的名稱一般,其總共有兩個階段——"標記"和"清除":首先其會對所有的死亡物件進行標記,最後再一起將這些物件回收。

 

  這個演算法是基礎的演算法,後續的演算法都是對其缺點的一些改進。此演算法有兩個不足的地方,其一從上圖也可以看得出來,垃圾回收後的記憶體空間不連續,造成許多的記憶體碎片。其二就是其效率問題,標記和清除的效率並不是太高。

  2.2 複製演算法

   為了解決效率和記憶體碎片的問題,一種稱作"copy"的演算法出現,這個演算法將記憶體空間分成兩份或以上,一份存放物件,一份空白,當進行垃圾回收的時候將所有的存活物件複製到空白的一份中,然後清空之前存放物件的空間。

 

 

 

  此演算法的優點:一定範圍內的高效率和沒有記憶體碎片。

 缺點:

  1、適用於存活物件相較死亡物件少的情況,例如新生代,如果存活的物件較多的話可能得到相反的效果。所以才說是一定範圍的高效率。

  2、需要劃分記憶體空間。如果本身的記憶體空間比較小還去劃分的話那可能會導致頻繁的GC,停頓時間增多,影響使用者體驗。

  另:此演算法一般用在新生代做垃圾回收演算法,並且將新生代分成三個部分——兩個Survivor和一個Eden區,其比例預設為1:1:8(可以通過虛擬機器引數改變)。當我們生成一個物件(通過關鍵字new或者反射)的時候,物件首先會分配在Eden區,等到Eden區放不下的時候則觸發一次MinorGC,將Eden和其中一個Survivor中的存活物件一起移到另一個Survivor中,然後清空。順帶一提,有存活物件的Survivor總是稱作From區,空白的Suvivor總是稱作To區,一般新生代存活物件佔5%左右。

  2.3 標記-整理法

    複製演算法是一個非常優秀的演算法,但是其只能用於存活物件較少的情況,而對於其他例如年老代中這些存活物件較多的區域則算不上是一個很好的選擇。至此,我們需要一個合適的演算法——標記-整理法。這個演算法基本跟標記-清除一樣,但是還多了一個整理的步驟,也就是標記-清除-整理的過程,不會產生記憶體碎片。

 

  2.4 分代演算法

    嚴格來說這不能算是一種演算法,應該是一種理念。其把整個記憶體空間分為兩個區域——新生代和年老代(1.8之前還有一個永久代,也就是方法區,但是在1.8之後已經刪除)。並且虛擬機器對物件定義了年齡的概念,表示該物件熬過了多少次GC,以此來作為物件放在新生代還是年老代的標準之一,預設新生代的物件15歲之後就可以進入年老代了。對於兩個區域採用的回收演算法也是不同的,新生代一般採用複製演算法,年老代一般採用標記-整理法,當然具體還是得看使用的垃圾回收器,如果年老代使用的是CMS的話那麼就是標記-清除了。

 

 

It is an honor if I could get some advices or corrections from you guys.