程序gc卡頓?我換了G1就高枕無憂了(從時代剖析垃圾收集器原理)

經典案例:
一個堆內存 2G 的資源服務器,PV 50萬,用戶反饋網站速度比較慢。於是公司決定對服務器進行升級,於是將堆內存擴展爲 16 個 G。
但是,用戶反饋卡頓十分嚴重,反而效率更低了。

可能一些對 JVM 還不夠熟悉的同學會不明所以然。其實目前來說,生產環境上對 JVM 的調優還是很重視的。雖然說 Java 這樣的一個沙盒機制,幫我們屏蔽了各種操作系統的差異以及內存回收的工作。但是也正因如此,很多程序員會對底層的機制不瞭解,從而無法理解生產環境中遇見的問題。

所以,爲了不只是做一個底層的 CRUD 小白程序員,我們也應該去了解 一部分(底層的知識是學不完的)相關的、重要的 較爲底層的知識,來幫助我們提高一個整體的格局。從而能夠在真正的生成環境中,讓自己的項目高效而穩定。

(其實最開始我是想一篇文章寫完 JVM 的,不過寫了才發現知識點過於龐大,確實不能放在一篇博文中)

垃圾收集器發展原因

其實最初的時候,Java 程序是不會因爲垃圾回收而產生嚴重卡頓的。而是隨着技術的發展,Java 原始的垃圾收集器才慢慢出現了嚴重的問題。

一個很關鍵的原因,就是 內存大小 !!!

我這裏舉個形象的例子來幫助大家理解。

  • 垃圾收集器,就是去清理一塊空間的垃圾。
    就比如,在你的家裏,有一個小房間,你的小孩子在裏面玩耍,而在不斷玩耍的過程中,製造出越來越多的垃圾。
  • 而小孩子他是不用去自己清理垃圾的,這時候,就輪到你這個家長(相當於垃圾收集器),拿着個小掃帚,去清掃你的孩子屋裏面的垃圾。
  • 這個過程有一個小細節,就是,你的孩子不能一邊玩,一邊產生垃圾,同時你一邊清理。
    因爲在清理垃圾之前,你需要先把地上面的物品先瀏覽一遍,看看哪些是垃圾,確認了才能將其清理。
    如果他一邊生產垃圾,你一邊清理,就無法分清哪些是垃圾,哪些不是垃圾。這時如果貿然清理,很容易清除一些本不該清理的東西,這樣就會造成不可預估的錯誤(你的孩子可能會精神崩潰),程序異常終止。
    當然也可能會因此沒有清理乾淨垃圾,導致垃圾殘留。
  • 所以,你展露出爲人父母的威嚴,讓其停止一切活動(stop the world,簡稱 STW),你先清理垃圾,等到你的清理工作結束之後,他才能繼續活動。
  • 這樣,就能基本保證你的孩子在活動時產生的垃圾可以一直不斷地會被回收,這樣,房間就可以擁有空間,不至於你的孩子最終被垃圾埋起來。

這一切最初看起來都很和諧。但是,慢慢的,世界改變了。

由於世界上經濟和技術的不斷髮展,我們已經能夠購買到越來越高級的機器,一切看起來繁榮昌盛,但是相當於你家的房子越來越大。
這時候,你的清理工作可就越來越繁重了。

  • 曾經,你只要掃一間小小的屋子;
  • 而現如今,你要掃一個偌大的天安門廣場。
  • 曾經,你只要花一會就能清理完你的小屋;
  • 而如今,你掃幾天幾夜也不一定掃的完這麼大的廣場。

當初的堆內存很肯能只有 幾兆,而現在的堆內存,都可以有 幾十、幾百兆,甚至上 T。
所以,垃圾收集器會不斷地發展,同時,也纔有了 JVM 調優的工作。

而我在開頭列舉的案例的原因,也正是因爲內存擴大,導致的 full gc 時間延長。

垃圾收集器的種類和原理

單線程垃圾收集器(Serial)

最初的垃圾收集器伴隨着 Java 一起誕生(Java 1 版本的默認收集器),它是一個單線程的收集器,而且運行良好。
因爲那時候的機器內存還很小,而且基本爲單核 CPU。所以單線程的垃圾收集器非常合適。

這個時候的垃圾收集器爲 Serial 垃圾收集器:
Serial(負責收集年輕代)和 Serial Old(負責老年代)

其中年輕代採用我們熟悉的複製算法,老年代採用標記-整理算法。
(太基礎的我就不細講了)
Serial
Serial 垃圾收集器由於過於簡單,已經很難以適用於目前高性能、多 CPU、大內存 的服務器機器來使用。

  • 每一次 STW,由於只有一個線程去進行垃圾回收,會嚴重浪費多核 CPU 的並行特性優勢
  • 再加上日益增大的內存,日常針對幾十百兆小內存的 Serial 會顯得力不從心,普通的垃圾收集算法會使得程序等待的時間讓人無法忍受

但這也並不是說 Serial 就完全沒有用武之地了

  • 首先對於一些比較老的機器,在只有一個核的情況下,單線程的垃圾收集器會比多線程擁有更高的效率(避免了多線程的上下文切換開銷)
  • 不僅如此,對於一些小型程序,因爲其算法實現簡單,一個 Serial 完全可以應付得來,並且對系統的開銷要比複雜的垃圾收集器低。
    因此,在 Client 端的小型程序,都仍然是默認採用 Serial 垃圾收集器。

多線程垃圾收集器(PS + PO)

Serial 單線程收集器在目前的市場,畢竟也只能去應用在小程序上了。
而對於我們的大型服務器,是絕對不可以用這麼老的單線程垃圾收集器的(你要掃天安門,好歹也要一羣年輕力壯的小夥一起去,而不能讓一個老爺爺獨自去吧)。

爲了跟上時代的步伐,多線程的垃圾收集器也誕生了。(從此之後,垃圾回收再也不用一個人孤零零的了,衆人拾柴火焰高)
其中 Parallel Scavenge 負責年輕代,Parallel Old 負責老年代
PS+PO
就拿我們目前最流行的 Java 1.8 來說,現在的 默認垃圾收集器仍然是 PS + PO,可見其確實很優秀。

但是,不得不承認,它雖然通過多線程提高了垃圾收集的效率,但是,面對日益增大的內存,即便是線程增多了,回收的 STW 可能仍然讓人難以忍受
所以,在幾百兆,到幾個 G 的內存,PS + PO 的多線程組合仍然是非常不錯的,但是,在內存繼續擴大,幾十個 G 的情況,PS + PO 多線程組合仍然會有較長的 STW 停頓時間。

不過,它仍然繼承了上一代 單線程收集器 的優點,在垃圾收集時專心致志,停止用戶線程一切操作,所以它的吞吐量非常可觀(在追求吞吐量優先的情況下,仍推薦 PS + PO 組合)。

劃時代的“併發”收集器(CMS)

不得不說到的 CMS 垃圾收集器,它是一個劃時代的產品。
(因爲它做到了 併發 收集)!!!

併發垃圾收集是指,垃圾收集器可以一邊在用戶線程工作的情況下,同時一邊清理垃圾。這樣,就不容易造成長時間的 STW 停頓。

回到我們之前的類比,假設你可以在你的小孩子一邊玩的情況下,你一邊清理垃圾,那你家小孩便不會因爲 STW 而不能夠繼續玩耍而要大發雷霆了。

CMS 的收集過程分爲 4 步:

  1. 初始標記
  2. 併發標記
  3. 重新標記
  4. 併發清理

在對垃圾收集的時間做過統計分析之後發現,垃圾回收的最耗時間的部分是在(下圖中併發標記)這段時間,於是,CMS 將最耗時間的這一部分 與用戶線程併發執行。

  1. 初始標記很快,沒有用戶線程干擾,並且只標記了 gc root 相關的很少的一部分對象;
  2. 併發標記開始,它要一點一點找出所有的沒有用的對象,同時,由於用戶線程對垃圾回收線程的影響,不斷在操作對象,也可能改變對象的狀態,所以這段併發標記的過程最爲複雜和耗時;
  3. 重新標記也很快,因爲對象基本已經標記完成,並且用戶線程沒有干擾,只需要最後再保證以下完整性和正確性;
  4. 清理垃圾時繼續和用戶線程一起,減少 STW。
    CMS

其實併發垃圾收集很久之前就有設想,但是之前從來沒有真正實現過,因爲這個難度是巨大的。
假設你在掃地的同時,你的孩子在不斷地丟垃圾,你怎麼掃???
所以 CMS 的誕生歷經波折,耗時許久,並且也被詬病有許多的問題(內存碎片、浮動垃圾)。

所以 CMS 在JDK 每一個版本都不是默認的垃圾收集器(儘管有了 CMS,但是 JVM 仍然拿 PS + PO 做默認收集器,要用 CMS 必須手動指定)。

CMS 是回收老年代的收集器,它採用的是 標記-清除 算法。
我們都清楚,標記-清除 算法是垃圾收集器最基礎、簡單高效的回收算法,CMS 通過這個算法來降低垃圾回收造成的 STW 延時。
但是我們也知道,這個收集算法有一致命的缺陷,就是內存碎片。
標記-清除
所以,隨着垃圾收集的不斷髮生,內存的碎片化情況會越來越嚴重,內存都變爲了一小塊一小塊,這時候,如果有大的對象需要進入,可能總內存是夠的,但是卻沒有了連續的一塊內存能夠給它分配。

CMS 的第二個問題,就是:浮動垃圾
因爲 CMS 允許在垃圾收集的部分過程中,用戶線程也能繼續執行任務。那麼,
在垃圾回收時,也會有垃圾在不斷產生,所以就會產生浮動垃圾。
所以可能出現這樣一種情況,在垃圾收集器執行時,用戶線程新產生的對象繼續去佔用內存,然後,突然就內存不夠了。

這兩種情況都是 CMS 致命的問題,沒有了足夠的內存空間:
1、內存碎片,沒有足夠的連續內存
2、回收時不斷生產,導致內存不足

這時,CMS 無法解決,於是,爲了不讓程序掛掉,CMS 就會去請救兵,去讓:
Serial Old(單線程老爺爺)進行垃圾回收

所以,曾經在 PS+PO 的時候,可能垃圾回收嚴重時要十幾分鍾,結果換了 CMS,有一天它突然卡了,一卡就是幾十個小時。。。。。

Parallel Scavenge(PS) 的改造版(ParNew)

實際上,多線程的 Parallel Scavenge(PS)年輕代多線程收集器已經很不錯了。它用於和 Parallel Old(PO)配合使用。

只不過,CMS 誕生之後,並沒有一個年輕代垃圾收集器和它去搭配使用,於是,
Parallel Scavenge 垃圾收集器進行了改造,來專門用於 CMS 的配合使用。

垃圾收集器的巨大發展(G1)

G1 是一種運行在服務端的垃圾收集器,目的是用在 多核、大內存 的機器上,它在大多數情況下可以實現指定的 GC 暫停時間,並且保持較高的吞吐量。

可是爲何 G1 能夠解決 CMS 的問題,並且能夠從容面對更大的內存???

邏輯分代,物理不分代

我們過去學習 JVM 內存空間的時候,都知道 堆分爲 年輕代、老年代,並且年輕代還要分爲 Eden 區 Survivor 區。
這都是爲了年輕代的複製算法高效,並且常用對象放入老年代減少 標記-整理 頻率。

但是,G1 開始打破格局(邏輯上分代,但是物理內存上不劃分代)

通過將大的堆內存劃分成小的一個一個的 Region,通過分治策略,分塊回收,來降低響應延遲。
這時 G1 低延時的一大原因。
G1

Card Table

首先要提到一個概念 card set

我們在垃圾收集的時候,首先第一步就是先確定存活的對象。(我們都知道,要確定對象的存活,只要從根開始搜索,能夠達到的都是存活對象)

在 Minor GC 時,我們只需要清理年輕代的垃圾。
但是,有一個點可能很多人沒有去往那裏想:

  • 就是,我們年輕代中,引用的對象可能在老年代
  • 而老年代引用的對象,可能在年輕代

這樣就有個很頭疼的問題,我們爲了去清理年輕代的垃圾,但是,我們不僅要去掃描年輕代的對象,還要去掃描老年代的對象。(這樣是非常不划算的)
card set
所以 JVM 在內部分成了 一個一個 card
當這個 card 中有對象,指回了年輕代之後,就把這個 card 標記出來;
這樣,以後只需要去遍歷被標記出來的 card 中的對象,其他沒有被標記就不用去管,從而提高效率。

這個記錄表則是 位圖bitmap(不知道的去補基礎)

打個比方,就好像你手裏有一百套房,你要去查看哪些是被租出去的。
這時候,你手裏有一張記錄表,你可以確定是哪幾棟有樓層去出租了。這樣,你就只需要去那些被標記的樓去看一下,哪一層是否有人,從而提高了效率。
card table

Collection Set

提及一下 Collection Set 只是順帶,它和 Remember Set 要區分開來

Collection Set 裏面存放的都是一些要被回收的對象,這些對象可以來自 Eden、Survivor、Old 各個分區,在 Collection Set 存活的數據會在 GC 從一個分區移動到另一個分區。

Remember Set

重點要提及的就是 Remember Set:
在 G1 的每一個 Region 中,都存放着一個 Remember Set,它記錄着其它 Region 中的對象到本 Region 中的引用。
這樣就使得,垃圾收集器不需要掃描整個堆棧來找到誰引用了當前分區的對象,只需要去掃描 Remember Set 即可。

要實現 Remember Set(RSet),就需要再每次給對象賦予引用時做一些額外的操作:
在 Remember Set 中做一條記錄(在 GC 中稱爲 寫屏障)
注意:這裏的寫屏障和內存屏障沒有半毛錢關係 !!!(也不要拿去噴面試官)
remember set

MixedGC

首先,G1 的垃圾回收,不止 MinorGC、FullGC,它還有一個 垃圾回收機制:
MixedGC

MixedGC 回收過程和 CMS 大致相同(就相當於一個仿真版 CMS):

  1. 初始標記
  2. 併發標記
  3. 最終標記
  4. 篩選回收(解決內存碎片問題)

前 3 步幾乎相同,但是最後一步有很大的差異。
它會篩選出垃圾最多的,最需要回收的 Region,然後,將這塊 Region 用複製算法,直接複製到另一塊 Region 上,並進行壓縮,這樣就解決了 CMS 的內存碎片的問題。
mixedgc

三色標記 + SATB

我們知道,不管是 CMS 還是 G1,裏面最關鍵的就是併發標記(它使得垃圾收集線程和工作線程可以同時運行)

而併發標記的關鍵點就是 三色標記算法 !!!

  1. 黑色:表示自己被標記,並且自己所有的孩子也被標記過了(注意孫子不一定被標記)
  2. 灰色:表示自己被標記,但是自己的孩子還沒有被標記
  3. 白色:表示自己還沒有被標記

三色標記的概念理解起來很簡單,就是記錄自己以及孩子是否被標記。
三色標記
不過 三色標記的問題在於:
由於標記是和用戶線程併發執行,因此可能出現漏標現象。

  • 假設,一個黑色對象,將自己一個引用指向了白色對象,(看圖)
  • 然後,這個灰色對象,將自己指向白色對象的引用解除,
  • 那麼,此時由於指向白色對象的對象已經是黑色(表示孩子也已經被掃描過,雖然實際上並沒有)
  • 所以白色對象將被漏標
    三色標記
    爲了去解決併發標記產生的漏標情況,那必須對 改變 的情況 進行記錄

現在有兩種選擇:

  1. (incremental update)
    記錄新增加的引用:
    把黑色重新改成灰色
  2. (SATB:snapshot at the beginning)
    記錄丟失的引用:
    當引用取消時,不把引用直接丟棄,而是保存起來

這時候你可以自己想一想,用哪一種方式好。

——————————————— 華麗的分割線 ————————————————

雖然說兩種方法可以達到同一種效果,但是,

  1. 我們看第一種方法,我們再最後最終標記的時候,我們要把那些由黑變灰的對象重新再次掃描一遍,
    這樣就增加了掃描的次數,影響了效率
    (尤其是那個黑對象的孩子還特別多的時候)
  2. 而第二種,僅僅只是將引用額外保存,只需要多花費一點點地空間,
    但是不會產生重複掃描對象的情況,從而可以 節省 GC 時間

所以我們可以很確定地選擇第二種,因爲我們的 G1 就是爲了解決 GC 的過長 STW 時間;
G1 在實際的運用這個方法的過程中,在對象引用被取消時,會將引用推入堆棧,
下次掃描時拿到這個引用,由於有 Remember Set(RSet)的存在,就不再需要去掃描整個堆去查找指向這個白色對象的引用
因此在 G1 中,Remember Set(RSet)和 SATB 完美融合。

不過在 CMS 之中所採用的是第一種方法,所以 G1 也改進了 CMS 的這一個不足。

其它垃圾收集器

ZGC 我這裏沒有給大家講,因爲 ZGC 出現的時間還不是特別長,我對它的瞭解並不夠深入。所以我在這裏暫時不去細講,不過大家也可以去網上瀏覽一些其它的博文來對它有一定的瞭解。
Shenandoah 垃圾收集器同樣如此。

不過我們可以知道的是,從 G1 開始,這些垃圾收集器都是針對大內存、低響應,它們的目標是一致的,只不過內存大小適用範圍不一樣,
G1 可以適用從幾十 G,到上百 G 的內存,而 ZGC 可以用於上 T 的內存。

還有一個叫 Epsilon,這個垃圾收集器不是用來回收垃圾的。。。。它根本不去回收。
所以,它僅僅只是爲了調試程序而使用,而不是放到生產環境中的。

垃圾收集器總結歸納圖

其中上面的表示年輕代(Serial、ParNew、Parallel Scavenge),
下面的表示老年代(CMS、Serial Old、Parallel Old)
其中的實線連線表示他們是常用的垃圾收集器組合,虛線則一般不用。

G1 雖然邏輯分代、但是物理上已經不分代了,而是劃分成一小塊一小塊的 Region
從 ZGC 開始完全不分代。
垃圾收集器的種類

作者的話

其實這篇文章比我預估的寫作時間要短,大概 6 小時左右,(可能因爲畫圖稍稍拖了點時間,不然會更短一些)。
一開始我再寫完垃圾收集器之後想要進而跟進一步,寫一寫關於 JVM 的調優。(不過發現內容過於龐大,不適合擠在一塊去寫)

我們之所以要學習 JVM,就是爲了去調優 !!!

現在 JVM 的知識也是面試的重災區,一個程序員如果對 JVM 的底層有一定的瞭解(當然絕對不用你去看 Hotspot 虛擬機源碼,這個深度的時間和收益並不合算),那麼他在程序的編寫過程中就會避免掉一系列的問題,以及對效率做一定的提高。
不僅如此,由於前幾代的 垃圾收集器 的種種弊端,使得線上生產環境下出現的問題頗多,因此也需要有精通 JVM 的優秀人員能夠去排查其中的問題,找到解決的思路與辦法。

共勉。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章