一、JVM記憶體分配與回收 下圖為堆記憶體結構圖(註意:元數據區(MetaData )實際上不屬於堆): 1、對象優先在Eden區分配 大多數情況下,對象在新生代中Eden區分配。當Eden區沒有足夠空間進行分配時,JVM將發起一次Minor GC。 提問:Minor GC和Full GC有什麼不同呢? ...
一、JVM記憶體分配與回收
下圖為堆記憶體結構圖(註意:元數據區(MetaData )實際上不屬於堆):
1、對象優先在Eden區分配
大多數情況下,對象在新生代中Eden區分配。當Eden區沒有足夠空間進行分配時,JVM將發起一次Minor GC。
提問:Minor GC和Full GC有什麼不同呢?
- 新生代GC(Minor GC):指發生在新生代的垃圾收集動作,Minor GC非常頻繁,回收速度一般也比較快
- 老年代GC(Major GC / Full GC):指發生在老年代的GC,出現了Full GC經常會伴隨至少一次的Minor GC(並非絕對),Full GC的速度一般會比Minor GC慢10倍以上
例子:定義一個GCTest類。
通過以下方式運行:
添加的參數:-XX:+PrintGCDetails
運行結果:
從上圖我們可以看出eden區記憶體幾乎已經被分配完全(即使程式什麼也不做,新生代也會使用至少2000多k記憶體)。
提問:假設我們再為allocation2分配記憶體會出現什麼情況呢?
簡單解釋一下為什麼會出現這種情況:因為給allocation2分配記憶體的時候eden區記憶體幾乎已經被分配完了,前面講了當Eden區沒有足夠空間進行分配時,虛擬機將發起一次Minor GC,GC期間JVM又發現allocation1無法存入Survior空間,因為allocation1對象占30M,而Survior區只有5M+5M,所以只好通過分配擔保機制把新生代的對象提前轉移到老年代中去,老年代上的空間足夠存放allocation1,所以不會出現Full GC。執行Minor GC後,後面分配的對象如果能夠存在eden區的話,還是會在eden區分配記憶體。可以執行以下代碼驗證:
2、大對象直接進入老年代
大對象就是需要大量連續記憶體空間的對象(比如:字元串、數組)。
提問:為什麼要這樣呢?
為了避免對大對象分配記憶體時由於分配擔保機制帶來的複製而降低效率。
3、長期存活的對象將進入老年代
既然虛擬機採用了分代收集的思想來管理記憶體,那麼記憶體回收時就必須能識別哪些對象應放在新生代,哪些對象應放在老年代中。為了做到這一點,JVM給每個對象一個對象年齡(Age)計數器。
如果對象在Eden區出生並經過第一次Minor GC後仍然能夠存活,並且能被Survior區容納的話,將被移動到Survior區中,並將對象年齡設為1。對象在Survior區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(預設為15歲),就會被晉升到老年代中。對象晉升到老年代的年齡閾值,可以通過參數-XX:MaxTenuringThreshold來設置。
二、如何判斷對象可以被回收
堆中幾乎放著所有的對象實例,對堆垃圾回收前的第一步就是要判斷哪些對象已經死亡(即不能再被任何途徑使用的對象)。
1、引用計數法
給對象中添加一個引用計數器,每當有一個地方引用它,計數器就加1;當引用失效,計數器就減1;任何時候計數器為0的對象就是不可能再被使用的。
這個方法實現簡單,效率高,但是目前主流的JVM中並沒有選擇這個演算法來管理記憶體,其最主要的原因是它很難解決對象之間相互迴圈引用的問題。所謂對象之間的相互引用問題,如下麵代碼所示:除了對象objA和objB相互作用著對方之外,這兩個對象之間再無任何引用。但是它們因為互相引用對方,導致它們的引用計數器都不為0,於是引用計數演算法無法通知GC回收器回收它們。
2、可達性分析演算法
這個演算法的基本思想就是通過一系列的稱為“GC Roots”的對象作為起點,從這些節點開始向下搜索,節點所走過的路徑稱為引用鏈,當一個對象到GC Roots沒有任何引用鏈相連的話,則證明此對象是不可用的。
GC Roots根節點:類載入器、Thread、虛擬機棧的本地變數表、static成員、常量引用、本地方法棧的變數等等。
3、finalize()方法最終判定對象是否存活
即使在可達性分析演算法中不可達的對象,也並非是“非死不可”的,這時候它們暫時處於“緩刑”階段,要真正宣告一個對象死亡,至少要經歷再次標記過程。
標記的前提是對象在進行可達性分析後發現沒有與GC Roots相連接的引用鏈。
(1)第一次標記併進行一次篩選
篩選的條件是此對象是否有必要執行finalize()方法。
當對象沒有覆蓋finalize()方法,或者finalize()方法已經被JVM調用過,JVM將這兩種情況都視為“沒有必要執行”,對象被回收。
(2)第二次標記
如果這個對象被判定為有必要執行finalize()方法,那麼這個對象將會被放置在一個名為:F-Queue的隊列之中,併在稍後由一條JVM自動建立的、低優先順序的Finalizer線程去執行。這裡所謂的“執行”是指JVM會觸發這個方法,但並不承諾會等待它運行結束。這樣做的原因是,如果一個對象finalize()方法中執行緩慢,或者發生死迴圈(更極端的情況),將很可能會導致F-Queue隊列中的其他對象永久處於等待狀態,甚至導致整個記憶體回收系統崩潰。
finalize()方法是對象逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的對象進行第二次小規模標記,如果對象要在finalize()中成功拯救自己(自救)——只要重新與引用鏈上的任何一個對象建立關聯即可,比如把自己賦值給某個類變數或對象的成員變數,那在第二次標記時它將移除出“即將回收”的集合。如果對象這時候還沒逃脫,那基本上它就真的被回收了。
例子:定義一個OOMTest類。
list中的User對象被引用,不會被回收;而下一行的User對象沒有被引用,所以會被GC回收。
User類重寫finalize()方法,每次GC都會調用User對象的finalize()方法:
運行結果:
分析:都是id為負數的User對象被回收。說明確實是list中的id為正數的User對象不會被回收,而下一行id為負數的對象則被回收。(程式最終會記憶體溢出,因為List對象越來越大)
4、如何判斷一個常量是廢棄常量
提問:運行時常量池主要回收的是廢棄的常量。那麼,我們如何判斷一個常量是廢棄常量呢?
假如在常量池中存在字元串“abc”,如果當前沒有任何String對象引用該字元串常量的話,就說明常量“abc”就是廢棄常量,如果這時發送記憶體回收而且有必要的話,“abc”就會被系統清理出常量池。
5、如何判斷一個類是無用的類
提問:方法區主要回收的是無用的類,那麼如何判斷一個類是無用的類呢?
判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。
類需要同時滿足下麵3個條件才能算是“無用的類”:
- 該類所有的實例都已經被回收,也就是說Java堆中不存在該類的任何實例
- 載入該類的ClassLoader已經被回收
- 該類對應的java.string.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法
JVM可以對滿足上述3個條件的無用類進行回收,這裡說的僅僅是“可以”,而並不是和對象一樣不使用了就必然會被回收。
三、垃圾收集演算法
1、標記-清除演算法
演算法分為“標記”和“清除”階段:首先標記出所有需要回收的對象,在標記完成後統一回收所有被標記的對象。它是最基礎的收集演算法,但是會帶來兩個明顯的問題:
(1)效率問題
(2)空間問題(標記清除後會產生大量不連續的碎片)
2、複製演算法
為瞭解決效率問題,“複製”收集演算法出現了。它可以將記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就將還存活的對象複製到另一塊去,然後再把使用的空間一次清理掉。這樣就使每次的記憶體回收都是對記憶體區間的一半進行回收。(比較適合記憶體相對較小的Survivor區中的From和To)
3、標記-整理演算法
根據老年代的特點推出的一種標記演算法,標記過程仍然與“標記-清除“演算法一樣,但後續步驟不是直接對可回收對象進行回收,而是讓所有存活的對象向一端移動,然後直接清理掉端邊界以外的記憶體。
4、分代收集演算法
當前JVM的垃圾收集都採用分代收集演算法,這種演算法沒有什麼新的思想,只是根據對象存活周期的不同將記憶體分為幾塊。一般將Java堆分為新生代和老年代,這樣我們就可以根據各個年代的特點選擇合適的垃圾收集演算法。
比如在新生代中,每次收集都會有大量對象死去,所以可以選擇複製演算法,只需要付出少量對象的複製成本就可以完成每次垃圾收集。而老年代的對象存活幾率是比較高的,而且沒有額外的空間對它進行分配擔保,所以我們必須選擇“標記-清除”或“標記-整理”演算法進行垃圾收集。
四、垃圾收集器
如果說垃圾收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。
雖然我們對各個收集器進行比較,但並非為了挑選出一個最好的收集器。因為直到現在為止還沒有最好的垃圾收集器出現,更加沒有萬能的垃圾收集器,我們能做的就是根據具體應用場景選擇適合自己的垃圾收集器。試想一下:如果有一種四海之內、任何場景都使用的完美收集器存在,那麼我們的HotSpot虛擬機就不會實現那麼多不同的垃圾收集器了。
1、Serial收集器
Serial(串列)收集器是最基本、歷史最悠久的垃圾收集器了。看名字就可以知道這是一個單線程收集器。它的“單線程”不僅僅意味著它只會使用一條垃圾收集線程去完成收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作線程(“Stop The World”),直到它收集結束。
採用分代收集演算法:新生代採用複製演算法,老年代採用標記-整理演算法。
JVM的設計者們當然知道Stop The World帶來的不良用戶體驗,所以在後續的垃圾收集器設計中停頓時間在不斷縮短(仍然還有停頓,尋找最優秀的垃圾收集器的過程仍在繼續)。
但是Serial收集器有沒有優於其他垃圾收集器的地方呢?當然有,它簡單而高效(與其他收集器的單線程相比)。Serial收集器由於沒有線程交互的開銷,自然可以獲得很高的單線程收集效率。
2、ParNew收集器
ParNew收集器其實就是Serial收集器的多線程版本,除了使用多線程進行垃圾收集外,其餘行為(控制參數、收集演算法、回收策略等等)和Serial收集器完全一樣。
採用分代收集演算法:新生代採用複製演算法,老年代採用標記-整理演算法。
它是許多運行在Server模式下的JVM的首要選擇,除了Serial收集器外,只有它能與CMS收集器(真正意義上的併發收集器,後面會介紹到)配合工作。
並行和併發的概念補充:
- 並行(Parallel):指多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態。適合科學計算、後臺處理等弱交互場景。
- 併發(Concurrent):指用戶線程與垃圾收集線程同時執行(但不一定是並行,可能會交替執行),用戶程式在繼續運行,而垃圾收集器運行在另一個CPU上,適合Web應用。
3、Parallel Scavenge收集器(-XX:+UseParallelGC(新生代),-XX:+UseParallelOldGC(老年代))
Parallel Scavenge收集器類似於ParNew收集器,是Server模式(記憶體大於2G,2個CPU)下的預設收集器(JDK 8),那麼它有什麼特別之處呢?
Parallel Scavenge收集器關註點是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的關註點更多是用戶線程的停頓時間(提高用戶體驗)。所謂吞吐量就是CPU中用於運行用戶代碼的時間與CPU總消耗時間的比值。Parallel Scavenge收集器提供了很多參數供用戶找到最合適的停頓時間或最大吞吐量,如果對於收集器運行不太瞭解的話,可以選擇把記憶體管理優化交給JVM去完成也是一個不錯的選擇。
採用分代收集演算法:新生代採用複製演算法,老年代採用標記-整理演算法。
4、Serial Old收集器
Serial收集器的老年代版本,它同樣是一個單線程收集器。它主要有兩大用途:一種用途是在JDK 1.5及以前的版本中與Parallel Scavenge收集器搭配使用,另一種用途是作為CMS收集器的後備方案。
5、Parallel Old收集器
Parallel Scavenge收集器的老年代版本。使用多線程和“標記-整理”演算法。在註重吞吐量以及CPU資源的場合,都可以優先考慮Parallel Scavenge收集器和Parallel Old收集器。
6、CMS收集器(-XX:+UseConcMarkSweepGC(old) -XX:+UseParNewGC)
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。它非常符合在註重用戶體驗的應用上使用,它是HotSpot虛擬機第一款真正意義上的併發收集器,它第一次實現了讓垃圾收集線程與用戶線程(基本上)同時工作。
從名字中的Mark Sweep這兩個詞可以看出,CMS收集器是一種“標記-清除”演算法實現的,它的運作過程相比於前面幾種垃圾收集器來說更加複雜一些。整個過程分為四個步驟:
- 初始標記:暫停所有的其他線程(STW),並記錄下直接與GC Roots相連的對象,速度很快。
- 併發標記:同時開啟GC和用戶線程,用一個閉包結構去記錄可達對象。但在這個階段結束,這個閉包結構並不能保證包含當前所有的可達對象。因為用戶線程可能會不斷的更新引用域,所以GC線程無法保證可達性分析的實時性。所以這個演算法里會跟蹤記錄這些發生引用更新的地方。
- 重新標記:重新標記階段就是為了修正併發標記期間因為用戶程式繼續運行而導致標記產生變動的那一部分對象的標記對象,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比併發標記階段時間短。
- 併發清除:開啟用戶線程,同時GC線程開始對未標記的區域做清掃。
從它的名字就可以看出它是一款優秀的垃圾收集器,主要優點:併發收集、低停頓。但是它有下麵三個明顯的缺點:
- 對CPU資源敏感(會和服務搶資源)
- 無法處理浮動垃圾(在Java業務程式線程與垃圾收集線程併發執行過程中又產生的垃圾,這種浮動垃圾只能等到下一次GC再清理了)
- 它使用的回收演算法“標記-清除”演算法會導致收集結束時會有大量空間碎片產生
CMS的相關參數:
① -XX:+UseConcMarkSweepGC啟動CMS
② -XX:ConcGCThreads:併發的GC線程數(並非STW時間,而是和服務一起執行的線程數)
③ -XX:+UseCMSCompactAtFullCollection:Full GC之後做壓縮(減少碎片)
④ -XX:CMSFullGCsBeforeCompaction:多少次Full GC之後壓縮一次(因壓縮非常消耗時間,所以不能每次Full GC都做)
⑤ -XX:CMSlnitiatingOccupancyFraction:觸發Full GC的條件(預設是92)
⑥ -XX:+UseCMSSlnitiatingOccupanyOnly:是否動態調節
⑦ -XX:+CMSScavengeBeforeRemark:Full GC之前先做YGC(一般這個參數是打開的)
⑧ -XX:+CMSClassUnloadingEnabled:啟動回收Perm區(JDK 1.7及以前)
7、G1收集器(-XX:+UseG1GC)
G1(Garbage-First)是一款面向伺服器的垃圾收集器(JDK 9預設收集器),主要針對配備多顆處理器及大容量記憶體的機器,以極高概率滿足GC停頓時間要求的同時,還具備高吞吐量性能特征。
G1將Java堆劃分為多個大小相等的獨立區域(Region),雖保留新生代和老年代的概念,但不再是物理隔閡了,它們都是(可以不連續) Region的集合。
分配大對象(直接進入Humongous區,專門存放短期巨型對象,不用直接進老年代,避免Full GC的大量開銷)不會因為無法找到連續空間而提前觸發下一次GC。
被視為JDK 1.7中HotSpot虛擬機的一個重要進化特征。它具備以下特點:
- 併發與並行:G1能充分利用CPU、多核環境下的硬體優勢,使用多個CPU(CPU或者CPU核心)來縮短Stop The World停頓時間。部分其他收集器原本需要停頓Java線程來執行GC動作,G1收集器仍然可以通過併發的方式來讓Java程式繼續運行。
- 分代收集:雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但是還是保留了分代的概念。
- 空間整合:與CMS的“標記-清理”演算法不同,G1從整體來看是基於“標記整理”演算法實現的收集器;從局部上來看是基於“複製”演算法實現的。
- 可預測的停頓:這是G1相對於CMS的另一個大優勢,降低停頓時間是G1和CMS共同的關註點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內完成垃圾收集。
G1收集器的運作大致分為以下幾個步驟:
- 初始標記(initial mark,STW):在此階段,G1 GC對根進行標記。該階段與常規的(STW)新生代垃圾回收密切相關。
- 併發標記(Concurrent Marking):G1 GC在整個堆中查找可訪問的(存活的)對象。
- 最終標記(Remark,STW):該階段是STW回收,幫助完成標記周期。
- 篩選回收(Cleanup,STW):篩選回收階段首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間來制定回收計劃,這個階段其實也可以做到與用戶程式一起併發執行,但是因為只回收一部分Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。
G1收集器在後臺維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的Region(這也就是它的名字Garbage-First的由來)。這種使用Region劃分記憶體空間以及有優先順序的區域回收方式,保證了G1收集器在有限時間內可以儘可能高的收集效率。
G1垃圾收集分類:
Young GC:
- ① 新對象進入Eden區
- ② 存活對象拷貝到Survivor區
- ③ 存活時間達到年齡閾值時,對象晉升到Old區
Mixed GC:
- ① 不是Full GC,回收所有的Young和部分Old(根據期望的GC停頓時間確定Old區垃圾收集的有限順序)
- ② global concurrent marking(全局併發標記)
- a. Initial marking phase:標記GC Root,STW
- b. Root region scanning phase:標記存活Region
- c. Concurrent marking phase:標記存活的對象
- d. Remark phase:重新標記,STW
- ③ 修改參數
- a. G1MixedGCLiveThresholdPercent:Old區的region被回收的時候的存活對象占比
- b. G1MixedGCCountTarget:一次global concurrent marking之後,最多執行Mixed GC的次數
- c. G1OldCSetRegionThresholdPercent:一次Mixed GC中能被選入CSet的最多old區的region數量
- ④ 觸發的時機
- a. InitiatingHeapOccupancyPercent:堆占有率達到這個值則觸發global concurrent marking,預設為45%
- b. G1HeapWastePercent:在global concurrent marking結束之後,可以知道區有多少空間要被回收,在每次YGC之後和再次發生Mixed GC之前,會檢查垃圾占比是否達到了此參數,只有達到了,下次才會發生Mixed GC
五、如何選擇垃圾收集器
1、優先調整堆的大小讓伺服器自己來選擇
2、如果記憶體小於100M,使用串列收集器
3、如果是單核,並且沒有停頓時間的要求,串列或JVM自己選擇
4、如果允許停頓時間超過1秒,選擇並行或者JVM自己選擇
5、如果響應時間最重要,並且不能超過1秒,使用併發收集器
下圖有連線的可以搭配使用,官方推薦使用G1,因為性能高