垃圾回收與記憶體分配策略 "垃圾回收與記憶體分配策略" "“垃圾”的定義" "對象是否為“垃圾”" "何為“引用” 四種引用類型" "最後的掙扎 finalize()方法" "回收方法區" "垃圾回收演算法" "回收的前置 分代理論" "標記 清除演算法(Mark Sweep)" "標記 複製演算法" "標記 ...
垃圾回收與記憶體分配策略
“垃圾”的定義
對象是否為“垃圾”
判斷對象是否已成為“垃圾”的兩種方法:引用計數法、可達性分析演算法
- 引用計數法
如果一個對象被引用一次,則加1,如果沒人引用則被回收;存在問題:如果兩個對象迴圈引用,但是沒有任何外部對象引用他們倆,則那兩個對象無法被回收。
- 可達性分析演算法(主流JVM採用)
沒有被根對象(GC ROOT)直接或簡介引用的對象則會被回收
根對象--肯定不能對回收的對象
GC ROOT對象:system class、同步鎖、線程類、本地方法類
何為“引用”--四種引用類型
JDK1.2以後將引用分為:強引用、軟引用、弱引用和虛引用4種,強度依次減弱。
- 強引用
被GC ROOT直接引用(等號賦值 - 軟引用
被GC ROOT間接引用;當記憶體不足時被回收,記憶體充足時不會被回收 - 弱引用
沒有GC ROOT直接引用,當發生垃圾回收時,不管記憶體是否充足都會被回收 - 虛引用
沒有GC ROOT直接引用,虛引用使用時必須配合引用隊列進行管理。
比如創建一個ByteBuffer實現類對象時,會創建個一個Cleaner對象,當ByteBuffer實現類對象沒有再被引用時,ByteBuffer實現類對象會被回收,Cleaner對象則會進入引用隊列,這時候一個referencehandles線程會查找引用隊列中是否存在cleaner對象,如果有則調用Cleaner.clean方法,clean方法則根據記錄的直接記憶體的地址,調用unsafe.freememory方法釋放直接記憶體
- 補充:引用隊列
軟引用、弱引用本身也要占用一定記憶體,當軟引用、弱引用的引用對象都被回收時,則進入引用隊列,會對引用隊列進行後續管理;虛引用引用的對象被釋放後,虛引用會進入引用隊列
最後的掙扎--finalize()方法
即使可達性分析後,對象被判定為“垃圾”,也並非非死不可。一個對象的死亡至少需要兩次標記:
沒有與GC Root的引用鏈,標記一次
對象沒有重寫finalize()方法,或finalize()重寫但已被調用過一次,標記第二次
如果重寫了finalize()方法,且還沒有被調用,那麼對象會被放置在F-Queue的隊列中,會有一條虛擬機自建的、優先度較低的線程Finalizer線程去執行對象的finalize()方法,但為了防止finalize()方法出現死迴圈等異常,並不會保證等待finalize()方法執行結束。在此期間,若對象建立了引用鏈,則對象可以存活一次,否則就“死定了”。
不建議使用該finalize()方法
回收方法區
方法區的垃圾回收主要包含兩部分:廢棄的常量、不再使用的類型
常量的回收類似與Java堆中的對象,當沒有引用時,則允許回收
類型的回收相對比較苛刻,需要同時滿足以下條件,才允許被回收
- 該類所有實例都已被回收
- 該類的類載入器已被回收
- 該類對應的java.lang.Class對象沒有被引用,且在任何地方都不可以通過反射訪問該類方法
垃圾回收演算法
從判定垃圾消亡的角度出發,垃圾回收演算法可以劃分為“引用計數式垃圾收集”、“追蹤式垃圾收集”兩類。在Java虛擬機中的討論都在追蹤式垃圾收集的範疇中。
回收的前置--分代理論
分代設計的理論建立在兩個分代假說之上:
- 弱分代假說:新生對象都是朝生夕死
強分代假說:熬過越多次垃圾回收的對象,就越難以消亡
設計原則:
垃圾收集器應該依據對象的年齡,把Java堆劃分為不同的區域。
- 新生代
朝生夕滅的對象集中在一個區域,每次回收只需關註少量需要存活的對象即可 - 老年代
難以消亡的對象集中在一個區域,可以使用較低的頻率去觸發回收機制
但是,在對新生代進行垃圾收集的時候,不免會出現新生代的中的對象被老年代引用的情況。所以,為了確定新生代區域的存活對象,除了GC Root之外還需要遍歷整個老年代中所有對象來獲得準確的可達性分析。基於此,引入第三條經驗法則:
- 新生代
跨代引用假說:跨代引用相對於同代引用來說只占少數
跨代引用一般傾向於兩個對象同時生存或同時消亡的
設計原則:
在新生代建立全局數據結構(記憶集),把老年代分為若幹小塊,記錄老年代中哪一塊記憶體存在跨代引用
此後,發生minor gc時只有包含了跨代引用的小塊記憶體中的對象才會被加入到GC Root進行掃描
標記-清除演算法(Mark Sweep)
先標記需要回收的對象,再統一清除
效率不穩定,隨著對象數量增多,標記、清除兩個過程的執行效率降低
記憶體碎片化,導致存入大對象時無法獲得足夠的連續記憶體空間,觸發另一次垃圾收集動作
標記-複製演算法
將可用記憶體劃分為兩個完全相等空間,每次只使用其中的一塊。如果其中的一塊記憶體用完,則將存活的對象完全複製到另一塊,再對原來的空間進行統一清除回收。
- 缺點
記憶體空間的浪費
若空間內大量對象都是存活的,複製的開銷增大- 優點
簡單高效
不用考慮記憶體空間碎片化
PS.
現商用Java虛擬機多在新生代中採用該方法
Appel式回收
HotSpot虛擬機中的Serial、ParNew等新生代收集器均採取該策略。具體如下:
把新生代分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次分配只使用Eden和一塊Survivor,發生垃圾回收時,將存活的對象一次性複製給另一塊Survivor空間內,然後清理已用的空間。HotSpot虛擬機給Eden和Survivor預設大小比例為8:1,也就是說會有10%的空間會被浪費。當預留的10%的記憶體空間存不下存活的對象時,,就需要依賴其它記憶體空間(大多為老年代)進行記憶體分配。
標記-整理演算法(Mark Compact)
區別與標記--清除演算法,標記--整理演算法,在標記後將存活的對象移向一端,然後將另一端的空間整體回收,是一種移動式的演算法。
- 優點
不存在碎片化記憶體,則無需依賴複雜的記憶體分配器- 缺點
對象的移動操作需要觸發“Stop The World”耗時較久
標記?清除:整理
標記-清除是一種非移動式演算法、標記-整理是一種移動式演算法,兩者比較說明:
吞吐量比較
吞吐量定義:賦值器和收集器效率之和
不移動會使得收集器效率增大,但是記憶體分配和訪問會比垃圾回收頻率高得多,所以整體吞吐量還是降低的。舉例說明
HotSpot虛擬機中關註吞吐量的Parallel Scavenger收集器基於標記-整理演算法;關註低延遲的CMS收集器基於標記-清除演算法混合方案
使虛擬機多數時間採用標記-清除演算法,暫時容忍碎片的存在,等到碎片化程度開始影響對象的記憶體分配時,在採用標記-整理演算法收集一次(CMS就採取該方式)
經典垃圾回收器
所謂“經典”垃圾回收器是指區別於實驗室階段的、已通過應用實踐的垃圾回收器。
Serial收集器
Serial:新生代:標記-複製演算法
Serial Old:老年代:標記-整理演算法
HotSpot虛擬機運行在客戶端模式下的預設新生代收集器
簡單高效、記憶體消耗最小
ParNew收集器
ParNew:新生代:標記-複製演算法
Serial Old:老年代:標記-整理演算法
激活CMS後,預設的新生代收集器
Serial的多線程版本,預設開啟的線程數與CPU核心數相同
Parallel Scavenge搜集器
標記-複製演算法,與ParNew相似
關註點在於達成可控制的吞吐量(吞吐量=用戶代碼運行時間/總時間;總時間=用戶代碼運行時間+垃圾回收時間)
參數說明
-XXMaxGCPauseMillis
更關註停頓時間
一個大於0的毫秒數,儘量使回收時間不超過這個值
實現原理:犧牲吞吐量和新生代空間獲取,小記憶體新生代空間的回收速度一定由於高記憶體速度,但是回收頻率也會增加-XXGCTimeRatio
更關註吞吐量
0到100之間的整數,代表垃圾回收時間占總時間的比率,相當於吞吐量的倒數-UserAdaptiveSizePolicy
開關函數,激活後虛擬機會根據當前運行情況自動調整Eden與Survivor的記憶體比例、老年代記憶體大小等參數,已提供合適的停頓時間和最大吞吐量
Serial Old 收集器
serial 收集器的老年版本,標記-整理演算法
在CMS收集器併發失敗時的預備方案
Parallel Old 收集器
Parallel Scavenge 收集器的老年版本,標記-整理演算法
在註重吞吐量或處理器資源稀缺時使用
CMS收集器
獲取最短停頓時間的為目標,採用併發-清除演算法
- 工作步驟
初始標記
標記GC Roots能直接關聯的對象,速度很快併發標記
從GC Roots直接關聯到的對象開始遍歷整個對象圖,耗時較長重新標記
修正併發標記期間,因用戶繼續運作導致標記產生變動的部分對象的標記記錄併發清除
清除掉標記的已死亡的對象
整個過程中,併發標記和併發清除耗時最久
- 關鍵問題
併發過程中會占用部分資源
當處理器核心數大於4時,預設回收線程數不超過25%(處理器核心數+3)/4
但是當處理器核心數小於4時,用戶線程執行速度會大幅降低“浮動垃圾”與併發失敗
與用戶程式運行併發運行就必然產生新的垃圾只有等下一次回收時才清理,這部分垃圾稱為“浮動垃圾”,所以需要給用戶線程預留足夠空間。因此,CMS不能等老年代滿了才進行收集,必須預留一部分作為併發時使用。如果CMS運行期間預留的記憶體無法滿足程式分配新對象的需求,就會出現“併發失敗”,這時候需要STW,臨時啟用Serial Old收集器對老年代的垃圾進行收集記憶體碎片
基於標記-清除演算法必然產生記憶體碎片,導致大對象分配時出現記憶體不足進而觸發Full GC。CMS提供-XX:UseCMSCompactAtFullCollection
開關參數(預設開啟),當不得不進行Full GC時進行記憶體碎片整合,即移動存活對象。會使得停頓時間延長
Garbage First 收集器
建立可預測的停頓時間模型,開創了面向局部收集的記憶體設計思路,基於Region的記憶體佈局形式。預設停頓時間為200毫秒
基於Region的記憶體佈局
把連續的Java堆記憶體劃分為多個大小相等的獨立空間,每個空間都可以扮演Eden、Survivor空間或者老年代空間,其中Humongous區域轉為收集大對象(大小超過了一個Region的對象,Region的大小可通過參數調整),G1大多會把Humongous當做老年代看待。收集器可以根據不同的角色採取不同的收集策略。局部收集思想
Region作為每次回收的最小記憶體單位,每次收集到的空間都是Region的整倍數,G1會跟蹤Region堆積的“價值”大小(回收所獲空間/回收所需時間的經驗值),再後臺維護一個優先順序列表,優先回收價值大的Region- 工作步驟
- 初始標記
標記GC Roots能直接關聯的對象,並修改TAMS指針的值,是藉助Minor GC完成,所以不會造成額外的時間成本。 - 併發標記
從GC Roots開始對堆中對象進行可達性分析,可併發執行,掃描完成時重新處理SATB記錄的引用變動 - 最終標記
處理併發標記時的發生變動的對象,STW,併發完成 - 篩選回收
更新Region的統計數據,根據用戶期望的停頓時間結合回收價值,確定需要回收Region集合。把需要回收的Region中存活的對象複製到空Region中,再清理需要回收的全部Region區域。
- 初始標記
- 關鍵問題
跨Region引用的處理辦法
每個Region都維護一張自己的記憶集,記錄別的Region指向自己的指針,並標記這些指針在哪些卡頁範圍之內。其存儲結構本質上是一種哈希表,key是別的Region的起始地址,value是一個集合,存儲卡表的索引號。G1要耗費大越10%到20%的額外記憶體來維持收集器的工作。併發干擾問題
CMS在併發標記時採用增量更新的演算法實現,而G1則通過原始快照(SATB)演算法實現。此外,G1在回收過程中創建新對象的記憶體分配上也做了改動,G1為每個Region設計了兩個名為TAMS(Top At Mark Start)的指針,併發標記中新分配的對象都要在這兩個指針位置以上。G1收集器預設這部分對象是隱式標記過的,預設為存活可靠地停頓預測
-XX:MaxGCPauseMillis
參數指用戶期望的停頓時間,具體實現是以“衰減均值”為理論基礎:在垃圾回收過程中,會記錄每個Region的回收耗時、記憶集中里的臟卡數量等各個可測量的步驟所花費的成本。“衰減均值”更能體現“最近”一段時間的平均狀態,更能在當下使回收不超過預期。(有點活在當下的感覺)
- G1與CMS
- 優點:
可以指定最大停頓時間、分Region的記憶體佈局、按收益動態回收、不會產生記憶體碎片、回收完成後可提供規整的可用記憶體 - 缺點:
記憶體占用、程式執行的額外負載都較高
G1的卡表更為複雜;運行負載方面,CMS使用寫後屏障來更細維護卡表,而G1為了實現原始搜索(SATB)快照演算法,還需要寫前屏障來跟蹤併發時的指針變化情況,G1能減少併發標記和重新標記的消耗,避免像CMS那樣在最終標記階段停頓時間過長。CMS直接同步處理,而G1非同步處理
- 優點:
總結
小記憶體上使用CMS有優勢,而大記憶體狀態下使用G1有更多優勢,而Java堆記憶體容量平衡點大約在6-8GB之間(經驗數據)