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


嘮嘮叨叨

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

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

垃圾是怎么找到的?
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這些垃圾回收器,每個拿出來講解都是可以單獨成一篇文章的,作者這里就不再添加到這篇文章了,后續我會單獨成文去寫這些垃圾回收器

下期見,我要去養生了~


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM