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

csdn 發佈 2020-08-05T00:03:25+00:00

可預測停頓:這是 G1 的另一大優勢,降低停頓時間是 G1 和 CMS 的共同關注點,但 G1 除了追求低停頓外,還能建立可預測的停頓時間模型。

作者 | 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 進行調優以及故障排查。

關鍵字: