1. 程式人生 > 實用技巧 >Hotspot 老年代GC原始碼分析

Hotspot 老年代GC原始碼分析

來年代的回收可分為 標記-壓縮回收 和 標記清理回收

前者會將存活物件在物件頭中打標,回收的時候,把被打標的物件複製到一塊,使得存活物件在記憶體上是連續分佈的。

需要注意的是,這裡說的連續分佈,不是物理意義上的,因為JVM向作業系統申請老年代和年輕代這樣的大塊記憶體時,使用的是mmap系統呼叫,作業系統給出的物理頁不一定是連續的。

GC分為前臺GC和 後臺GC

前臺GC在 System.gc() 或者 記憶體分配失敗時 由 VM_Thread 執行,VM_Thread是JVM本身的工作執行緒,前臺GC也稱為同步GC, 呼叫方會阻塞在該點,等待GC完成

在使用CMS 收集器的情況下,由 CMSThread 執行後臺 GC, 後臺GC 會和 Java 執行緒 輪番執行,當 CMSThread 覺得自己應該讓出 CPU 的時候,會 Yield,讓出 CPU,讓Java業務執行緒執行。

前臺GC 的起點是 CMSCollector::acquire_control_and_collect

mark_sweep_phase1: 將 普通根(Universe,JavaThread,JNI 引用的物件等,注意,沒有以年輕代為起點) 做為 起點,對他們和他們引用的物件,以及他們引用的物件引用的物件...... 深度打標,打標其實只是為物件頭設定特殊值,如果必要,會把物件頭儲存下來

mark_sweep_phase2: 進行 老年代 和 年輕代 存活物件的地址計算,並且寫入到物件頭,具體計算方法很簡單

需要倆根指標 A,B。兩者一開始都指向 當前代 的 記憶體空間的 bottom 地址。

假設A 是用來指向可寫入地址,B是掃描指標。

B會從 bottom 一直向上掃描,知道掃到頂部為止,中途發現一個活物件,(活物件已在上一步被打標)則把 A 指向的地址(forwardee指標)寫進這個物件的物件頭。並且執行 A = A + 活物件大小。

如果不是活的,則會一直掃描直到找到存活物件,這樣的話,B指標之前會累積一段 非存活物件空間,直接在這段非存活物件空間的起始處,記下本非存活空間的終止地址(也就是下一個存活空間的起始地址)

無論是不是存活物件,B指標都要執行 B = B + 當前物件大小,以便掃描下一個物件。

......後面還有,省略

mark_sweep_phase3:調增引用型別,adjust_points,和 phase1 一樣,以 普通根 和 年輕代 為起點,深度掃描他們的引用型別,如果引用型別指向的物件(oopDesc),的物件頭被設定了 forwardee 指標,則把引用型別調整為

forwardee 指標。

mark_sweep_phase4: 遍歷整個老年代和年輕代,將物件頭中包含 forwardee 指標的 物件,複製到 forward 指標所指的記憶體區域

個人感覺 3 和 4 非常耗時,要掃描一遍 兩個代的記憶體區,3是深度搜索,4要複製,都挺耗時。

do_mark_sweep_work 和 後臺GC一起講,因為大體步驟都一樣

後臺GC 的起點是CMSCollector::collect_in_background,由 CMSThread 呼叫

值得注意的是,後臺GC 貌似沒有給出壓縮的方式,而是按照中規中矩的 Mark - Sweep 把老年代垃圾清除掉

後臺GC 是有中規中矩的步驟的,通過一個 while 迴圈,把這些狀態逐個完成。

有一個遍歷儲存 當前狀態,完成當前狀態就往下一個狀態轉化。

虛擬碼:

while (true) {

  switch (state) {

    case initMark : checkPointRootsInitial(); state = nextState;
    case mark : markFromRoots();state = nextState;

    case finalMark : checkpointRootsFinal();state = nextState;

    ......  
 }
}

下面的序號和 狀態轉化的順序一致。

需要注意的是,標記壓縮標記物件是直接在物件頭標記,判斷物件是否標記直接 oop->mark()->isMark(); 這樣判斷就行

非壓縮標記的話,需要使用一張 bit_map , 和卡表一樣,都是以一個小得多的記憶體陣列去標記某一塊記憶體區域怎麼樣怎麼樣了的技巧。

只不過 bit_map 是給物件打標,而卡表標記某個引用關係發生變化的物件對應的記憶體區域。並且 bit_map 是使用 一位 去對應 shifter 個字(64位機器一個字是64位),而卡表是用一個位元組去表示一張卡(一般512B)

粒度不一樣

1.checkpointRootInitial : 此階段需要託付給 VM_Thread 去執行,具體是做為一個 VM_Operation去執行,關於VM_Operation,具體操作和上述類似,但是加多了年輕代,也就是以 普通根 和 年輕代 為起點,淺度地對這些物件打標,也就是隻是簡單地把他們自己地址對應的位在bit_map 上打標,不會涉及到他們的引用

2.markFromRoots:遍歷上一階段的bit_map, 對bit_map中打標了的位對應的區域的物件(假設為物件集合T0),執行深度打標(打標T0集合中物件引用的物件,引用的物件引用的物件......具體是依賴棧來實現的)

具體操作是遍歷 bit_map ,一位一位地遍歷,對於髒的位對應的物件,就深度打標

3.checkpointRootsFinal : 此階段和階段1一樣,也要託付給 VM_Thread,目的都是為了 STW(Stop the world),保持物件引用關係不變。此階段做的有兩件事:

  1.把髒卡表的髒記憶體資訊複製到一個modUnionTable 中

  2.遍歷髒卡表對應區域的物件,遍歷普通根物件,遍歷年輕代物件,對這些物件進行深度打標,具體也是用棧實現

4.preclean:預清理,這個階段主要是處理軟應用,弱引用之類的 Java 提供的特別引用,個人感覺並不是什麼清理的意思,因為實際上的操作會讓存活物件多很多。首先是找到一些 referent 還可達的 Reference,把他們從 discoverList 上摘下來

discoverList 是會被放到 Reference 的 pending 佇列的,最後會被 Reference Handler 執行緒處理。而且會把他們在 modUnionTable 中打標,並且會對 from 和 to 同樣在 modUnionTable 中打標。

上面的壓縮回收,連年輕代都壓縮回收了,但是此處的後臺回收,一般不回收年輕代,而且所謂的清理,貌似讓更多的物件保留了下來。

5.sweep:這一步是真正的清理了,但是記憶體實際上不會歸還作業系統,只是規還給了JVM c++層面管理來年代記憶體的 space 類,具體一般是 compatiableFreeListSpace, 是一種基於夥伴演算法,用多級連結串列(每一級連結串列連線起了一種大小的記憶體塊

一般大小是 2^0, 2^1, 2^2, 2^3 ......)來管理記憶體的類,這個類還持有一個 類似 map 的字典,鍵是記憶體塊大小,值是具體記憶體塊。一開始整個老年代是一整塊大記憶體塊,放在字典裡,多級連結串列還是空的,當第一次被索要記憶體的時候,就會把字典裡的這塊大記憶體分出一部分填充到 多級連結串列中,之後如果連結串列記憶體不足的話,再向字典要

清理的過程中,也是線性掃描老年代的記憶體,從 bottom 開始掃描,遇到一個存活物件的時候,前面已經是一段空閒區域或死亡物件組合成的記憶體區間,這一段記憶體區間會被歸還到compatiableFreeListSpace,而且還會看看是否能和空閒的記憶體塊合成更大的記憶體塊,歸還到compatiableFreeListSpace中。

6.resize:重新計算老年代大小,如果需要增大大小就擴容,否則縮容

7 resetting:此步驟是清空之前用的 bit_map 之類的記錄工具,以便下次繼續GC