天天看點

JVM系列之GC

概述

垃圾回收解決三個問題:哪些記憶體需要回收?什麼時候回收?如何回收?

垃圾回收關注的是堆heap記憶體。

GC如何發起

垃圾回收針對不同的分區又分為MinorGC和FullGC,不同分區的觸發條件又有不同。總體來說GC的觸發分為主動和被動兩類:

  • 主動:程式顯式調用​

    ​System.gc()​

    ​發起GC(不一定馬上甚至不會GC)
  • 被動:記憶體配置設定失敗,需要清理空間

無論哪種情況,GC發起的方式都是一緻的:

  1. 需要GC的線程發起一個VM_Operation操作(這是一個基類,不同垃圾回收器發起各自的子類操作,如CMS收集器發起的是VM_GenCollectFullConcurrent)
  2. 該操作投遞到一個隊列中,JVM中有一個VMThread線程專門處理隊列中的這些操作請求,該線程調用VM_Operation的evaluate函數來處理具體每一個操作
  3. VM_Operation的evaluate函數調用自身的doit虛函數
  4. 各垃圾回收器派生的VM_Operation子類覆寫doit方法,實作各自的垃圾回收處理工作,一個典型的C++多态的使用

引用計數法

在對象中添加一個引用計數器,每當一個地方引用它時,計數器就加一;當引用失效時,計數器值就減一;任何時刻計數器為零的對象就是不可能再被使用的。

問題:循環引用,循環依賴

可達性分析法

又叫根搜尋算法,通過一系列的GC Roots,也就是根對象作為起始節點集合,從根節點開始,根據引用關系向下搜尋,搜尋過程所走過的路徑稱為引用鍊(Reference Chain),如果某個對象到GC Roots間沒有任何引用鍊相連。

步驟:

  1. 找到所有根節點,即根節點枚舉。停頓時間是非常短暫且相對固定的
  2. 标記:從GC Roots往下繼續周遊對象圖。停頓時間随着java堆中的對象增加而增加的。

為了減少停頓時間,需要讓垃圾回收器和使用者線程同時運作,即并發标記。

理論前提:該算法的全過程都需要基于一個能保障一緻性的快照中才能夠分析,這意味着必須全程當機使用者線程的運作。

三色标記

在周遊對象圖的過程中,把通路的對象按照<是否通路過>這個條件标記成以下三種顔色:

  • 白色:表示對象尚未被垃圾回收器通路過。在可達性分析剛剛開始的階段,所有的對象都是白色的,若在分析結束的階段,仍然是白色的對象,即代表不可達。
  • 黑色:表示對象已經被垃圾回收器通路過,且這個對象的所有引用都已經掃描過。黑色的對象代表已經掃描過,它是安全存活的,如果有其它的對象引用指向黑色對象,無須重新掃描一遍。黑色對象不可能直接(不經過灰色對象)指向某個白色對象。
  • 灰色:表示對象已經被垃圾回收器通路過,但這個對象至少存在一個引用還沒有被掃描過。

    灰色對象是黑色對象與白色對象之間的中間态。當标記過程結束後,隻會有黑色和白色的對象,而白色的對象就是需要被回收的對象。

但垃圾回收器和使用者線程同時運作的。

垃圾回收器在對象圖上面标記顔色,而同時使用者線程在修改引用關系,引用關系修改,對象圖就發生變化,這樣就有可能出現兩種後果:

  • 一種是把原本消亡的對象錯誤的标記為存活,這不是好事,但是其實是可以容忍的,隻不過産生一點逃過本次回收的浮動垃圾而已,下次清理就可以。
  • 一種是把原本存活的對象錯誤的标記為已消亡,這就是非常嚴重的後果,一個程式還需要使用的對象被回收,那程式肯定會是以發生錯誤。

被判定為不可達的對象要成為可回收對象必須至少經曆兩次标記過程。

GC Roots

可達性分析算法的起點是一組稱為GC Roots的對象,包括:

  1. VM棧(棧幀中的本地變量表)中的引用的對象。
  2. 方法區中的類靜态屬性引用的對象。
  3. 方法區中的常量引用的對象。
  4. 本地方法棧中JNI本地方法的引用對象。

HotSpot怎麼快速找到GC Root?

之是以要快速,是因為,執行GC時,是Stop The World,程序響應中斷,GC後還要進行對象引用鍊追溯、對象的複制拷貝等工作,故而GC Roots周遊需要極高的效率。

包括HotSpot在内的現代JVM采取用空間換時間的政策,核心思想:提前将GC Roots的位置資訊記錄起來,GC時,按圖索骥,快速找到它們。

HotSpot使用一組稱為OopMap的資料結構。ordinary object pointer,普通對象指針,就是指一個Java對象,在JVM中或Hotspot源碼層面,對應一個C++執行個體。在Java層面,叫Java對象,在JVM層面,叫oop。與之對應的就是klass,就是一個Java類在JVM中對應的C++執行個體。Map實際上是地圖。

在類加載完成時,HotSpot就把對象内什麼偏移量上是什麼類型的資料計算出來,在JIT編譯過程中,也會在棧和寄存器中哪些位置是引用。在GC掃描時,就可以直接知道哪些是可達對象。

GC Roots的位置資訊也就是在OopMap中。HotSpot源碼中關于OopMap相關資料的建立代碼分散在各個地方,可以通過在源碼目錄下搜尋new OopMap關鍵字找到它們。在函數傳回,異常跳轉,循環跳轉等時刻,JVM将記錄OopMap相關資訊供後續GC時使用。

JVM需要知道一個64bit的資料是一個引用還是一個long型變量?如果它不知道的話,如何進行記憶體回收呢?

保守式GC和準确式GC:

保守式GC:虛拟機不能明确分辨上面說的問題,無法知道棧中的哪些是引用,采用保守的态度,如果一個資料看上去像是一個對象指針(比如這個數字指向堆區,那個位置剛好有一個對象頭部),那麼這種情況下就将其當作一個引用。這樣把可能不是引用的也當成引用,現實點的說就是懶政,這種情況下是可能産生漏網之魚沒有被垃圾回收的

準确式GC:明确知道一個64bit的數字是一個long還是一個對象引用。現代商業JVM均采用這種更先進的方式,JVM知道棧中和對象的結構中每一個位址單元裡裝的是什麼東西,不會錯殺漏殺。

安全點:

HotSpot隻在特定的位置生成OopMap,這些位置稱為安全點。程式執行過程中并非所有地方都可以停下來開始GC,隻有在到達安全點是才可以暫停。安全點的標明基本上以是否具有讓程式長時間執行的特征標明的。比如說方法調用、循環跳轉、異常跳轉等。具有這些功能的指令才會産生Safepoint。

GC算法

  1. 标記-清除(Mark-Sweep)
  2. 複制(Copying)
  3. 标記-整理(Mark-Compact)
  4. 分代收集(Generational Collection),借助前面三種算法實作

标記-清除

Mark-Sweep,最基礎:

  1. 标記出所有需要回收的對象
  2. 在标記完成後統一回收所有被标記的對象

不足:

  1. 效率問題:标記和清除兩個過程的效率都不高
  2. 空間問題:标記清除之後會産生大量不連續的記憶體碎片,空間碎片太多可能會導緻以後在程式運作過程的中需要配置設定較大的對象時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。

标記-整理

Mark-Compact,複制收集算法在對象存活率較高時就要進行較多的複制操作,效率将會變低。更關鍵的是,如果不想浪費50%的空間,就需要有額外的空間進行配置設定擔保,以應對被使用的記憶體中所有對象都100%存活的極端情況,是以在老年代一般不能直接選用這種算法。

根據老年代的特點, 提出标記-整理算法,标記過程仍然與标記-清除算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的記憶體。

複制

Copying,為解決效率問題,它将可用記憶體按容量劃分為大小相等的兩塊,每次隻使用其中的一塊。當這一塊的記憶體用完,就将還存活着的對象複制到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是整個半區進行記憶體回收,記憶體配置設定時也就不用考慮記憶體碎片等複雜情況,隻要移動堆頂指針,按順序配置設定記憶體即可。

不足:記憶體縮小為原來的一半

現在的商業虛拟機都采用這種收集算法來回收新生代,IBM公司的專門研究表明,新生代中的對象98%是朝生夕死的,是以并不需要按照1:1的比例來劃分記憶體空間,而是将記憶體劃分為一塊較大的(新生代)Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor當回收時,将Eden和Survivor中還存活着的對象一次性複制到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的額Survivor空間。HotSpot虛拟機預設Eden和Survivor的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%,隻有10%的記憶體會被浪費。當然,98%的對象可回收隻是一般場景下的資料,沒有辦法保證每次回收都隻有不多于10%的對象存活,當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行配置設定。

跨代引用

年輕代引用老年代的這種跨代不需要單獨處理。

但是老年代引用年輕代的會影響young gc,這種跨代需要處理

GC回收器

主要垃圾收集器如下,圖中标出它們的工作區域、垃圾收集算法,以及配合關系:

JVM系列之GC

Serial

最基礎、曆史最悠久的收集器。如同它的名字(串行),它是一個單線程工作的收集器,使用一個處理器或一條收集線程去完成垃圾收集工作,進行垃圾收集時,必須暫停其他所有工作線程(STW,獨占式),直到垃圾收集結束。适合單CPU伺服器。

Serial是一個新生代收集器,Serial Old是Serial收集器的的老年代版本。Serial/Serial Old收集器的運作過程如圖:

JVM系列之GC

ParNew

實質上是Serial收集器的多線程并行版本,使用多條線程進行垃圾收集,多CPU,停頓時間比Serial少。ParNew/Serial Old收集器運作示意圖:

JVM系列之GC

Parallel Scavenge

ParallerGC,一款新生代收集器,基于标記-複制算法實作,也能夠并行收集。和ParNew有些類似,但Parallel Scavenge主要關注的是垃圾收集的吞吐量。高吞吐量則可以高效率地利用CPU時間,盡快完成程式的運算任務,主要适合在背景運算而不需要太多互動的任務。吞吐量,就是CPU用于運作使用者代碼的時間和總消耗時間的比值,比值越大,說明垃圾收集的占比越小。

JVM系列之GC

Serial Old

Serial收集器的老年代版本,它同樣是一個單線程收集器,使用标記-整理算法。

Parallel Old

Parallel Scavenge收集器的老年代版本,支援多線程并發收集,基于标記-整理算法實作。

CMS

Concurrent Mark Sweep,一種以擷取最短回收停頓時間為目标的收集器。目前很大一部分的Java應用集中在網際網路站或者B/S系統的服務端上,這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。CMS收集器就非常符合這類應用的需求。同樣是老年代的收集器,采用标記-清除算法。GC分為四步:

  • 初始标記(CMS initial mark):單線程運作,需要Stop The World,标記GC Roots能直達的對象
  • 并發标記(CMS concurrent mark):無停頓,和使用者線程同時運作,從GC Roots直達對象開始周遊整個對象圖
  • 重新标記(CMS remark):多線程運作,需要Stop The World,标記并發标記階段産生對象
  • 并發清除(CMS concurrent sweep):無停頓,和使用者線程同時運作,清理掉标記階段标記的死亡的對象

CMS GC運作示意圖如下:

JVM系列之GC

G1

Garbage First(G1)GC是垃圾收集器的一個颠覆性的産物,開創局部收集的設計思路和基于Region的記憶體布局形式。雖然G1也仍是遵循分代收集理論設計的,但其堆記憶體的布局與其他收集器有非常明顯的差異。以前的收集器分代是劃分新生代、老年代、持久代等。

G1把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region采用不同的政策去處理。

JVM系列之GC

這樣就避免收集整個堆,而是按照若幹個Region集進行收集,同時維護一個優先級清單,跟蹤各個Region回收的價值,優先收集價值高的Region。

G1收集器的運作過程大緻可劃分為以下四個步驟:

  • 初始标記(initial mark),标記了從GC Root開始直接關聯可達的對象。STW(Stop the World)執行
  • 并發标記(concurrent marking),和使用者線程并發執行,從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆裡的對象圖,找出要回收的對象
  • 最終标記(Remark),STW,标記再并發标記過程中産生的垃圾
  • 篩選回收(Live Data Counting And Evacuation),制定回收計劃,選擇多個Region 構成回收集,把回收集中Region的存活對象複制到空的Region中,再清理掉整個舊 Region的全部空間。需要STW

G1 vs CMS

對比

  1. CMS收集器是擷取最短回收停頓時間為目标的收集器,CMS工作時,GC工作線程與使用者線程可以并發執行,以此來達到降低手機停頓時間的目的(隻有初始标記和重新标記會STW)。但是CMS收集器對CPU資源非常敏感。在并發階段,雖然不會導緻使用者線程停頓,但是會占用CPU資源而導緻引用程式變慢,總吞吐量下降
  2. CMS僅作用于老年代,是基于标記清除算法,清理過程中會有大量的空間碎片
  3. CMS收集器無法處理浮動垃圾,由于CMS并發清理階段使用者線程還在運作,伴随程式的運作自熱會有新的垃圾不斷産生,這一部分垃圾出現在标記過程之後,CMS無法在本次收集中處理它們,隻好留待下一次GC時将其清理掉
  4. G1是一款面向服務端應用的垃圾收集器,适用于多核處理器、大記憶體容量的服務端系統。G1能充分利用CPU、多核環境下的硬體優勢,使用多個CPU(CPU或CPU核心)來縮短STW的停頓時間,它滿足短時間停頓的同時達到一個高的吞吐量
  5. 從JDK 9開始,G1成為預設的垃圾回收器。當應用有以下任何一種特性時非常适合用G1:Full GC持續時間太長或太頻繁;對象的建立速率和存活率變動很大;應用不希望停頓時間長(長于0.5s甚至1s)
  6. G1将空間劃分成很多塊(Region),然後各自回收。堆比較大時可以采用,采用複制算法,碎片化問題不嚴重。整體上看屬于标記整理算法,局部(region之間)屬于複制算法
  7. G1 需要記憶集 (具體來說是卡表)來記錄新生代和老年代之間的引用關系,這種資料結構在 G1 中需要占用大量的記憶體,可能達到整個堆記憶體容量的 20% 甚至更多。而且 G1 中維護記憶集的成本較高,帶來更高的執行負載,影響效率。是以 CMS 在小記憶體應用上的表現要優于 G1,而大記憶體應用上 G1 更有優勢,大小記憶體的界限是6GB到8GB。

有CMS,還要引入G1?G1主要解決記憶體碎片過多的問題。

CMS優點:CMS最主要的優點在名字上已經展現出來——并發收集、低停頓。

CMS3個明顯的缺點:

  • Mark Sweep算法會導緻記憶體碎片比較多
  • CMS的并發能力比較依賴于CPU資源,并發回收時垃圾收集線程可能會搶占使用者線程的資源,導緻使用者程式性能下降
  • 并發清除階段,使用者線程依然在運作,會産生所謂的理浮動垃圾(Floating Garbage),本次垃圾收集無法處理浮動垃圾,必須到下一次垃圾收集才能處理。如果浮動垃圾太多,會觸發新的垃圾回收,導緻性能降低。

選擇

如何選擇GC,即需要考慮各個GC的适用場景:

  • Serial :如果應用程式有一個很小的記憶體空間(大約100 MB)亦或它在沒有停頓時間要求的單線程處理器上運作
  • Parallel:如果優先考慮應用程式的峰值性能,并且沒有時間要求要求,或者可以接受1秒或更長的停頓時間
  • CMS/G1:如果響應時間比吞吐量優先級高,或者垃圾收集暫停必須保持在大約1秒以内
  • ZGC:如果響應時間是高優先級的,或者堆空間比較大

GC種類

  • 部分收集(Partial GC):指目标不是完整收集整個Java堆的GC,分為:
    • 新生代收集(Minor GC/Young GC):指目标隻是新生代的GC
    • 老年代收集(Major GC/Old GC):指目标隻是老年代的GC。目前隻有CMS收集器會有單獨收集老年代的行為
    • 混合收集(Mixed GC):指目标是收集整個新生代以及部分老年代的GC。目前隻有G1收集器會有這種行為
  • 整堆收集(Full GC):收集整個Java堆和方法區的GC

Minor GC

新建立的對象優先在新生代Eden區進行配置設定,如果Eden區沒有足夠的空間時,就會觸發Young GC來清理新生代。

頻繁Minor GC?通常情況下,由于新生代空間較小,Eden區很快被填滿,就會導緻頻繁Minor GC,可通過增大新生代空間​

​-Xmn​

​來降低Minor GC的頻率。

Full GC

JVM系列之GC

觸發條件有多個,Full GC時會STW,STOP THE WORD。

  1. 在執行Young GC之前,JVM會進行空間配置設定擔保,如果老年代的連續空間小于新生代對象的總大小(或曆次晉升的平均大小),則觸發一次Full GC
  2. 顯式調用​

    ​System.gc()​

    ​方法

    此方法的調用是建議 JVM 進行 Full GC,雖然隻是建議而非一定,但很多情況下它會觸發 Full GC,增加 Full GC 的頻率,也即增加間歇性停頓的次數。是以強烈建議能不使用此方法就不要使用,讓虛拟機自己去管理它的記憶體。可通過​

    ​-XX:+ DisableExplicitGC​

    ​來禁止​

    ​RMI​

    ​調用​

    ​System.gc()​

    ​。
  3. ​jmap -dump​

    ​等指令
  4. 大對象直接進入老年代,從年輕代晉升上來的老對象,嘗試在老年代配置設定記憶體時,但是老年代記憶體空間不夠
  5. Concurrent Mode Failure

    執行CMS GC 的過程中同時有對象要放入老年代,而此時老年代空間不足(有時空間不足是CMS GC時目前的浮動垃圾過多,導緻暫時性的空間不足觸發Full GC),便會報 Concurrent Mode Failure 錯誤,并觸發 Full GC。

  6. JDK 1.7 及以前的永久代空間不足

    在 JDK 1.7 及以前,HotSpot 虛拟機中的方法區是用永久代實作的,永久代中存放的為一些 Class 的資訊、常量、靜态變量等資料,當系統中要加載的類、反射的類和調用的方法較多時,永久代可能會被占滿,在未配置為采用 CMS GC 的情況下也會執行 Full GC。如果經過 Full GC 仍然回收不了,那麼 JVM 會抛出OOM,為避免以上原因引起的 Full GC,可采用的方法為增大永久代空間或轉為使用 CMS GC。

  7. 老年代空間不足,老年代記憶體使用率過高,達到一定比例,也會觸發Full GC
  • 方法區記憶體空間不足:如果方法區由永久代實作,永久代空間不足 Full GC。

頻繁Full GC怎麼辦

Full GC的排查思路大概如下:

  1. 清楚從程式角度,有哪些原因導緻FGC?
    • 大對象:系統一次性加載過多資料到記憶體中(如SQL查詢未做分頁),導緻大對象進入老年代
    • 記憶體洩漏:頻繁建立大量對象,無法被回收(比如IO對象使用完後未調用close方法釋放資源),先引發FGC,最後導緻OOM
    • 程式頻繁生成一些長生命周期的對象,當這些對象的存活年齡超過分代年齡時便會進入老年代,最後引發FGC
    • 程式BUG
    • 代碼中顯式調用gc方法,包括自己的代碼甚至架構中的代碼
    • JVM參數設定問題:包括總記憶體大小、新生代和老年代的大小、Eden區和S區的大小、元空間大小、垃圾回收算法等
  2. 清楚排查問題時能使用哪些工具
    • 公司的監控系統:可全方位監控JVM的各項名額
    • JDK的自帶工具,包括jmap、jstat等常用指令:
    # 檢視堆記憶體各區域的使用率以及GC情況
    jstat -gcutil -h20 pid 1000
    # 檢視堆記憶體中的存活對象,并按空間排序
    jmap -histo pid | head -n20
    # dump堆記憶體檔案
    jmap -dump:format=b,file=heap pid      
  3. 可視化的堆記憶體分析工具:JVisualVM、MAT等
  4. 排查指南
    • 檢視監控,以了解出現問題的時間點以及目前FGC的頻率(可對比正常情況看頻率是否正常)
    • 了解該時間點之前有沒有程式上線、基礎元件更新等情況
    • 了解JVM的參數設定,包括:堆空間各個區域的大小設定,新生代和老年代分别采用哪些垃圾收集器,然後分析JVM參數設定是否合理
    • 再對步驟1中列出的可能原因做排除法,其中元空間被打滿、記憶體洩漏、代碼顯式調用gc方法比較容易排查
    • 針對大對象或者長生命周期對象導緻的FGC,可通過​

      ​jmap -histo​

      ​指令并結合dump堆記憶體檔案作進一步分析,需要先定位到可疑對象
    • 通過可疑對象定位到具體代碼再次分析,這時候要結合GC原理和JVM參數設定,弄清楚可疑對象是否滿足進入到老年代的條件才能下結論

對象什麼時候會進入老年代

  1. 長期存活的對象将進入老年代

    在對象的對象頭資訊中存儲着對象的疊代年齡,疊代年齡會在每次YoungGC之後對象的移區操作中增加,每一次移區年齡加一,當這個年齡達到15(預設)之後,這個對象将會被移入老年代。可以通過這個參數設定這個年齡值:​

    ​- XX:MaxTenuringThreshold​

  2. 大對象直接進入老年代

    有一些占用大量連續記憶體空間的對象在被加載就會直接進入老年代。這樣的大對象一般是一些數組,長字元串之類的對。HotSpot虛拟機提供這個參數來設定:​

    ​-XX:PretenureSizeThreshold​

  3. 動态對象年齡判定

    為了能更好地适應不同程式的記憶體狀況,HotSpot虛拟機并不是永遠要求對象的年齡必須達到​

    ​- XX:MaxTenuringThreshold​

    ​才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代。
  4. 空間配置設定擔保

    假如在Young GC之後,新生代仍然有大量對象存活,就需要老年代進行配置設定擔保,把Survivor無法容納的對象直接送入老年代。

Stop The World如何讓Java線程都停下來對象移動後,引用如何修正?

垃圾回收的過程将伴随着對象的遷徙,而一旦對象遷徙之後,之前指向它的所有引用(包括棧裡的引用、堆裡對象的成員變量引用等等)都将失效。

參考

​​垃圾回收器比較: G1 vs CMS​​​​Java GC的5個問題​​​​9種常見的CMS GC問題分析與解決​​GC知識