垃圾回收策略和演算法,看這篇就夠了

2020-08-08 17:17:51

作者 | Craig無忌
來源 | 程式設計師大帝(ID:kingcoding)


前言

回收,舊手機,舊冰箱,舊空調,舊洗衣機,電瓶車摩托車,自行車,報紙,塑料......

還記得小時候,我喝完的飲料瓶子都不會扔,每次都放到陽臺。小區裡聽到收廢品的吆喝,感覺帶着這些瓶瓶罐罐衝下樓,換幾塊錢買雪糕,想想都是童年的回憶啊。

我一直都覺得騎個三輪車,回收廢品的大爺特別酷,因爲感覺他的車上面就像哆啦A夢的口袋,翻一翻什麼都有。不過這些年,隨着垃圾分類,感覺收廢品的大爺也越來越少了。

回過頭想,如果沒有這些收廢品的大爺,那我攢的瓶子也賣不了錢,家裏陽臺那麼多瓶子還佔地方。所以你大爺就是你大爺,主動過來幫你清理垃圾,還給你錢。

所以爲什麼 Java 越來越流行,除了說它一處安裝,到處執行的機制 機製以外。還因爲程式設計師也越來越懶,跟 C/C++ 相比,Java 最適合懶人的便是引入了自動垃圾回收的機制 機製,也就是Garage Collection(下文簡稱 GC )。

網上對於 Java 垃圾回收的介紹堪稱冠冕堂皇:

讓程式設計師專注於程式本身,不用關心記憶體回收這些惱人的問題,真正讓程式設計師的生產力得到了釋放,程式設計師不用感知到它的存在。

說這麼多,不就是程式設計師懶麼, Java 直接幫你把髒活累活都幹了 乾了。就像咱們現在人都愛點外賣,爲什麼?因爲不用自己動手,吃完也不用洗碗。還有你去餐盤吃飯,吃完就走,服務員會替你收拾好這些餐盤,你不會關心服務員什麼時候來收,怎麼收。

大家可能會說既然 Java 這麼方便,已經幫我們完成了對垃圾的清理與回收,那 GC 方面的知識我不用瞭解好像也沒事吧。但是人有失手,馬有失蹄,假如突然有一天外賣小哥帶着你的外賣小哥跑路了,你必須要親自動手下廚,總不能餓死吧。

所以對於 GC,道理也是一樣的,線上的服務不遇到問題還好,出現 Bug 或者想自己做一些效能調優的時候,就需要對 GC 有深入瞭解纔可以,這也是成爲一名優秀 Java 程式設計師的必修課!

今天就把 JVM GC 相關的知識詳細介紹一下,本文將會從以下幾個方面來講述相關知識,文字較多,相信大家耐心看了之後肯定有收穫,碼字不易,別忘了「在看」,「轉發」哦。

  • JVM 記憶體區域

  • 回收策略

  • 垃圾回收經典演算法

  • 垃圾回收器對比

JVM 記憶體區域


我們首先要知道垃圾回收主要回收的是哪些數據,這些數據主要在哪一塊區域,所以我們一起來看下 JVM 的記憶體區域。

JDK8以前

在JDK8之前的虛擬機器,主要包含:

(1)堆

物件範例和陣列都是在堆上分配的,GC 也主要對這兩類數據進行回收,這裏是 GC 發生的主要區域!

(2)方法區(永久代)

方法區在 JVM 中是一個非常重要的區域,它與堆一樣是被執行緒共用的區域。在方法區中,儲存了每個類的資訊(包括類的名稱、方法資訊、欄位資訊)、靜態變數、常數以及編譯器編譯後的程式碼等。

方法區是堆的一個邏輯部分,爲了區分Java堆,它還有一個別名 Non-Heap(非堆)。相對而言,GC 對於這個區域的收集是很少出現的。當方法區無法滿足記憶體分配需求時,將拋出 OutOfMemoryError 異常。

隨着動態類載入的情況越來越多,這塊記憶體越來越不太可控。如果設定小了,當JVM載入的類資訊容量超過了這個值,系統執行過程中就容易出現記憶體溢位 OOM:PermGen 的錯誤,設定大了又浪費記憶體。

(3)棧

棧是執行緒私有的,生命週期與執行緒相同,主要儲存執行方法時的區域性變數表、運算元棧、動態連線和方法返回地址等資訊。這塊區域是不需要進行 GC 的。

(4)程式計數器

程式計數器也是執行緒私有的,它裏面記錄了下一次需要執行的行號,這塊區域也不需要進行 GC。

(5)本地方法棧

本地方法棧主要爲了虛擬機器執行 Java 的本地方法( Native Method)時服務,這塊區域也不需要進行 GC。

JDK8之後

JDK8 最大的變化就是對 JVM 記憶體空間進行了改造,主要的區別是將方法區進行了移除,並新增了元空間,元空間是放置在 JVM 記憶體空間之外的直接記憶體中,並且 JDK8 中對於方法區的參數 PermSize 和 MaxPermSize 已經失效。

上文咱們已經介紹過,JDK8 之前方法區放在 JVM 之中,但是隨着動態類載入的情況越來越多,很容易因爲大小的限制導致記憶體溢位 OOM:PermGen 的錯誤。

所以JDK8 之後把使用元空間替代了原來的方法區,在這種架構下,元空間就突破了原來-XX:MaxPermSize 的限制。這樣就從一定程度上解決了原來在執行時生成大量類造成經常 Full GC 問題,如執行時使用反射、代理等,所以升級以後Java堆空間可能會增加。

垃圾回收策略


凡事都講解個策略,那麼 Java 怎麼判斷堆中的物件範例或數據是不是垃圾呢,應不應該把它回收掉呢?

參照計數法

第一種最簡單粗暴的就是參照計數法。當物件被參照,程式計數器 +1,釋放時候 -1,當爲 0 時證明物件未被參照,可以回收。


但是這個演算法有明顯的缺陷,對於回圈參照的情況下,回圈參照的物件就不會被回收。例如下圖:物件 A,物件 B 回圈參照,沒有其他的物件參照 A 和 B,則 A 和 B 都不會被回收。

可達性分析

第二種策略明顯好的多,也就是所謂可達性分析法。它指的,通過一系列稱之爲「GC Roots」 的物件作爲起點;從此起點向下搜尋,所走過的路徑稱之爲參照鏈,當一個物件到 GC Roots 沒有任何參照鏈相連線,代表此物件不可達。


在 Java 可以作爲GC Roots 的物件包括:

1、虛擬機器棧(幀棧中的區域性變數表)中的參照物件;

2、方法區中類靜態屬性參照的物件;

3、方法區中常數參照的物件;

4、本地方法棧中JNI (即一般說的 Native 方法) 的參照物件;

畫外音:GC Roots有哪些這個問題經常在面試中被問到,大家一定牢記!

垃圾回收經典演算法


知道了應該對哪些物件進行回收,那接下來就要看如何回收了,經典的垃圾回收演算法有三種。

標記 - 清除演算法


在gc時候,首先掃描時對需要清理的無用物件進行標記,然後將這些物件直接清理。

操作起來非常很簡單,但仔細想想有什麼問題呢?

沒錯,記憶體碎片!如上圖,如果清理了兩個 1kb 的物件,再新增一個 2kb 的物件,是無法放入這兩個位置的。

怎麼解決呢,如果能把這些碎片的記憶體連起來就可以了!

標記 - 整理演算法


標記 - 整理演算法就是在標記 - 清理演算法的基礎上,多加了一步整理的過程,把空閒的空間進行上移,從而解決了記憶體碎片的問題。

但是缺點也很明顯:每進一次垃圾清除都要頻繁地移動存活的物件,效率十分低下。

複製演算法


複製演算法是將空間一分爲二,在清理時,將需要保留的物件複製到第二塊區域上,複製的時候直接緊湊排列,然後把原來的一塊區域清空。

不過複製演算法的缺點也顯而易見,本來 JVM 堆假設有 100M 記憶體,結果由於將空間一分爲二,真正能用的變成只有 50M 了!這肯定是不能接受的!另外每次回收也要把存活物件移動到另一半,效率低下。

分代演算法

分代收集演算法整合了以上演算法,綜合了這些演算法的優點,最大程度避免了它們的缺點。與其說它是演算法,倒不是說它是一種策略,因爲它是把上述幾種演算法整合在了一起,我們先從下圖看看物件的生存規律。

由圖可知,大部分的物件都很短命,一般來說,98% 的物件都是朝生夕死的,所以分代收集演算法根據物件存活週期的不同將堆分成新生代和老生代。

新生代和老年代的預設比例爲 1 : 2,新生代又分爲 Eden 區, from Survivor 區(簡稱 S0 ),to Survivor 區(簡稱 S1 ),三者的比例爲 8: 1 : 1。

根據新老生代的特點選擇最合適的垃圾回收演算法,我們把新生代發生的 GC 稱爲 Young GC(也叫 Minor GC ),老年代發生的 GC 稱爲 Old GC(也稱爲 Full GC )。

大多數情況下,物件在新生代 Eden區中分配。當 Eden 區沒有足夠空間進行分配時,虛擬機器將發起一次 MinorGC;

Minor GC 非常頻繁,一般回收速度也比較快;出現了 Full GC,經常會伴隨至少一次的 Minor GC,Full GC 的速度一般會比 Minor GC 慢10倍以上。

    

整個過程大致分爲以下幾個步驟:

(1)當 Eden 滿了後,進行 Minor GC,將需要儲存的數據複製到 S0 中;

(2)然後清空 Eden 和 S1 區域,需要保留的物件目前在 S0 中;

(3)下一次當 Eden 滿了後,進行Minor GC,將原來 S0 存在的數據複製到S1中,將 Eden 中需要儲存的數據也複製到 S1 中;

(4)清空 Eden 和 S0 區域,需要儲存的物件目前都在 S1 中;

(5)Eden+S0 複製到 S1;

(6)Eden+S1 複製到 S0;

(7)Eden+S0 複製到 S1;

周而復始...


垃圾回收器對比


前面的內容更多的是方法論,真正執行垃圾回收的要靠各個垃圾回收器。

Java虛擬機器規範並沒有規定垃圾收集器應該如何實現,因此一般來說不同廠商,不同版本的虛擬機器提供的垃圾收集器實現可能會有差別,一般會給出參數來讓使用者根據應用的特點來組合各個年代使用的收集器,主要有以下幾種垃圾收集器。

Serial收集器

從名字看出這是一個單執行緒收集器。序列垃圾回收器在進行垃圾回收時,它會持有所有應用程式的執行緒,凍結所有應用程式執行緒,使用單個垃圾回收執行緒來進行垃圾回收工作。

它是 JDK1.3 之前新生代的回收器的唯一選擇,在單執行緒的情況效果很好,因爲單執行緒沒有執行緒的切換的開銷。但是在現在大部分都是多 CPU 的伺服器,所以它現在被使用的很少了。

但是它還是 JVM 執行在 Client 模式下的預設垃圾收集器。因爲一般桌面應用下新生代空間不是很大,使用這個垃圾回收器也可以保證回收的時間在 100 毫秒左右。

Serial-Old收集器

這個收集器就是 serial 收集器的老年版本,他同樣還是單執行緒的垃圾回收器。它存在的主要意義的還是 JVM 執行在 client 模式下的預設老年代回收器跟 serial收集器一起使用,同樣它還作爲 CMS 垃圾回收器的後備垃圾回收器。


ParNew收集器

ParNew 垃圾收集器就是 serial 回收器的多執行緒版本,有很多的程式碼都是和 serial 收集器公用的。一個很重要的作用就是作爲新生代的垃圾回收器跟 CMS垃圾回收器進行組合。但是在單核 CPU 的情況下,效率是沒有 serial 垃圾回收器的效果好的。

可以通過-XX:UseConcMarkSweepGC 或者-XX:UseParNewGC 來指定使用它。預設情況它用於回收垃圾的執行緒的數目跟 CPU 的數目相同。可以通過-XX:parallelGCThreads 來指定使用的垃圾回收的執行緒的數目。


Parallel Scavenge收集器

與 ParNew 執行緒一樣同樣爲多執行緒的垃圾回收器,但是這個垃圾回收器和其他回收器的關注點不同。其他的垃圾回收器是儘可能縮短垃圾回收時對使用者執行緒的縮短時間。但是這個垃圾回收器關注的是一個吞吐量的概念。

吞吐量指的是執行使用者程式碼的時間/(執行使用者程式碼時間+垃圾回收時間)。縮短使用者停頓時間對那些高互動性比如一些 web 專案看中的。而吞吐量是一些執行在後台的計算任務是看重的。

Parallel Old收集器

這個回收器是 Parallel scavenge 的老年代版本,經常和 Parallel scavenge 一起使用在對記憶體比較敏感和對吞吐量比較高的場合下使用,使用多執行緒和「標記-整理」演算法。這個收集器是在JDK 1.6 中纔開始提供


CMS收集器(劃重點!!)

CMS( Concurrent Mark Sweep )收集器是一種以獲取最短回收停頓時間爲目標的收集器。目前很大一部分的Java應用都集中在網際網路站或 B/S 系統的伺服器端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。CMS 基於「標記-清除」演算法實現的,整個過程分爲 4 個步驟,包括:

(1)初始標記:只標記根節點直接關聯的參照物件,需要暫停使用者執行緒(時間短);

(2)併發標記:標記其他參照物件,可以跟使用者執行緒併發同時執行;

(3)重新標記:暫停使用者執行緒,對併發標記期間新增加的參照關係變化再次標記(時間短);

(4)併發清除:跟使用者執行緒併發進行。

其中初始標記、重新標記這兩個步驟仍然需要「Stop The World」。初始標記僅僅只是標記一下 GC Roots 能直接關聯到的物件,速度很快,併發標記階段就是進行 GC Roots Tracing 的過程,而重新標記階段則是爲了修正併發標記期間,因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。

由於整個過程中耗時最長的併發標記和併發清除過程中,收集器執行緒都可以與使用者執行緒一起工作,所以總體上來說,CMS 收集器的記憶體回收過程是與使用者執行緒一起併發地執行。

CMS 收集器已經在很大程度上減少了使用者執行緒的停頓時間,但是他也存在下面 下麪三個主要的缺點:

(1)跟使用者執行緒競爭資源

CMS 預設的併發執行緒數目爲(CPU 數目+3)/4,當 CPU 執行緒大於 4 的時候,CMS 垃圾收集器至少要佔用 25% 的資源。當小於 4 的時候佔用 CPU 資源更加明顯。

(2)無法清除浮動垃圾

當收集器在進行併發清除垃圾的時候,由於使用者執行緒還在執行,要預留一定的空間給使用者執行緒進行使用,所以收集器一定不能在老年代已經佔用 100% 的情況下再進行垃圾收集。

(3)記憶體碎片

因爲這個垃圾回收器是使用的標記-清除演算法,所以會產生大量的記憶體碎片。

有兩個值可以進行控制:

-XX:UseCMSCompactAtFullCollection 預設開啓,來指定需要 FULL GC 時,會對記憶體空間進行一次整理。

-XX:CMSFullGCsBeforeCompaction 來指定多少次不整理之後進行一次整理。


G1收集器(劃重點!!)

G1 是目前技術發展的最前沿成果之一,HotSpot 開發團隊賦予它的使命是未來可以替換掉JDK1.5中發佈的CMS收集器。與CMS 收集器相比 G1 收集器有以下特點:

(1)空間整合:G1 收集器採用標記-整理演算法,不會產生記憶體空間碎片。分配大物件時不會因爲無法找到連續空間而提前觸發下一次 GC。

(2)可預測停頓:這是 G1 的另一大優勢,降低停頓時間是 G1 和 CMS 的共同關注點,但 G1 除了追求低停頓外,還能建立可預測的停頓時間模型。能讓使用者明確指定在一個長度爲 N 毫秒的時間片段內,消耗在垃圾收集上的時間不得超過 N 毫秒,這幾乎已經是實時垃圾收集器的特徵了。

上面提到的垃圾收集器,收集的範圍都是整個新生代或者老年代,而 G1 不再是這樣。使用 G1 收集器時,Java 堆的記憶體佈局與其他收集器有很大差別,它將整個Java堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔閡了,它們都是一部分可以不連續 Region 的集合。


本文介紹了 JVM 垃圾回收的原理與垃圾收集器的種類,相信看到這裏的各位人才應該對相關知識有了更深刻的認識。

理論有了,接下來我會持續更新相關內容,介紹下真實場景下如何對 JVM 進行調優以及故障排查。

更多推薦閱讀