JVM垃圾回收之三色標記
阿新 • • 發佈:2021-02-07
三色標記法是一種垃圾回收法,它可以讓JVM不發生或僅短時間發生STW(Stop The World),從而達到清除JVM記憶體垃圾的目的。JVM中的**CMS、G1垃圾回收器**所使用垃圾回收演算法即為三色標記法。
## 三色標記演算法思想
三色標記法將物件的顏色分為了黑、灰、白,三種顏色。
**白色**:該物件沒有被標記過。(物件垃圾)
**灰色**:該物件已經被標記過了,但該物件下的屬性沒有全被標記完。(GC需要從此物件中去尋找垃圾)
**黑色**:該物件已經被標記過了,且該物件下的屬性也全部都被標記過了。(程式所需要的物件)
![](https://img2020.cnblogs.com/blog/2002319/202102/2002319-20210207155947452-729461329.png)
### 演算法流程
從我們`main`方法的根物件(JVM中稱為`GC Root`)開始沿著他們的物件向下查詢,用黑灰白的規則,標記出所有跟`GC Root`相連線的物件,掃描一遍結束後,一般需要進行一次短暫的STW(Stop The World),再次進行掃描,此時因為黑色物件的屬性都也已經被標記過了,所以只需找出灰色物件並順著繼續往下標記(且因為大部分的標記工作已經在第一次併發的時候發生了,所以灰色物件數量會很少,標記時間也會短很多), 此時程式繼續執行,`GC`執行緒掃描所有的記憶體,找出掃描之後依舊被標記為白色的物件(垃圾),清除。
具體流程:
1. 首先建立三個集合:白、灰、黑。
2. 將所有物件放入白色集合中。
3. 然後從根節點開始遍歷所有物件(注意這裡並不**遞迴遍歷**),把遍歷到的物件從白色集合放入灰色集合。
4. 之後遍歷灰色集合,將灰色物件引用的物件從白色集合放入灰色集合,之後將此灰色物件放入黑色集合
5. 重複 4 直到灰色中無任何物件
6. 通過write-barrier檢測物件有變化,重複以上操作
7. 收集所有白色物件(垃圾)
### 三色標記存在問題
1. 浮動垃圾:併發標記的過程中,若一個已經被標記成黑色或者灰色的物件,突然變成了垃圾,由於不會再對黑色標記過的物件重新掃描,所以不會被發現,那麼這個物件不是白色的但是不會被清除,重新標記也不能從`GC Root`中去找到,所以成為了浮動垃圾,**浮動垃圾對系統的影響不大,留給下一次GC進行處理即可**。
1. 物件漏標問題(需要的物件被回收):併發標記的過程中,一個業務執行緒將一個未被掃描過的白色物件斷開引用成為垃圾(刪除引用),同時黑色物件引用了該物件(增加引用)(這兩部可以不分先後順序);因為黑色物件的含義為其屬性都已經被標記過了,重新標記也不會從黑色物件中去找,導致該物件被程式所需要,卻又要被GC回收,此問題會導致系統出現問題,而`CMS`與`G1`,兩種回收器在使用三色標記法時,都採取了一些措施來應對這些問題,**CMS對增加引用環節進行處理(Increment Update),G1則對刪除引用環節進行處理(SATB)。**
## 解決辦法
在JVM虛擬機器中有兩種常見垃圾回收器使用了該演算法:CMS(Concurrent Mark Sweep)、G1(Garbage First) ,為了解決三色標記法對物件漏標問題各自有各自的法:
### CMS回顧
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在網際網路網站或者基於瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的響應速度,希望系統停頓時間儘可能短,以給使用者帶來良好的互動體驗。CMS收集器就非常符合這類應用的需求(但是實際由於某些問題,很少有使用CMS作為主要垃圾回收器的)。
從名字(包含“Mark Sweep”)上就可以看出CMS收集器是基於標記-清除演算法實現的,它的運作過程相對於前面幾種收集器來說要更復雜一些,整個過程分為四個步驟,包括:
1)初始標記(CMS initial mark)
2)併發標記(CMS concurrent mark)
3)重新標記(CMS remark)
4)併發清除(CMS concurrent sweep)
其中初始標記、重新標記這兩個步驟仍然需要“Stop The World”。初始標記僅僅只是標記一下GCRoots能直接關聯到的物件,速度很快;
併發標記階段就是從GC Roots的直接關聯物件開始遍歷整個物件圖的過程,這個過程耗時較長但是不需要停頓使用者執行緒,可以與垃圾收集執行緒一起併發執行;
重新標記階段則是為了修正併發標記期間,因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間通常會比初始標記階段稍長一些,但也遠比並發標記階段的時間短;
最後是併發清除階段,清理刪除掉標記階段判斷的已經死亡的物件,由於不需要移動存活物件,所以這個階段也是可以與使用者執行緒同時併發的。由於在整個過程中耗時最長的併發標記和併發清除階段中,垃圾收集器執行緒都可以與使用者執行緒一起工作,所以從總體上來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。
![img](https://img2020.cnblogs.com/blog/2002319/202102/2002319-20210203084504068-2000031218.png)
### CMS解決辦法:增量更新
在應對漏標問題時,CMS使用了增量更新(Increment Update)方法來做:
在一個未被標記的物件(白色物件)被重新引用後,**引用它的物件若為黑色則要變成灰色,在下次二次標記時讓GC執行緒繼續標記它的屬性物件**。
但是就算時這樣,其仍然是存在漏標的問題:
- 在一個灰色物件正在被一個GC執行緒回收時,當它已經被標記過的屬性指向了一個白色物件(垃圾)
- 而這個物件的屬性物件本身還未全部標記結束,則為灰色不變
- **而這個GC執行緒在標記完最後一個屬性後,認為已經將所有的屬性標記結束了,將這個灰色物件標記為黑色,被重新引用的白色物件,無法被標記**
### CMS另兩個致命缺陷
1. CMS採用了`Mark-Sweep`演算法,最後會產生許多記憶體碎片,當到一定數量時,CMS無法清理這些碎片了,CMS會讓`Serial Old`垃圾處理器來清理這些垃圾碎片,而`Serial Old`垃圾處理器是單執行緒操作進行清理垃圾的,效率很低。
所以使用CMS就會出現一種情況,硬體升級了,卻越來越卡頓,其原因就是因為進行`Serial Old GC`時,效率過低。
- 解決方案:使用`Mark-Sweep-Compact`演算法,減少垃圾碎片
- 調優引數(配套使用):
```bash
-XX:+UseCMSCompactAtFullCollection 開啟CMS的壓縮
-XX:CMSFullGCsBeforeCompaction 預設為0,指經過多少次CMS FullGC才進行壓縮
```
2. 當JVM認為記憶體不夠,再使用CMS進行併發清理記憶體可能會發生OOM的問題,而不得不進行`Serial Old GC`,`Serial Old`是單執行緒垃圾回收,效率低
- 解決方案:降低觸發`CMS GC`的閾值,讓浮動垃圾不那麼容易佔滿老年代
- 調優引數:
```bash
-XX:CMSInitiatingOccupancyFraction 92% 可以降低這個值,讓老年代佔用率達到該值就進行CMS GC
```
### G1回顧
G1(Garbage First)實體記憶體不再分代,而是由一塊一塊的`Region`組成,但是邏輯分代仍然存在。G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region採用不同的策略去處理,這樣無論是新建立的物件還是已經存活了一段時間、熬過多次收集的舊物件都能獲取很好的收集效果。
Region中還有一類特殊的Humongous區域,專門用來儲存大物件。G1認為只要大小超過了一個Region容量一半的物件即可判定為大物件。每個Region的大小可以通過引數`-XX:G1HeapRegionSize`設定,取值範圍為1MB~32MB,且應為2的N次冪。而對於那些超過了整個Region容量的超級大物件,將會被存放在N個連續的Humongous Region之中,G1的大多數行為都把Humongous Region作為老年代的一部分來進行看待,如圖所示
![](https://img2020.cnblogs.com/blog/2002319/202102/2002319-20210207155906754-102190047.png)
### G1前置知識
**Card Table(多種垃圾回收器均具備)**
- 由於在進行`YoungGC`時,我們在進行對一個物件是否被引用的過程,需要掃描整個Old區,所以JVM設計了`CardTable`,將Old區分為一個一個Card,一個Card有多個物件;如果一個Card中的物件有引用指向Young區,則將其標記為`Dirty Card`,下次需要進行`YoungGC`時,只需要去掃描`Dirty Card`即可。
- Card Table 在底層資料結構以 `Bit Map`實現。
![](https://img2020.cnblogs.com/blog/2002319/202102/2002319-20210207155914345-67461391.png)
**RSet(Remembered Set)**
是輔助GC過程的一種結構,典型的空間換時間工具,和Card Table有些類似。
後面說到的CSet(Collection Set)也是輔助GC的,它記錄了GC要收集的Region集合,集合裡的Region可以是任意年代的。
在GC的時候,對於old->young和old->old的跨代物件引用,只要掃描對應的CSet中的RSet即可。 邏輯上說每個Region都有一個RSet,RSet記錄了其他Region中的物件引用本Region中物件的關係,屬於points-into結構(誰引用了我的物件)。
而Card Table則是一種points-out(我引用了誰的物件)的結構,每個Card 覆蓋一定範圍的Heap(一般為512Bytes)。G1的RSet是在Card Table的基礎上實現的:每個Region會記錄下別的Region有指向自己的指標,並標記這些指標分別在哪些Card的範圍內。 這個RSet其實是一個Hash Table,Key是別的Region的起始地址,Value是一個集合,裡面的元素是Card Table的Index。每個`Region`中都有一個`RSet`,記錄其他`Region`到本`Region`的引用資訊;使得垃圾回收器不需要掃描整個堆找到誰引用當前分割槽中的物件,只需要掃描RSet即可。
**CSet(Collection Set)**
一組可被回收的分割槽Region的集合, 是多個物件的集合記憶體區域。
**新生代與老年代的比例**
`5% - 60%`,一般不使用手工指定,因為這是G1預測停頓時間的基準,這地方簡要說明一下,G1可以指定一個預期的停頓時間,然後G1會根據你設定的時間來動態調整年輕代的比例,例如時間長,就將年輕代比例調小,讓YGC儘早行。
### G1解決辦法:SATB
SATB(Snapshot At The Beginning), 在應對漏標問題時,G1使用了`SATB`方法來做,具體流程:
1. 在開始標記的時候生成一個快照圖示記存活物件
2. 在一個引用斷開後,要將此引用推到GC的堆疊裡,保證白色物件(垃圾)還能被GC執行緒掃描到(在**write barrier(寫屏障)**裡把所有舊的引用所指向的物件都變成非白的)。
3. 配合`Rset`,去掃描哪些Region引用到當前的白色物件,若沒有引用到當前物件,則回收
### SATB詳細流程
>1. SATB是維持併發GC的一種手段。G1併發的基礎就是SATB。SATB可以理解成在GC開始之前對堆記憶體裡的物件做一次快照,此時活的對像就認為是活的,從而開成一個物件圖。
>2. 在GC收集的時候,新生代的物件也認為是活的物件,除此之外其他不可達的物件都認為是垃圾物件。
>3. 如何找到在GC過程中分配的物件呢?每個region記錄著兩個top-at-mark-start(TAMS)指標,分別為prevTAMS和nextTAMS。在TAMS以上的物件就是新分配的,因而被視為隱式marked。
>4. 通過這種方式我們就找到了在GC過程中新分配的物件,並把這些物件認為是活的物件。
>5. 解決了物件在GC過程中分配的問題,那麼在GC過程中引用發生變化的問題怎麼解決呢?
>6. G1給出的解決辦法是通過Write Barrier。Write Barrier就是對引用欄位進行賦值做了額外處理。通過Write Barrier就可以瞭解到哪些引用物件發生了什麼樣的變化。
>7. mark的過程就是遍歷heap標記live object的過程,採用的是三色標記演算法,這三種顏色為white(表示還未訪問到)、gray(訪問到但是它用到的引用還沒有完全掃描)、back(訪問到而且其用到的引用已經完全掃描完)。
>8. 整個三色標記演算法就是從GC roots出發遍歷heap,針對可達物件先標記white為gray,然後再標記gray為black;遍歷完成之後所有可達物件都是balck的,所有white都是可以回收的。
>9. SATB僅僅對於在marking開始階段進行“snapshot”(marked all reachable at mark start),但是concurrent的時候併發修改可能造成物件漏標記。
>10. 對black新引用了一個white物件,然後又從gray物件中刪除了對該white物件的引用,這樣會造成了該white物件漏標記。
>11. 對black新引用了一個white物件,然後從gray物件刪了一個引用該white物件的white物件,這樣也會造成了該white物件漏標記。
>12. 對black新引用了一個剛new出來的white物件,沒有其他gray物件引用該white物件,這樣也會造成了該white物件漏標記。
### SATB效率高於增量更新的原因?
因為SATB在重新標記環節只需要去重新掃描那些被推到堆疊中的引用,並配合`Rset`來判斷當前物件是否被引用來進行回收;
並且在最後`G1`並不會選擇回收所有垃圾物件,而是根據`Region`的垃圾多少來判斷與預估回收價值(指回收的垃圾與回收的`STW`時間的一個預估值),將一個或者多個`Region`放到`CSet`中,最後將這些`Region`中的存活物件壓縮並複製到新的`Region`中,清空原來的`Region`。
### G1會不會進行Full GC?
會,當記憶體滿了的時候就會進行`Full GC`;且`JDK10`之前的`Full GC`,為單執行緒的,所以使用G1需要避免`Full GC`的產生。
解決方案:
- 加大記憶體;
- 提高CPU效能,加快GC回收速度,而物件增加速度趕不上回收速度,則Full GC可以避免;
- 降低進行Mixed GC觸發的閾值,讓Mixed GC提早發生(預設45%)
## 站在巨人的肩膀上
1. [Getting Started with the G1 Garbage Collector](http://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html)
2. [請教G1演算法的原理](http://hllvm.group.iteye.com/group/topic/44381)
3. [關於incremental update與SATB的一點理解](http://hllvm.group.iteye.com/group/topic/44529)
4. [Tips for Tuning the Garbage First Garbage Collector](http://www.infoq.com/articles/tuning-tips-G1-GC)
5. [g1gc-impl-book](https://github.com/authorNari/g1gc-impl-book)
6. [垃圾優先型垃圾回收器調優](http://www.oracle.com/technetwork/cn/articles/java/g1gc-1984535-zhs.html)
7. [Understanding G1 GC Logs](https://blogs.oracle.com/poonam/entry/understanding_g1_gc_logs)
8. [G1: One Garbage Collector To Rule Them All](http://www.infoq.com/articles/G1-One-Garbage-Collector-To-Rule-Them-All)
9. [ Java Hotspot G1 GC的一些關鍵技術](https://tech.meituan.com/2016/09/23/