1. 程式人生 > >兩個週末整理的垃圾回收知識,我要吐血了

兩個週末整理的垃圾回收知識,我要吐血了

嘮嘮叨叨

今天的肝貨來了,作者已經肝吐血了,看書查資料整理了萬字的垃圾回收相關知識,雖然很長,但是看完相信你一定會有很大的收貨,誒,週末又沒有了,心好痛。

「面試必問」的垃圾回收,我們直接進入正題,讀完你會學到以下的所有知識,「包括但不限於」:

垃圾是怎麼找到的?
OopMap有什麼作用?
為什麼需要STW?
記憶集有什麼作用?
常用的7種垃圾回收器都有哪些??
三色標記演算法?
CMS為什麼會產生碎片化?
G1居然會引起Full GC?
......

垃圾物件是怎麼找到的?

引用計數演算法

就是給物件新增一個計數器

  • 每當有一個地方引用它的時候,計數器就加1
  • 每當有一個引用失效的時候,計數器就減1

「當計數器的值為0的時候,那麼該物件就是垃圾了」

這種方案的原理很簡單,而且判定的效率也非常高,但是卻可能會有其他的額外情況需要考慮。

比如兩個「物件迴圈引用」,a物件引用了b物件,b物件也引用了a物件,a、b物件卻沒有再被其他物件所引用了,其實正常來說這兩個物件已經是垃圾了,因為沒有其他物件在使用了,但是計數器內的數值卻不是0,所以引用計數演算法就無法回收它們。

這種演算法是比較「直接的找到垃圾」,然後去回收,也被稱為"直接垃圾收集"。

根可達演算法

這也是「jvm預設使用」的尋找垃圾演算法

它的原理就是定義了一系列的根,我們把它稱為 「"GC Roots"」 ,從 「"GC Roots"」 開始往下進行搜尋,走過的路徑我們把它稱為 「"引用鏈"」 ,當一個物件到 「"GC Roots"」 之間沒有任何引用鏈相連時,那麼這個物件就可以被當做垃圾回收了。

如圖,「根可達演算法」就可以「避免」計數器演算法不好解決的「迴圈引用問題,Object 6、Object 7、Object 8」彼此之前有引用關係,但是沒有與 「"GC Roots"」 相連,那麼就會被當做垃圾所回收。

在java中,有「固定的GC Roots 物件」和「不固定的臨時GC Roots物件:」

「固定的GC Roots:」

  • 1.在「虛擬機器棧(棧幀的本地變量表)中所引用的物件」,譬如各個執行緒被呼叫的方法堆疊中使用到的引數、區域性變數、臨時變數等。
  • 在方法區中「類靜態屬性引用的物件」,譬如 Java 類的引用靜態變數。
  • 在方法區中「常量引用的物件」,譬如字串常量池中的引用。
  • 在方法區棧中 「JNI (譬如 Native 方法)引用的物件」。
  • Java 「虛擬機器內部的引用」,如基本資料型別對應的 Class 物件,一些常駐的異常物件(空指標異常、OOM等),還有類載入器。
  • 所有「被 Synchronized 持有的物件」。
  • 反應 Java 虛擬機器內部情況的 「JMXBean、JVMTI 中註冊的回撥原生代碼快取等」。

「臨時GC Roots:」

「為什麼會有臨時的 GC Roots ?」

目前的垃圾回收大部分都是「分代收集和區域性回收」,如果只針對某一部分割槽域進行區域性回收,那麼就必須要考慮的「當前區域的物件有可能正被其他區域的物件所引用」,這時候就要將這部分關聯的物件也新增到 GC Roots 中去來確保根可達演算法的準確性。

這種演算法是利用了「逆向思維」,找到使用的物件,剩下的就是垃圾,也被稱為"間接垃圾收集"。

四種引用型別

強引用

"Object o = new Object()" 就是一種強引用關係,這也是我們在程式碼中最常用的一種引用關係。

無論任何情況下,只要強引用關係還存在,垃圾回收器就不會回收掉被引用的物件。

軟引用

當記憶體空間不足時,就會回收軟引用物件。

String str = new String("abc");
// 軟引用
SoftReference<String> softRef = new SoftReference<String>(str);

軟引用用來描述那些有用但是沒必要的物件。

弱引用

弱引用要比軟引用更弱一點,它「只能夠存活到下次垃圾回收之前」。

也就是說,垃圾回收器開始工作,會回收掉所有隻被弱引用關聯的物件。

//弱引用
WeakReference<String> weakRef = new WeakReference<String>(str);

在ThreadLocal中就使用了弱引用來防止記憶體洩漏。

虛引用

虛引用是最弱的一種引用關係,它的唯一作用是用來作為一種通知。

如零拷貝(Zero Copy),開闢了堆外記憶體,虛引用在這裡使用,會將這部分資訊儲存到一個佇列中,以便於後續對堆外記憶體的回收管理。

分代收集理論

大多數的垃圾回收器都遵循了分代收集的理論進行設計,它建立在兩個分代假說之上:

  • 「弱分代假說」:絕大多數物件都是朝升夕滅的。
  • 「強分代假說」:熬過越多次數垃圾回收過程的物件就越難消亡。

這兩種假說的設計原則都是相同的:

垃圾收集器「應該將jvm劃分出不同的區域」,把那些較難回收的物件放在一起(一般指老年代),這個區域的垃圾回收頻率就可以降低,減少垃圾回收的開銷。剩下的區域(一般指新生代)可以用較高的頻率去回收,並且只需要去關心那些存活的物件,也不用標記出需要回收的垃圾,這樣就能夠以較低的代價去完成垃圾回收。

  • 「跨代引用假說」:如果某個新生代的物件存在了跨代引用,但是老年代的物件是很難消亡的,那麼隨著時間的推移,這個新生代物件也會慢慢晉升為老年代物件,那麼這種跨代引用也就被消除了。

由於跨代引用是很少的,所以我們不應該為了少量的跨代引用去掃描整個老年代的資料,只需要在新生代物件建立一個「記憶集」來記錄引用資訊。

記憶集:「將老年代分為若干個小塊,每塊區域中有N個物件」,在物件引用資訊發生變動的時候來維護記憶集資料的準確性,這樣每次發生了 「"Minor GC"」 的時候只需要將記憶集中的物件新增到 「"GC Roots"」 中就可以了。

三種垃圾收集演算法

標記清除演算法

這種演算法的實現是很簡單的,有兩種方式

  • 1.標記出垃圾,然後清理掉
  • 2.標記出存貨的物件,回收其他空間

這種演算法有兩個缺點

  • 1.隨著物件越來越多,那麼所需要消耗的時間就會越來越多
  • 2.標記清除後會導致碎片化,如果有大物件分配很有可能分配不下而出發另一次的垃圾收集動作

標記複製演算法

這種演算法解決了第一種演算法碎片化的問題。

就是「開闢兩塊完全相同的區域」,物件只在其中一篇區域內分配,然後「標記」出那些「存活的物件,按順序整體移到另外一個空間」,如下圖,可以看到回收後的物件是排列有序的,這種操作只需要移動指標就可以完成,效率很高,「之後就回收移除前的空間」。

這種演算法的缺點也是很明顯的

  • 浪費過多的記憶體,使現有的「可用空間變為」原先的「一半」

標記整理演算法

這種演算法可以說是結合了前兩種演算法,既有標記刪除,又有整理功能。

這種演算法就是通過標記清除演算法找到存活的物件,然後將所有「存活的物件,向空間的一端移動」,然後回收掉其他的記憶體。

但是這種演算法卻有一個缺點,就是在移動物件的時候必須要暫停使用者的應用程式(「STW」)才能移動。

STW

Java 中「Stop-The-World機制簡稱 STW」 ,是在執行垃圾收集演算法時,Java 應用程式的其他所有執行緒都被掛起(除了垃圾收集幫助器之外)。Java 中一種全域性暫停現象,全域性停頓,所有 Java 程式碼停止,native 程式碼可以執行,但不能與 JVM 互動。

為什麼需要STW

在 java 應用程式中「引用關係」是不斷髮生「變化」的,那麼就會有會有很多種情況來導致「垃圾標識」出錯。

想想一下如果 Object a 目前是個垃圾,GC 把它標記為垃圾,但是在清除前又有其他物件指向了 Object a,那麼此刻 Object a 又不是垃圾了,那麼如果沒有 STW 就要去無限維護這種關係來去採集正確的資訊。

再舉個例子,到了秋天,道路上灑滿了金色的落葉,環衛工人在打掃街道,卻永遠也無法打掃乾淨,因為總會有不斷的落葉。

垃圾回收器是怎樣尋找 GC Roots 的?

我們在前面說明了根可達演算法是通過 GC Roots 來找到存活的物件的,也定義了 GC Roots,那麼垃圾回收器是怎樣尋找GC Roots 的呢?

首先,「為了保證結果的準確性,GC Roots列舉時是要在STW的情況下進行的」,但是由於java應用越來越大,所以也不能逐個檢查每個物件是否為GC Root,那將消耗大量的時間。

一個很自然的想法是,能不能用空間換時間,在某個時候把棧上代表引用的位置全部記錄下來,這樣到真正 gc 的時候就可以直接讀取,而不用再一點一點的掃描了。事實上,大部分主流的虛擬機器也正是這麼做的,比如 HotSpot ,它使用一種叫做 「OopMap」 的資料結構來記錄這類資訊。

OopMap

我們知道,一個執行緒意味著一個棧,一個棧由多個棧幀組成,一個棧幀對應著一個方法,一個方法裡面可能有多個安全點。 gc 發生時,程式首先執行到最近的一個安全點停下來,然後更新自己的 OopMap ,記下棧上哪些位置代表著引用。列舉根節點時,遞迴遍歷每個棧幀的 OopMap ,通過棧中記錄的被引用物件的記憶體地址,即可找到這些物件( GC Roots )。

使用 OopMap 可以「避免全棧掃描」,加快列舉根節點的速度。但這並不是它的全部用意。它的另外一個更根本的作用是,可以幫助 HotSpot 實現準確式 GC (即使用準確式記憶體管理,虛擬機器可用知道記憶體中某個位置的資料具體是什麼型別) 。

安全點

從執行緒角度看,安全點可以理解成是在「程式碼執行過程中」的一些「特殊位置」,當執行緒執行到這些位置的時候,說明「虛擬機器當前的狀態是安全」的。

比如:「方法呼叫、迴圈跳轉、異常跳轉等這些地方才會產生安全點」。

如果有需要,可以在這個位置暫停,比如發生GC時,需要暫停所有活動執行緒,但是執行緒在這個時刻,還沒有執行到一個安全點,所以該執行緒應該繼續執行,到達下一個安全點的時候暫停,等待GC結束。

那麼如何讓執行緒在垃圾回收的時候都跑到最近的安全點呢? 這裡有「兩種方式」:

  • 搶先式中斷
  • 主動式中斷

搶先式中斷:就是在stw的時候,先讓所有執行緒「完全中斷」,如果中斷的地方不在安全點上,然後「再啟用」,「直到執行到安全點的位置」再中斷。

主動式中斷:在安全點的位置打一個標誌位,每個執行緒執行都去輪詢這個標誌位,如果為真,就在最近的安全點掛起。

但是如果有些執行緒處於sleep狀態怎麼辦呢?

安全區域

為了解決這種問題,又引入了安全區域的概念

安全區域是指「在一段程式碼片中,引用關係不會發生改變」,實際上就是一個安全點的拓展。當執行緒執行到安全區域時,首先標識自己已進入安全區域,那樣,當在這段時間裡JVM要發起GC時,就不用管標識自己為“安全區域”狀態的執行緒了,該執行緒只能乖乖的等待根節點列舉完或者整個GC過程完成之後才能繼續執行。

聊聊垃圾回收器

前面和大家聊了很多垃圾收集演算法,所以在真正實踐的時候會有多種選擇,垃圾回收器就是真正的實踐者,接下來就和大家聊聊10種垃圾回收器

Serial

Serial是一個「單執行緒」的垃圾回收器,「採用複製演算法負責新生代」的垃圾回收工作,可以與CMS垃圾回收器一起搭配工作。

在STW的時候「只會有一條執行緒」去進行垃圾收集的工作,所以可想而知,它的效率會比較慢。

但是他確是所有垃圾回收器裡面消耗額外記憶體最小的,沒錯,就是因為簡單。

ParNew

ParNew 是一個「多執行緒」的垃圾回收器,「採用複製演算法負責新生代」的垃圾回收工作,可以與CMS垃圾回收器一起搭配工作。

它其實就是 Serial 的多執行緒版本,主要區別就是在 STW 的時候可以用多個執行緒去清理垃圾。

Pararllel Scavenge

Pararllel Scavenge 是一個「多執行緒」的垃圾回收器,「採用複製演算法負責新生代」的垃圾回收工作,可以與 Serial Old , Parallel Old 垃圾回收器一起搭配工作。

截圖2020-12-18 下午5.23.32

是與ParNew類似,都是用於年輕代回收的使用複製演算法的並行收集器,與ParNew不同的是,Parallel Scavenge的「目標是達到一個可控的吞吐量」。

吞吐量=程式執行時間/(程式執行時間+GC時間)。

如程式運行了99s,GC耗時1s,吞吐量=99/(99+1)=99%。Parallel Scavenge提供了兩個引數用以精確控制吞吐量,分別是用以控制最大GC停頓時間的-XX:MaxGCPauseMillis及直接控制吞吐量的引數-XX:GCTimeRatio.

「停頓時間越短就越適合需要與使用者互動的程式」,良好的響應速度能提升使用者體驗,而高吞吐量則可以高效的利用CPU時間,儘快完成程式的運算任務,主要適合在後臺運算而不需要太多互動的任務。

Serial Old

Serial Old 是一個「單執行緒」的垃圾回收器,「採用標記整理演算法負責老年代」的垃圾回收工作,有可能還會配合 「CMS」 一起工作。

截圖2020-12-18 下午4.36.21

其實它就是 Serial 的老年代版本,整體鏈路和 Serial 大相徑庭。

Parallel Old

Parallel Old 是一個「多執行緒」的垃圾回收器,「採用標記整理演算法負責新生代」的垃圾回收工作,可以與 Serial Old , Parallel Old 垃圾回收器一起搭配工作。

截圖2020-12-18 下午5.23.32

Parallel Old 是 Pararllel Scavenge 的老年代版本,它的設計思路也是以吞吐量優先的,ps+po也是很常用的一種組合。

CMS

CMS可以說是一款具有"跨時代"意義的垃圾回收器,支援了和使用者執行緒一起工作,做到了「一起併發回收垃圾」的"壯舉"。

  • 1.初始標記
    • 初始標記只是標記出來「和 GC Roots 直接關聯」的物件,整個速度是非常快的,為了保證標記的準確,這部分會在 「STW」 的狀態下執行。
  • 2.併發標記
    • 併發標記這個階段會直接根據第一步關聯的物件找到「所有的引用」關係,這一部分時刻使用者執行緒「併發執行」的,雖然耗時較長,但是不會有很大的影響。
  • 3.重新標記
    • 重新標記是為了解決第二步併發標記所導致的標錯情況,這裡簡單舉個例子: 併發標記時a沒有被任何物件引用,此時垃圾回收器將該物件標位垃圾,在之後的標記過程中,a又被其他物件引用了,這時候如果不進行重新標記就會發生「誤清除」。
    • 這部分內容也是在 「STW」 的情況下去標記的。
  • 4.併發清除
    • 這一步就是最後的清除階段了,將之前「真正確認為垃圾的物件回收」,這部分會和使用者執行緒一起併發執行。

CMS的「三個缺點」:

  • 1.影響使用者執行緒的執行效率
    • CMS預設啟動的回收執行緒數是(處理器核心數 + 3)/ 4 ,由於是和使用者執行緒一起併發清理,那麼勢必會影響到使用者執行緒的執行速度,並且這個影響「隨著核心執行緒數的遞減而增加」。所以 JVM 提供了一種 "「增量式併發收集器」"的 CMS 變種,主要是用來減少垃圾回收執行緒獨佔資源的時間,所以會感覺到回收時間變長,這樣的話「單位時間內處理垃圾的效率就會降低」,也是一種緩和的方案。
  • 2.會產生"浮動垃圾"
    • 之前說到 CMS 真正清理垃圾是和使用者執行緒一起進行的,在「清理」這部分垃圾的時候「使用者執行緒會產生新的垃圾」,這部分垃圾就叫做浮動垃圾,並且只能等著下一次的垃圾回收再清除。
  • 3.會產生碎片化的空間
    • CMS 是使用了標記刪除的演算法去清理垃圾的,而這種演算法的缺點就是會產生「碎片化」,後續可能會「導致大物件無法分配」從而觸發「和 Serial Old 一起配合使用」來處理碎片化的問題,當然這也處於 「STW」 的情況下,所以當 java 應用非常龐大時,如果採用了 CMS 垃圾回收器,產生了碎片化,那麼在 STW 來處理碎片化的時間會非常之久。

G1

G1(Garbage First):顧名思義,「垃圾回收第一」,官方對它的評價是在垃圾回收器技術上具有「里程碑式」的成果。

G1回收的目標不再是整個新生代,不再是整個老年代,也不再是整個堆了。G1可以「面向堆記憶體的任何空間來進行」回收,衡量的標準也不再是根據年代來區分,而是哪塊「空間的垃圾最多就回收哪」塊兒空間,這也符合G1垃圾回收器的名字,垃圾第一,這就是G1的 「Mixed GC」 模式。

當然我的意思是「垃圾回收不根據年代來區分」,但是G1還是「根據年代來設計」的,我們先來看下G1對於堆空間的劃分:

G1 垃圾回收器把堆劃分成一個個「大小相同的Region」,每個 Region 都會扮演一個角色,H、S、E、O。

E代表伊甸區,S代表 Survivor 區,H代表的是 Humongous(G1用來分配「大物件的區域」,對於 Humongous 也分配不下的超大物件,會分配在連續的N個 Humongous 中),剩餘的深藍色代表的是 Old 區,灰色的代表的是空閒的 region。

在 HotSpot 的實現中,整個堆被劃分成2048左右個 Region。每個 Region 的大小在1-32MB之間,具體多大取決於堆的大小。

在併發標記垃圾時也會產生新的物件,G1對於這部分物件的處理是這樣的:

將 Region 「新增一塊併發回收過程中分配物件的空間」,併為此設計了兩個 TAMS(Top at Mark Start)指標,這塊區域專門用來在併發時分配新物件,有物件新增只需要將 TAMS 指標移動下就可以了,並且這些「新物件預設是標記為存活」,這樣就「不會干擾到標記過程」。

但是這種方法也會有個問題,有可能「垃圾回收的速度小於新物件分配的速度」,這樣會導致 "Full GC" 而產生長時間的 STW。

在 G1 的設計理念裡,「最小回收單元是 Region」 ,每次回收的空間大小都是Region的N倍,那麼G1是「怎麼選擇要回收哪塊兒區域」的呢?

G1 會跟蹤各個 Region 區域內的垃圾價值,和回收空間大小回收時間有關,然後「維護一個優先順序列表」,來收集那些價值最高的Reigon區域。

執行的步驟:

  • 初始標記:
    • 標記出來 GC Roots 能「直接關聯」到的物件
    • 修改 TAMS 的值以便於併發回收是新物件分配
    • 是在 Minor GC 時期(「STW」)完成的
  • 併發標記:
    • 根據剛剛關聯的對像掃描整個物件引用圖,和使用者執行緒「併發執行」
    • 記錄 SATB(原始快照) 在併發時有引用的值
  • 最終標記:
    • 處於 「STW」,處理第二步遺留下來的少量 SATB(原始快照) 記錄
  • 篩選回收:
    • 維護之前提到的優先順序列表
    • 根據「優先順序列表」,「使用者設定的最大暫停時間」來回收 Region
    • 將需要回收的 Region 記憶體活的物件「複製」到不需要回收的 Region區域內,然後回收需要回收的 Region
    • 這部分是處於 「STW」 下執行,並且是多執行緒的

三色標記

這裡我們又提到了一個概念叫做 「SATB 原始快照」,關於SATB會延伸出有一個概念,「三色標記演算法」,也就是垃圾回收器標記垃圾的時候使用的演算法,這裡我們簡單說下:

將物件分為「三種顏色」:

  • 白色:沒被 GC 訪問過的物件(被 GC 標記完後還是白色代表是垃圾)
  • 黑絲:存活的物件
  • 灰色:被 GC 訪問過的物件,但是物件引用鏈上至少還有一個引用沒被掃描過

我們知道在「併發標記」的時候「可能會」出現「誤標」的情況,這裡舉兩個例子:

  • 1.剛開始標記為「存活」的物件,但是在併發標記過程中「變為了垃圾物件」
  • 2.剛開始標記為「垃圾」的物件,但是在併發標記過程中「變為了存活物件」

第一種情況影響還不算很大,只是相當於垃圾沒有清理乾淨,待下一次清理的時候再清理一下就好了。

第二種情況就危險了,正在使「用的物件的突然被清理掉」了,後果會很嚴重。

那麼「產生上述第二種情況的原因」是什麼呢?

  • 1.「新增」一條或多條「黑色到白色」物件的**新引用
  • 2.刪除「了」灰色「物件」到該白色物件「的直接」引用**或間接引用。

當這兩種情況「都滿足」的時候就會出現這種問題了。

所以為了解決這個問題,引入了「增量更新」(Incremental Update)和「原始快照」(SATB)的方案:

增量更新破壞了第一個條件:「增加新引用時記錄」該引用資訊,在後續 STW 掃描中重新掃描(CMS的使用方案)。

原始快照破壞了第二個條件:「刪除引用時記錄下來」,在後續 STW 掃描時將這些記錄過的灰色物件為根再掃描一次(G1的使用方案)。

結尾的嘮叨

其實關於垃圾回收器,我們這裡只介紹了最常用的7中,是因為剩下的 Shenandoah,ZGC,Epsilon這些垃圾回收器,每個拿出來講解都是可以單獨成一篇文章的,作者這裡就不再新增到這篇文章了,後續我會單獨成文去寫這些垃圾回收器

下期見,我要去養生