Categories
程式開發

Java垃圾回收GC概覽


自動內存管理

這部分的內容可以說是重中之重了,有過C/C++開發工作的人應該知道內存管理的重要性和難度。雖然Java自己實現了內存管理,不用開發人員去操心,但其內存管理還是有不足之處,常常也會出內存洩漏和內存溢出等問題。當我們進行這些問題排查的時候,沒有掌握相關的JVM內存管理知識,那就是盲目,沒有方向,掌握這部分知識在解決問題的時候才能有所依據。

這部分內存主要涉及兩塊,一個是內存模型,內存模型是基礎,影響了後面的內存管理,其中還涉及到了多線程競爭的問題,並發這塊也是一個大問題,這裡就不詳述。第二個是垃圾回收的基礎知識和各種GC算法,了解掌握GC算法有助於問題的分析和性能調優,這塊也是相當重要,但感覺也不用深入到GC算法代碼細節部分,了解其主要的算法思想即可,有需要在去深入。

GC工具的使用和日誌分析就需要自己多動手實踐了。

內存模型

Java垃圾回收GC概覽 29

上圖是Java虛擬機運行時的數據庫,需要掌握其五個部分:方法區、堆、虛擬機棧、本地方法棧、程序計數器

程序計數器:字節碼的行號指示器;線程私有Java虛擬機棧:Java執行的線程內存模型,存放基本數據類型和對象引用;線程私有本地方法棧:為Java方法服務,與Java虛擬機棧類似;線程私有堆:存放對象實例;線程共享方法區:加載的類型信息、常量、靜態變量、即時編譯其編譯後的代碼緩存等數據;線程共享

– 運行時常量池:存放類的版本、字段、方法、接口等描述信息,還有常量池表(存放編譯期生成的各種字面量與符號引用)

還有非運行時的數據區:直接內存。這部分也會被頻繁使用

上面這些哪些會有OOM現象:Java虛擬機棧(變量不斷存放)、本地方法棧(不斷遞歸)、堆(對像不斷存放)、方法區(類信息等不斷存放)、直接內存

OOM示例這塊可以參考:深入理解Java虛擬機:JVM高級特性與最佳實踐》的2.4 實戰:OutOfMemoryError異常

垃圾回收基礎知識

垃圾回收這塊有些前置知識需要了解:回收的判斷標準(可達性分析算法)、分代收集理論、垃圾收集的三個基本算法

可達性分析算法

可達性分析算法:主要是通過枚舉GC Roots作為根節點出發,能夠遍歷到的就是任然可以存活的對象

Java垃圾回收GC概覽 30

在Java技術體系裡面,固定可作為GC Roots的對象包括以下幾種:

在虛擬機棧(棧幀中的本地變量表)中引用的對象,譬如各個線程被調用的方法堆棧中使用到的參數、局部變量、臨時變量等。在方法區中類靜態屬性引用的對象,譬如Java類的引用類型靜態變量。在方法區中常量引用的對象,譬如字符串常量池(String Table)裡的引用。在本地方法棧中JNI(即通常所說的Native方法)引用的對象。 Java虛擬機內部的引用,如基本數據類型對應的Class對象,一些常駐的異常對象(比如NullPointExcepiton、OutOfMemoryError)等,還有系統類加載器。所有被同步鎖(synchronized關鍵字)持有的對象。反映Java虛擬機內部情況的JMXBean、JVMTI中註冊的回調、本地代碼緩存等。

分代收集理論

重要的分代假設裡面,在ZGC之前,GC算法都有圍繞這個假設進行算法設計

1)弱分代假說(Weak Generational Hypothesis):絕大多數對像都是朝生夕滅的。 2)強分代假說(Strong Generational Hypothesis):熬過越多次垃圾收集過程的對象就越難以消亡。 3)跨代引用假說(Intergenerational Reference Hypothesis):跨代引用相對於同代引用來說僅佔極少數。

垃圾收集算法

這個是相當重要的,目前為止的GC算法設計中都起碼有其中一個算法的思想

###### 標記-清除算法

Java垃圾回收GC概覽 31

如它的名字一樣,分為標記和清除兩個階段:首先標記出需要清除的對象,標記完成後進行清除

優點:基礎簡單缺點:

– 第一個是執行效率不穩定,如果Java堆中包含大量對象,而且其中大部分是需要被回收的,這時必須進行大量標記和清除的動作,導致標記和清除兩個過程的執行效率都隨對像數量增長而降低

– 第二個是內存空間的碎片化問題,標記、清除之後會產生大量不連續的內存碎片,空間碎片太多可能會導致當以後在程序運行過程中需要分配較大對象時無法找到足夠的連續內存而不得不提前觸發另一次垃圾收集動作

###### 標記-複製算法

Java垃圾回收GC概覽 32

它將可用內存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當這一塊的內存用完了,就將還存活著的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。如果內存中多數對像都是存活的,這種算法將會產生大量的內存間複製的開銷,但對於多數對像都是可回收的情況,算法需要復制的就是佔少數的存活對象,而且每次都是針對整個半區進行內存回收,分配內存時也就不用考慮有空間碎片的複雜情況,只要移動堆頂指針,按順序分配即可。

G1及前面的算法的年輕代都是使用這個思想來進行垃圾的回收。因為存活對象少,複製所消耗的時間少

優點:實現簡單,運行高效,沒有空間碎片缺點:將可用內存縮小為了原來的一半,空間浪費未免太多了一點

###### 標記-整理算法

Java垃圾回收GC概覽 33

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

其中的標記過程仍然與“標記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對像都向內存空間一端移動,然後直接清理掉邊界以外的內存,“標記-整理”算法的示意圖如上圖所示。

整理還有靈活調整空間,可以及時整理,也可以當碎片化到一定程度再進行整理

優點:沒有空間碎片,空間利用率高缺點:移動存活對象並更新所有引用這些對象的地方將會是一種極為負重的操作,而且這種對象移動操作必須全程暫停用戶應用程序才能進行

Serial收集器

Java垃圾回收GC概覽 34

如上圖所示,Serial收集其是一個單線程的,在其進行垃圾回收時,必須停止業務代碼,讓其專心進行回收,書中有個形象的比喻:“你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或者房間外待著,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完?”

其新生代使用標記-複製算法,老年代使用標記-整理算法

ParNew收集器

Java垃圾回收GC概覽 35

如上圖所示,ParNew是基於Serial的並行版本,其他的基本沒有任何改變,其思想就是利用多線程加快垃圾回收速度

其新生代使用標記-複製算法,老年代使用標記-整理算法

Parallel Scavenge收集器

Parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是盡可能地縮短垃圾收集時用戶線程的停頓時間,而Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是處理器用於運行用戶代碼的時間與處理器總消耗時間的比值

這個收集器設計目標是達到設定吞吐量,還能智能調節

Serial Old收集器

Java垃圾回收GC概覽 36

Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用標記-整理算法。這個收集器的主要意義也是供客戶端模式下的HotSpot虛擬機使用。如果在服務端模式下,它也可能有兩種用途:一種是在JDK 5以及之前的版本中與Parallel Scavenge收集器搭配使用,另外一種就是作為CMS收集器發生失敗時的後備預案,在並發收集發生Concurrent Mode Failure時使用。

Parallel Old收集器

Java垃圾回收GC概覽 37

直到Parallel Old收集器出現後,“吞吐量優先”收集器終於有了比較名副其實的搭配組合,在註重吞吐量或者處理器資源較為稀缺的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器這個組合。

CMS收集器

Java垃圾回收GC概覽 38

CMS是一個突破的節點,其突破點在於將垃圾回收的節點進行細分,達到了縮短STW時間,部分回收過程可以和業務代碼一起運行。

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。目前很大一部分的Java應用集中在互聯網網站或者基於瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的響應速度,希望系統停頓時間盡可能短,以給用戶帶來良好的交互體驗。 CMS收集器就非常符合這類應用的需求。

CMS是基於標記-清楚算法的,如上圖所示,一共分為四個步驟:

1)初始標記:需要STW;標記GC Roots能直接關聯到的對象,速度很快2)並發標記:不需要STW;標記GC Roots的直接關聯對像開始遍歷標記(並發)3)重新標記:需要STW ;修正階段2並發標記中因業務運行導致變動的部分4)並發清除:不需要STW;清理刪除死亡對象,有內存碎片

CMS有如下優缺點:

優點:並發收集、低停頓,適合如今互聯網服務器的快響應需求缺點:

– 對資源敏感,核心4或以上還行,一下影響很大,強佔資源

– 由於浮點垃圾,需預留一部分空間給並發收集時的程序使用,過低過高都不行

– 有空間碎片

G1 GC

Java垃圾回收GC概覽 39

G1 GC說是里程牌式的成果,其突破點在於對於內存佈局的重新設計(Region),可控的最大停頓時間。可以說這些設計和改進,從CMS開始都是針對追求高響應的服務端的。

G1大致也分為下面四個階段:

1)初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的對象,並且修改TAMS指針的值,讓下一階段用戶線程並發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓線程,但耗時很短,而且是藉用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際並沒有額外的停頓。 2)並發標記(Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆裡的對像圖,找出要回收的對象,這階段耗時較長,但可與用戶程序並發執行。當對像圖掃描完成以後,還要重新處理SATB記錄下的在並發時有引用變動的對象。 3)最終標記(Final Marking):對用戶線程做另一個短暫的暫停,用於處理並發階段結束後仍遺留下來的最後那少量的SATB記錄。 4)篩選回收(Live Data Counting and Evacuation):負責更新Region的統計數據,對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來製定回收計劃,可以自由選擇任意多個Region構成回收集,然後把決定回收的那一部分Region的存活對象複製到空的Region中,再清理掉整個舊Region的全部空間。這裡的操作涉及存活對象的移動,是必須暫停用戶線程,由多條收集器線程並行完成的。

關於G1算法是否有STW停頓,這有一個小偷團伙作案的例子:

1)大家一起在地圖標記:首先在地圖上標記出比較富有的人家,準備踩點,在地圖上標記,地圖時靜止的,對應需要STW2)分工進行踩點:需要到具體地方查看,人來人往的,這個時候不是靜止的,不需要STW3)碰頭在地圖上確認:大家碰頭確認哪些人家比較富有和好偷,在地圖上標記出來,對應需要STW4)分頭行動開工:這個時候肯定是月黑風高,要么沒人在家,要么酣然入睡,對應需要STW

G1的優缺點:

優點:可控的、低延遲的、基本無碎片,6G-8G較為合適缺點:跨代之間的回收產生的卡表,佔較多內存,處理較為複雜,6G以下可能CMS好

雪蘭多

Java垃圾回收GC概覽 40

shennadoah感覺是在G1的基礎上最進一步的改進,大致的步驟如下,這裡就不詳細分析了:

初始標記(Initial Marking):與G1一樣,首先標記與GC Roots直接關聯的對象,這個階段仍是“Stop The World”的,但停頓時間與堆大小無關,只與GC Roots的數量相關。並發標記(Concurrent Marking):與G1一樣,遍歷對像圖,標記出全部可達的對象,這個階段是與用戶線程一起並發的,時間長短取決於堆中存活對象的數量以及對像圖的結構複雜程度。最終標記(Final Marking):與G1一樣,處理剩餘的SATB掃描,並在這個階段統計出回收價值最高的Region,將這些Region構成一組回收集(Collection Set)。最終標記階段也會有一小段短暫的停頓。並發清理(Concurrent Cleanup):這個階段用於清理那些整個區域內連一個存活對像都沒有找到的Region(這類Region被稱為Immediate Garbage Region)。並發回收(Concurrent Evacuation):並發回收階段是Shenandoah與之前HotSpot中其他收集器的核心差異。在這個階段,Shenandoah要把回收集裡面的存活對像先複製一份到其他未被使用的Region之中。複製對像這件事情如果將用戶線程凍結起來再做那是相當簡單的,但如果兩者必須要同時並發進行的話,就變得複雜起來了。其困難點是在移動對象的同時,用戶線程仍然可能不停對被移動的對象進行讀寫訪問,移動對像是一次性的行為,但移動之後整個內存中所有指向該對象的引用都還是舊對象的地址,這是很難一瞬間全部改變過來的。對於並發回收階段遇到的這些困難,Shenandoah將會通過讀屏障和被稱為“Brooks Pointers”的轉髮指針來解決(講解完Shenandoah整個工作過程之後筆者還要再回頭介紹它)。並發回收階段運行的時間長短取決於回收集的大小。初始引用更新(Initial Update Reference):並發回收階段複製對象結束後,還需要把堆中所有指向舊對象的引用修正到復制後的新地址,這個操作稱為引用更新。引用更新的初始化階段實際上並未做什麼具體的處理,設立這個階段只是為了建立一個線程集合點,確保所有並發回收階段中進行的收集器線程都已完成分配給它們的對象移動任務而已。初始引用更新時間很短,會產生一個非常短暫的停頓。並發引用更新(Concurrent Update Reference):真正開始進行引用更新操作,這個階段是與用戶線程一起並發的,時間長短取決於內存中涉及的引用數量的多少。並發引用更新與並發標記不同,它不再需要沿著對像圖來搜索,只需要按照內存物理地址的順序,線性地搜索出引用類型,把舊值改為新值即可。最終引用更新(Final Update Reference):解決了堆中的引用更新後,還要修正存在於GC Roots中的引用。這個階段是Shenandoah的最後一次停頓,停頓時間只與GC Roots的數量相關。並發清理(Concurrent Cleanup):經過並發回收和引用更新之後,整個回收集中所有的Region已再無存活對象,這些Region都變成Immediate Garbage Regions了,最後再調用一次並發清理過程來回收這些Region的內存空間,供以後新對象分配使用。

ZGC

Java垃圾回收GC概覽 41

ZGC收集器是一款基於Region內存佈局的,(暫時)不設分代的,使用了讀屏障、染色指針和內存多重映射等技術來實現可並發的標記-整理算法的,以低延遲為首要目標的一款垃圾收集器。

首先從ZGC的內存佈局說起。與Shenandoah和G1一樣,ZGC也採用基於Region的堆內存佈局,但與它們不同的是,ZGC的Region(在一些官方資料中將它稱為Page或者ZPage,本章為行文一致繼續稱為Region)具有動態性——動態創建和銷毀,以及動態的區域容量大小。在x64硬件平台下,ZGC的Region可以具有如圖3-19所示的大、中、小三類容量:

小型Region(Small Region):容量固定為2MB,用於放置小於256KB的小對象。中型Region(Medium Region):容量固定為32MB,用於放置大於等於256KB但小於4MB的對象。大型Region(Large Region):容量不固定,可以動態變化,但必須為2MB的整數倍,用於放置4MB或以上的大對象。每個大型Region中只會存放一個大對象,這也預示著雖然名字叫作“大型Region”,但它的實際容量完全有可能小於中型Region,最小容量可低至4MB。大型Region在ZGC的實現中是不會被重分配(重分配是ZGC的一種處理動作,用於復制對象的收集器階段,稍後會介紹到)的,因為複制一個大對象的代價非常高昂。

接下來是ZGC的核心問題——並發整理算法的實現。染色指針技術

染色指針可以使得一旦某個Region的存活對像被移走之後,這個Region立即就能夠被釋放和重用掉,而不必等待整個堆中所有指向該Region的引用都被修正後才能清理。這點相比起Shenandoah是一個頗大的優勢,使得理論上只要還有一個空閒Region,ZGC就能完成收集,而Shenandoah需要等到引用更新階段結束以後才能釋放回收集中的Region,這意味著堆中幾乎所有對像都存活的極端情況,需要1∶1複製對像到新Region的話,就必須要有一半的空閒Region來完成收集。至於為什麼染色指針能夠導致這樣的結果,筆者將在後續解釋其“自愈”特性的時候進行解釋。染色指針可以大幅減少在垃圾收集過程中內存屏障的使用數量,設置內存屏障,尤其是寫屏障的目的通常是為了記錄對象引用的變動情況,如果將這些信息直接維護在指針中,顯然就可以省去一些專門的記錄操作。實際上,到目前為止ZGC都並未使用任何寫屏障,只使用了讀屏障(一部分是染色指針的功勞,一部分是ZGC現在還不支持分代收集,天然就沒有跨代引用的問題)。內存屏障對程序運行時性能的損耗在前面章節中已經講解過,能夠省去一部分的內存屏障,顯然對程序運行效率是大有裨益的,所以ZGC對吞吐量的影響也相對較低。染色指針可以作為一種可擴展的存儲結構用來記錄更多與對象標記、重定位過程相關的數據,以便日後進一步提高性能。現在Linux下的64位指針還有前18位並未使用,它們雖然不能用來尋址,卻可以通過其他手段用於信息記錄。如果開發了這18位,既可以騰出已用的4個標誌位,將ZGC可支持的最大堆內存從4TB拓展到64TB,也可以利用其餘位置再存儲更多的標誌,譬如存儲一些追踪信息來讓垃圾收集器在移動對象時能將低頻次使用的對象移動到不常訪問的內存區域。

其大致步驟如下:

並發標記(Concurrent Mark):與G1、Shenandoah一樣,並發標記是遍歷對像圖做可達性分析的階段,前後也要經過類似於G1、Shenandoah的初始標記、最終標記(儘管ZGC中的名字不叫這些)的短暫停頓,​​而且這些停頓階段所做的事情在目標上也是相類似的。與G1、Shenandoah不同的是,ZGC的標記是在指針上而不是在對像上進行的,標記階段會更新染色指針中的Marked 0、Marked 1標誌位。並發預備重分配(Concurrent Prepare for Relocate):這個階段需要根據特定的查詢條件統計得出本次收集過程要清理哪些Region,將這些Region組成重分配集(Relocation Set)。重分配集與G1收集器的回收集(Collection Set)還是有區別的,ZGC劃分Region的目的並非為了像G1那樣做收益優先的增量回收。相反,ZGC每次回收都會掃描所有的Region,用範圍更大的掃描成本換取省去G1中記憶集的維護成本。因此,ZGC的重分配集只是決定了裡面的存活對象會被重新復製到其他的Region中,裡面的Region會被釋放,而並不能說回​​收行為就只是針對這個集合裡面的Region進行,因為標記過程是針對全堆的。此外,在JDK 12的ZGC中開始支持的類卸載以及弱引用的處理,也是在這個階段中完成的。並發重分配(Concurrent Relocate):重分配是ZGC執行過程中的核心階段,這個過程要把重分配集中的存活對象複製到新的Region上,並為重分配集中的每個Region維護一個轉發表( Forward Table),記錄從舊對像到新對象的轉向關係。得益於染色指針的支持,ZGC收集器能僅從引用上就明確得知一個對像是否處於重分配集之中,如果用戶線程此時並發訪問了位於重分配集中的對象,這次訪問將會被預置的內存屏障所截獲,然後立即根據Region上的轉發表記錄將訪問轉發到新復制的對像上,並同時修正更新該引用的值,使其直接指向新對象,ZGC將這種行為稱為指針的“自愈”(Self-Healing)能力。這樣做的好處是只有第一次訪問舊對象會陷入轉發,也就是只慢一次,對比Shenandoah的Brooks轉髮指針,那是每次對象訪問都必須付出的固定開銷,簡單地說就是每次都慢,因此ZGC對用戶程序的運行時負載要比Shenandoah來得更低一些。還有另外一個直接的好處是由於染色指針的存在,一旦重分配集中某個Region的存活對像都複製完畢後,這個Region就可以立即釋放用於新對象的分配(但是轉發表還得留著不能釋放掉),哪怕堆中還有很多指向這個對象的未更新指針也沒有關係,這些舊指針一旦被使用,它們都是可以自癒的。並發重映射(Concurrent Remap):重映射所做的就是修正整個堆中指向重分配集中舊對象的所有引用,這一點從目標角度看是與Shenandoah並發引用更新階段一樣的,但是ZGC的並發重映射並不是一個必須要“迫切”去完成的任務,因為前面說過,即使是舊引用,它也是可以自癒的,最多只是第一次使用時多一次轉發和修正操作。重映射清理這些舊引用的主要目的是為了不變慢(還有清理結束後可以釋放轉發表這樣的附帶收益),所以說這並不是很“迫切”。因此,ZGC很巧妙地把並發重映射階段要做的工作,合併到了下一次垃圾收集循環中的並發標記階段裡去完成,反正它們都是要遍歷所有對象的,這樣合併就節省了一次遍歷對象圖的開銷。一旦所有指針都被修正之後,原來記錄新舊對象關係的轉發表就可以釋放掉了。

GC算法的演進

在GC算法的發展的,個人感覺始終圍繞著一個點:減少單次的STW時間。

如使用多線程加速回收,以減少串行GC的整個一次回收的STW時間;將回收過程細化,分階段,更長的一次STW被分成了小部分,分散在各個回收處理階段,也達到了減少單次STW時間。

而且GC算法的演進方向是為了滿足服務端的快速響應。

算法演進圖大致如下:

Java垃圾回收GC概覽 42

參考鏈接

以低成本提供高性能-根據您的需求選擇最佳的JVM和最佳的垃圾收集器使用OkHttp庫處理HTTP請求-教程-教程com.squareup.okhttp3:okhttp:4.9.0OkHttp:TlsUtil中的NoSuchMethodError copyIntoaliostad /超級基準標記