深入理解JVM虚拟机读书笔记——垃圾回收器
注:本文參考自周志明老師的著作《深入理解Java虛擬機(第3版)》,相關電子書可以關注WX公眾號,回復 001 獲取。
如果說收集算法是內存回收的方法論,那垃圾收集器就是內存回收的實踐者。《Java虛擬機規范》中對垃圾收集器應該如何實現并沒有做出任何規定,因此不同的廠商、不同版本的虛擬機所包含的垃圾收集器都可能會有很大差別,不同的虛擬機一般也都會提供各種參數供用戶根據自己的應用特點和要求組合出各個內存分代所使用的收集器。
各款經典收集器之間的關系如下圖所示:
如圖展示了七種作用于不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用,圖中收集器所處的區域,則表示它是屬于新生代收集器抑或是老年代收集器。下面來逐個了解一下不同款的垃圾收集器:
1. Serial收集器
Serial收集器是最基礎、歷史最悠久的收集器,它是一個單線程工作的收集器。這里的“單線程”的意義并不僅僅是說明它只會使用一個處理器或一條收集線程去完成垃圾收集工作,更重要的是強調在它進行垃圾收集時,必須暫停其他所有工作線程,直到它收集結束(“Stop The World”)。
使用Serial收集器進行垃圾回收時,新生代收集采用標記-復制算法,老年代收集采用標記-整理算法,收集流程如圖所示:
對于這種垃圾收集器,進行垃圾回收時出現的“Stop The World”,它暫停所有用戶線程的行為顯然是不可接受的,但是在HotSpot虛擬機設計之初,也是無奈之舉,原書中給出的解釋如下:
對于“Stop The World”帶給用戶的惡劣體驗,早期HotSpot虛擬機的設計者們表示完全理解,但也 同時表示非常委屈:“你媽媽在給你打掃房間的時候,肯定也會讓你老老實實地在椅子上或者房間外待 著,如果她一邊打掃,你一邊亂扔紙屑,這房間還能打掃完?”這確實是一個合情合理的矛盾,雖然垃 圾收集這項工作聽起來和打掃房間屬于一個工種,但實際上肯定還要比打掃房間復雜得多!雖然Serial收集器的“單線程收集”是有明顯的弊端的,但是,他自身的優勢也是很明顯的:
即,簡單而高效,對于內存資源受限的環境,它是所有收集器里額外內存消耗最小的;對于單核處理器或處理器核心數較少的環境來說,Serial收集器由于沒有線程交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。
在用戶桌面的應用場景以及近年來流行的部分微服務應用中,分配給虛擬機管理的內存一般來說并不會特別大,收集幾十兆甚至一兩百兆的新生代(僅僅是指新生代使用的內存,桌面應用甚少超過這個容量),垃圾收集的停頓時間完全可以控制在十幾、幾十毫秒,最多一百多毫秒以內,只要不是頻繁發生收集,這點停頓時間對許多用戶來說是完全可以接受的。所以,Serial收集器對于運行在客戶端模式下的虛擬機來說是一個很好的選擇。
2. ParNew收集器
ParNew收集器實質上是Serial收集器的多線程并行版本,除了同時使用多條線程進行垃圾收集之外,其余的行為包括Serial收集器可用的所有控制參數(例如:-XX:SurvivorRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、對象分配規則、回收策略等都與Serial收集器完全一致,在實現上這兩種收集器也共用了相當多的代碼。ParNew收集器的工作過程如下圖所示:
ParNew收集器除了支持多線程并行收集之外,其他與Serial收集器相比并沒有太多創新之處,但它卻是不少運行在服務端模式下的HotSpot虛擬機。目前只有ParNew收集器能與CMS收集器(后面再介紹)配合工作。
注意:從ParNew收集器開始,后面還將會接觸到若干款涉及“并發”和“并行”概念的收集器。有必要先解釋清楚這兩個名詞。
在談論垃圾收集器的上下文語境中,它們可以理解為:
- 并行(Parallel):并行描述的是多條垃圾收集器線程之間的關系,說明同一時間有多條這樣的線程在協同工作,通常默認此時用戶線程是處于等待狀態。
- 并發(Concurrent):并發描述的是垃圾收集器線程與用戶線程之間的關系,說明同一時間垃圾收集器線程與用戶線程都在運行。由于用戶線程并未被凍結,所以程序仍然能響應服務請求,但由于垃圾收集器線程占用了一部分系統資源,此時應用程序的處理的吞吐量將受到一定影響。
3. Parallel Scavenge收集器
Parallel Scavenge收集器是一款新生代收集器,它是基于標記-復制算法實現的收集器,能夠并行收集的多線程收集器。
Parallel Scavenge收集器的目標則是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是處理器用于運行用戶代碼的時間與處理器總消耗時間的比值,即:
如果虛擬機完成某個任務,用戶代碼加上垃圾收集總共耗費了100分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99%。
停頓時間越短就越適合需要與用戶交互或需要保證服務響應質量的程序,良好的響應速度能提升用戶體驗;而高吞吐量則可以最高效率地利用處理器資源,盡快完成程序的運算任務,主要適合在后臺運算而不需要太多交互的分析任務。
Parallel Scavenge收集器提供了兩個參數用于精確控制吞吐量,分別是控制最大垃圾收集停頓時間的-XX:MaxGCPauseMillis參數,以及直接設置吞吐量大小的-XX:GCTimeRatio參數。
由于與吞吐量關系密切,Parallel Scavenge收集器也經常被稱作“吞吐量優先收集器”。除上述兩個參數之外,Parallel Scavenge收集器還有一個參數-XX:+UseAdaptiveSizePolicy值得我們關注。這是一個開關參數,當這個參數被激活之后,就不需要人工指定新生代的大小(-Xmn)、Eden與Survivor區的比例(-XX:SurvivorRatio)、晉升老年代對象大小(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據當前系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量。這種調節方式稱為垃圾收集的自適應的調節策略(GC Ergonomics)。
4. Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同樣是一個單線程收集器,使用標記-整理算法,主要是供客戶端模式下的HotSpot虛擬機使用。
如果在服務端模式下,它也可能有兩種用途:一種是在JDK 5以及之前的版本中與Parallel Scavenge收集器搭配使用,另外一種就是作為CMS收集器發生失敗時的后備預案,在并發收集發生Concurrent Mode Failure時使用。這兩點都將在后面的內容中繼續講解。
Serial/Serial Old收集器運行示意圖如下所示:
注意:
Parallel Scavenge收集器架構中本身有PS MarkSweep收集器來進行老年代收集,并非直接調用Serial Old收集器,但是這個PS MarkSweep收集器與Serial Old的實現幾乎是一樣的,所以在官方的許多資料中都是直接以Serial Old代替PS MarkSweep。
5. Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多線程并發收集,基于標記-整理算法實現。這個收集器是直到JDK 6時才開始提供的,在此之前,新生代的Parallel Scavenge收集器一直處于相當尷尬的狀態,原因是如果新生代選擇了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器以外別無選擇,其他表現良好的老年代收集器,如CMS無法與它配合工作。
由于老年代Serial Old收集器在服務端應用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整體上獲得吞吐量最大化的效果。同樣,由于單線程的老年代收集中無法充分利用服務器多處理器的并行處理能力,在老年代內存空間很大而且硬件規格比較高級的運行環境中,這種組合的總吞吐量甚至不一定比ParNew加CMS的組合來得優秀。
直到Parallel Old收集器出現后,“吞吐量優先”收集器終于有了比較名副其實的搭配組合,在注重吞吐量或者處理器資源較為稀缺的場合,都可以優先考慮Parallel Scavenge加Parallel Old收集器這個組合。
Parallel Scavenge/Parallel Old收集器運行示意圖:
介紹完上面5種經典的垃圾收集器后,下面要介紹的兩種則是更加先進,且使用普遍的經典垃圾收集器,其中G1垃圾回收器更是使用最普遍的。
6. CMS收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。
目前很大一部分的Java應用集中在互聯網網站或者基于瀏覽器的B/S系統的服務端上,這類應用通常都會較為關注服務的響應速度,希望系統停頓時間盡可能短,以給用戶帶來良好的交互體驗。CMS收集器就非常符合這類應用的需求。
CMS收集器是基于標記-清除算法實現的,它的整個運作過程分為四個步驟:
- 1)初始標記(CMS initial mark)
- 2)并發標記(CMS concurrent mark)
- 3)重新標記(CMS remark)
- 4)并發清除(CMS concurrent sweep)
初始標記、重新標記這兩個步驟仍然需要“Stop The World”。
一、初始標記僅僅只是標記一下GC Roots能直接關聯到的對象,速度很快;
二、并發標記階段就是從GC Roots的直接關聯對象開始遍歷整個對象圖的過程,這個過程耗時較長但是不需要停頓用戶線程,可以與垃圾收集線程一起并發運行;
三、重新標記階段則是為了修正并發標記期間,因用戶程序繼續運作而導致標記產生變動的那一部分對象的標記記錄(詳見3.4.6節中關于增量更新的講解),這個階段的停頓時間通常會比初始標記階段稍長一些,但也遠比并發標記階段的時間短;
四、并發清除階段,清理刪除掉標記階段判斷的已經死亡的對象,由于不需要移動存活對象,所以這個階段也是可以與用戶線程同時并發的。
由于在整個過程中耗時最長的并發標記和并發清除階段中,垃圾收集器線程都可以與用戶線程一起工作,所以從總體上來說,CMS收集器的內存回收過程是與用戶線程一起并發執行的。
通過下圖(Concurrent Mark Sweep收集器運行示意圖 )可以比較清楚地看到CMS收集器的運作步驟中并發和需要停頓的階段:
停頓時間短、響應速度快是CMS收集器的重要優勢,而它也并非完美的垃圾收集器,其主要有以下幾個缺點:
-
CMS是一款基于“標記-清除”算法實現的收集器,這意味著收集結束時會有大量空間碎片產生。
-
CMS收集器對處理器資源非常敏感(面向并發設計的程序都對處理器資源比較敏感)。在并發標記階段,它雖然不會導致用戶線程停頓,但卻會因為占用了一部分線程(或者說處理器的計算能力)而導致應用程序變慢,降低總吞吐量。
-
CMS默認啟動的回收線程數是(處理器核心數量 + 3)/ 4,也就是說,如果處理器核心數在四個或以上,并發回收時垃圾收集線程只占用不超過25%的
處理器運算資源,并且會隨著處理器核心數量的增加而下降。
-
但是當處理器核心數量不足四個時, CMS對用戶程序的影響就可能變得很大。如果應用本來的處理器負載就很高,還要分出一半的運算能力去執行收集器線程,就可能導致用戶程序的執行速度忽然大幅降低。
-
7. Garbage First收集器
Garbage First(簡稱G1)收集器是垃圾收集器技術發展歷史上的里程碑式的成果,JDK 8 Update 40 版本之后,G1收集器被Oracle官方稱為“全功能的垃圾收集 器”(Fully-Featured Garbage Collector)。
G1是一款主要面向服務端應用的垃圾收集器。JDK 9之后,G1取代了Parallel Scavenge加Parallel Old組合,成為服務端模式下的默認垃圾收集器。
G1不再堅持固定大小以及固定數量的分代區域劃分,而是把連續的Java堆劃分為多個大小相等的獨立區域(Region),每一個Region都可以根據需要,扮演新生代的Eden空間、Survivor空間,或者老年代空間。收集器能夠對扮演不同角色的Region采用不同的策略去處理,這樣無論是新創建的對象還是已經存活了一段時間、熬過多次收集的舊對象都能獲取很好的收集效果。
Region中還有一類特殊的Humongous區域,專門用來存儲大對象。G1認為只要大小超過了一個Region容量一半的對象即可判定為大對象。每個Region的大小可以通過參數-XX:G1HeapRegionSize設定,取值范圍為 1MB~32MB,且應為2的N次冪。而對于那些超過了整個Region容量的超級大對象,將會被存放在N個連續的Humongous Region之中,G1的大多數行為都把Humongous Region作為老年代的一部分來進行看待,如下圖(G1收集器Region分區示意圖)所示:
雖然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它們都是一系列區域(不需要連續)的動態集合。
G1收集器之所以能建立可預測的停頓時間模型,是因為它將Region作為單次回收的最小單元,即每次收集到的內存空間都是Region大小的整數倍,這樣可以有計劃地避免在整個Java堆中進行全區域的垃圾收集。
更具體的處理思路是讓G1收集器去跟蹤各個Region里面的垃圾堆積的“價值”大小,價值即回收所獲得的空間大小以及回收所需時間的經驗值,然后在后臺維護一個優先級列表,每次根據用戶設定允許的收集停頓時間(使用參數-XX:MaxGCPauseMillis指定,默認值是200毫秒),優先處理回收價值收益最大的那些Region,這也就是“Garbage First”名字的由來。
這種使用Region劃分內存空間,以及具有優先級的區域回收方式,保證了G1收集器在有限的時間內獲取盡可能高的收集效率。
G1收集器的運作過程大致可劃分為以下四個步驟:
-
初始標記(Initial Marking):僅僅只是標記一下GC Roots能直接關聯到的對象,并且修改TAMS指針的值,讓下一階段用戶線程并發運行時,能正確地在可用的Region中分配新對象。這個階段需要停頓線程,但耗時很短,而且是借用進行Minor GC的時候同步完成的,所以G1收集器在這個階段實際
并沒有額外的停頓。
-
并發標記(Concurrent Marking):從GC Root開始對堆中對象進行可達性分析,遞歸掃描整個堆里的對象圖,找出要回收的對象,這階段耗時較長,但可與用戶程序并發執行。當對象圖掃描完成以后,還要重新處理SATB記錄下的在并發時有引用變動的對象。
-
最終標記(Final Marking):對用戶線程做另一個短暫的暫停,用于處理并發階段結束后仍遺留下來的最后那少量的SATB記錄。
-
篩選回收(Live Data Counting and Evacuation):負責更新Region的統計數據,對各個Region的回收價值和成本進行排序,根據用戶所期望的停頓時間來制定回收計劃,可以自由選擇任意多個Region構成回收集,然后把決定回收的那一部分Region的存活對象復制到空的Region中,再清理掉整個舊Region的全部空間。這里的操作涉及存活對象的移動,是必須暫停用戶線程,由多條收集器線程并行完成的。
從上述階段的描述可以看出,G1收集器除了并發標記外,其余階段也是要完全暫停用戶線程的,換言之,它并非純粹地追求低延遲,官方給它設定的目標是在延遲可控的情況下獲得盡可能高的吞吐量,所以才能擔當起“全功能收集器”的重任與期望。
G1收集器運行示意圖:
通過上圖可以比較清楚地看到G1收集器的運作步驟中并發和需要停頓的階段。
擴展:低延遲垃圾收集器
衡量垃圾收集器的三項最重要的指標是:內存占用(Footprint)、吞吐量(Throughput)和延遲(Latency),三者共同構成了一個“不可能三角[1]”。三者總體的表現會隨技術進步而越來越好,但是要在這三個方面同時具有卓越表現的“完美”收集器是極其困難甚至是不可能的,一款優秀的收集器通常最多可以同時達成其中的兩項。
HotSpot的垃圾收集器從Serial發展到CMS再到G1,性能不斷改進,但是沒有一款收集器可以做到盡善盡美,隨著收集器的發展,目前也有了一些新的垃圾收集器,例如:Shenandoah收集器、ZGC收集器,這兩種收集器感興趣的小伙伴可以自己買本書或者找一些博客去進一步去了解一下。
內存分配與回收策略(重要)
Java技術體系的自動內存管理,最根本的目標是自動化地解決兩個問題:自動給對象分配內存以及自動回收分配給對象的內存。
在經典分代的設計下,新生對象通常會分配在新生代中,少數情況下(例如對象大小超過一定閾值)也可能會直接分配在老年代。對象分配的規則并不是固定的,
《Java虛擬機規范》并未規定新對象的創建和存儲細節,這取決于虛擬機當前使用的是哪一種垃圾收集器,以及虛擬機中與內存相關的參數的設定。
下面來具體了解下內存分配與回收相關幾個要點:
一、對象優先在Eden分配
大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC。
二、大對象直接進入老年代
大對象就是指需要大量連續內存空間的Java對象,最典型的大對象便是那種很長的字符串,或者元素數量很龐大的數組。
大對象對虛擬機的內存分配來說就是一個不折不扣的壞消息,比遇到一個大對象更加壞的消息就是遇到一群“朝生夕滅”的“短命大對象”,我們寫程序的時候應注意避免。在Java虛擬機中要避免大對象的原因是,在分配空間時,它容易導致內存明明還有不少空間時就提前觸發垃圾收集,以獲取足夠的連續空間才能安置好它們,而當復制對象時,大對象就意味著高額的內存復制開銷。
HotSpot虛擬機提供了 -XX:PretenureSizeThreshold 參數,指定大于該設置值的對象直接在老年代分配,這樣做的目的就是避免在Eden區及兩個Survivor區之間來回復制,產生大量的內存復制操作。
三、長期存活的對象將進入老年代
HotSpot虛擬機中多數收集器都采用了分代收集來管理堆內存,為了便于虛擬機決策哪些存活對象應當放在新生代,哪些存活對象放在老年代中,虛擬機給每個對象定義了一個對象年齡(Age)計數器,存儲在對象頭中。
對象通常在Eden區里誕生,如果經過第一次Minor GC后仍然存活,并且能被Survivor容納的話,該對象會被移動到Survivor空間中,并且將其對象年齡設為1歲。對象在Survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15),就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數 -XX:MaxTenuringThreshold 設置。
四、動態對象年齡判定
為了能更好地適應不同程序的內存狀況,HotSpot虛擬機并不是永遠要求對象的年齡必須達到 -XX:MaxTenuringThreshold 才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到 -XX:MaxTenuringThreshold 中要求的年齡。
五、空間分配擔保
在發生Minor GC之前,虛擬機必須先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間,如果這個條件成立,那這一次Minor GC可以確保是安全的。如果不成立,則虛擬機會先查看 -XX:HandlePromotionFailure 參數的設置值是否允許擔保失敗(Handle Promotion Failure);
如果允許,那會繼續檢查老年代最大可用的連續空間是否大于歷次晉升到老年代對象的平均大小,如果大于,將嘗試進行一次Minor GC,盡管這次Minor GC是有風險的;如果小于,或者 -XX:HandlePromotionFailure 設置不允許冒險,那這時就要改為進行一次Full GC。
解釋一下“冒險”是冒了什么風險:
新生代使用復制收集算法,但為了內存利用率,只使用其中一個Survivor空間來作為輪換備份,因此當出現大量對象在Minor GC后仍然存活的情況——最極端的情況就是內存回收后新生代中所有對象都存活,需要老年代進行分配擔保,把Survivor無法容納的對象直接送入老年代,這與生活中貸款擔保類似。老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩余空間,但一共有多少對象會在這次回收中活下來在實際完成內存回收之前是無法明確知道的,所以只能取之前每一次回收晉升到老年代對象容量的平均大小作為經驗值,與老年代的剩余空間進行比較,決定是否進行Full GC來讓老年代騰出更多空間。
取歷史平均值來比較其實仍然是一種賭概率的解決辦法,也就是說假如某次Minor GC存活后的對象突增,遠遠高于歷史平均值的話,依然會導致擔保失敗。如果出現了擔保失敗,那就只好老老實實地重新發起一次Full GC,這樣停頓時間就很長了。
雖然擔保失敗時繞的圈子是最大的,但通常情況下都還是會將 -XX:HandlePromotionFailure 開關打開,避免Full GC過于頻繁。
結尾:
非常建議學習Java的小伙伴,買一本周志明老師的《深入理解Java虛擬機(第3版)》去讀一讀,博客和視頻教程,始終不如看書來得實在呀!
后續會陸續更新,這本書的筆記記的差不多了,排版和格式需要花時間整理,文章都會同步到公眾號上,也歡迎大家通過公眾號加入我的交流qun互相討論jvm這塊的知識內容!
總結
以上是生活随笔為你收集整理的深入理解JVM虚拟机读书笔记——垃圾回收器的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: while语法php格式,PHP Whi
- 下一篇: 78--规格参数的查询与修改功能