垃圾收集器與內存分配策略
when ? what ? why ? how ?
爲什麼要進行垃圾回收?
當需要排查各種內存溢出、內存泄漏問題時,當垃圾收集成爲系統達到更高併發量的瓶頸時,我們需要對內存動態分配和內存回收技術實施必要的監控和調節。
垃圾回收的區域是哪塊?
JVM 內存結構分成程序計數器、虛擬機棧、本地方法棧、堆、方法區。程序計數器、虛擬機棧和本地方法棧都是線程私有的。堆和方法區是線程共享的。
當線程結束時,那些內存(程序計數器、本地方法棧、虛擬機棧)自然就跟隨着回收了。 只有在程序處於運行期間時才能知道會創建那些對象,所以堆和方法區的分配和回收是動態的。
垃圾收集器的區域指的是堆和方法區。
怎樣判斷對象是否已經死了?
1.引用計數算法
給一個對象添加引用計數器,有一個地方引用它,計數器加 1 ,引用失效時,計數器減 1 。
引用計數算法的判定效率很高,但是 Java 虛擬機裏面沒有選用引用計數算法來管理內存,主要因爲它很難解決對象之間相互引用的問題。
2.可達性分析算法
通過一系列的稱爲 “GC Roots”的對象作爲起始點,從這些節點開始向下搜索,搜索所走過的路徑稱爲引用鏈(Reference Chain),當一個對象到 GC Roots 沒有任何引用鏈相連時(從 GC Roots 到這個對象不可達),證明此對象是不可用的。將會判定次對象是可回收對象。
可做爲GC Roots的對象
- 虛擬機棧(棧幀中的本地變量表)中引用的對象
- 方法區中類靜態屬性引用的對象
- 方法區中常量引用的對象
- 本地方法棧中JNI(即一般說的Native方法)引用的對象
JDK1.2 之前只有被引用或沒有被引用兩種狀態。JDK1.2 之後將引用分爲4類。
引用:
- 強引用——垃圾收集器永遠不會回收掉被引用的對象
- 軟引用——在系統將要發生內存溢出異常之前,將會把這些對象列進回收範圍之中進行第二次回收
- 弱引用——只能生存到下一次垃圾收集發生之間
- 虛引用——能在這個對象被收集器回收時收到一個系統通知
要真正宣告一個對象死亡,至少要經歷兩次標記過程,如果對象在進行可達性分析後發現沒有與GC Roots 相連接的引用鏈,那麼它將會被第一次標記並且進行一次篩選,篩選條件是此對象是否有必要執行 finalize() 方法。當對象沒有覆蓋 finalize()方法或 finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視爲“沒有必要執行”。 任何一個對象的finalize()方法都只會被系統自動調用一次。
對象可以在調用 finalize()中拯救自己———只要重新與引用鏈上的任何一個對象建立關係即可。
方法區回收
方法區(HotSpot虛擬機中的永久代)的垃圾回收效率比較低。新生代中常規應用進行一次垃圾收集一般可回收 70% ~ 95% 的空間。
永久代的垃圾回收集主要回收兩部分內容:廢棄常量和無用的類。
廢棄常量與回收堆中的對象非常類似。
無用的類回收需要滿足3個條件虛擬機才可以進行回收
- 該類所有實例都已經被回收,也就是 Java 堆中不存在該類的任何實例。
- 加載該類的 ClassLoader 已經被回收。
- 該類對應的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
有哪些垃圾收集算法?
標記-清理算法
標記-清理算法(Mark-Sweep):分爲“標記”和“清除”兩個階段,首先標記出需要回收的對象(可達性分析算法),然後是清理掉需要回收的對象。
不足處:一個是效率——標記和清理這兩個過程效率都不高,第二個是空間問題如上圖有很多不連續的內存碎片,如果程序運行中需要分配較大對象時,無法找到足夠的連續內存而不提前觸發另一次垃圾收集動作。因爲這個算法的這兩個不足處纔會有後面幾個算法進行優化改進。
複製算法
爲了解決效率問題, “複製”算法出現了,它將容量劃分爲大小相等的兩塊,每次只使用其中一塊。當這一塊內存用完了,就將還存活的對象複製到另外一塊上面,然後再把已使用過的內存空間一次清理掉。這樣每次只對整個半區進行內存回收,內存分配時也就不用考慮內存碎片等複雜情況,只需要移動堆頂指針,按順序分配內存即可,簡單、高效。 缺點:內存需要犧牲一半。
有研究因爲新生代中的對象 98% 都是“朝生夕死”,所以不需要按照 1:1比例來劃分內存空間,而是將內存劃分爲一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和一塊 Survivor。回收時,將Eden 和 Survivor 上存活的對象放到另一個 Survivor 空間上。 HotSpot虛擬機默認 Eden 和 Survivor 空間大小 8:1,也就是新生代中可用內存空間爲整個新生代容量的 90%,只有 10% 會被“浪費”。
如果 Eden 和 Survivor 存活的對象比另一個 Survivor空間要大呢?
會依賴其它內存(老年代)進行分配擔保(Handle Promotion)。會將這些對象直接通過分配擔保機制進入老年代。
標記-整理算法
標記-整理算法(Mark-Compact)和標記-清理算法(Mark-Sweep)很像,但是標記-整理算法標記可回收的對象後不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉邊界以外的內存。
分代收集算法
根據對象存活週期的不同將內存劃分爲幾塊,一般吧 Java 堆劃分爲新生代和老年代。
在新生代中,每次垃圾回收時都發現有大批對象死去,只有少量存活,選用複製算法。
在老年代中,因爲對象存活率高、沒有額外空間對它進行進行分配擔保,選用標記-清理算法或標記-刪除算法來進行回收。
有哪些垃圾收集器?
垃圾收集器是內存回收的具體實現。Java虛擬機規範中對垃圾收集器應該如何實現並沒有任何規定,因此不同的廠商、不同版本的虛擬機所提供的垃圾收集器都可能會有很大的差別,並且一般都會提供參數供用戶根據自己的應用特點和要求組合出各個年代所使用的收集器。
如上圖如果兩個收集器之間存在連線則說明它們可以搭配使用。每個收集器都有自己的特點,我們需要根據具體的應用場景選擇需要的收集器。
Serial 收集器
serial 收集器是最基本、發展歷史最悠久的收集器。這個收集器是單線程的收集器,當它進行垃圾收集時,必須暫停其它所有的工作線程,直到它收集結果 “Stop The World”。
在用戶不可見的情況下把用戶正常工作的線程全部停掉,對很多應用來說都是難以接受的。從 Serial 收集器到 Parallel 收集器再到 CMS 到 G1 收集器,用戶線程停頓時間在不斷縮短,但是仍然沒有完全消除。
Serial 收集器優點:由於沒有線程交互的開銷,專心做垃圾收集可以獲得最高的單線程收集效率。簡單高效。 適用於 Client 模式下的虛擬機(桌面應用場景中,分配給虛擬機管理的內存一般來說不是很大,收集幾十兆甚至一兩百兆的新生代,停頓時間完全可以控制在幾十毫秒最多一百多毫秒以內,只要不頻繁發生這個停頓就可以接受)。
ParNew 收集器
ParNew 收集器是 Serial 收集器的多線程版本。
ParNew 收集器除了是並行的多線程線程收集器(多條垃圾收集線程並行工作,但此時用戶線程仍然處於等待狀態)之外,其他與 Serial 收集器相比並沒有太多創新之處。 適用於 Server 模式下的虛擬機中首選新生代收集器。
Parallel Scavenge 收集器
Parallel Scavenge 收集器也是一個在新生代,使用複製算法,並行的多線程收集器。但是它的關注的是一個可控制的吞吐量(Throughput)。
吞吐量就是 CPU 用於運行用戶代碼的時間與 CPU 總消耗時間的比值,即吞吐量 = 運行用戶代碼時間 / (運行用戶代碼時間 + 垃圾收集時間)
停頓時間越短就越適合需要與用戶交互的程序,良好的響應速度能提升用戶體驗,而高吞吐量則可以高效率的利用 CPU 時間,儘快完成程序的運算任務,主要適合在後臺運算而不需要太多交互的任務。
Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,也是個單線程收集器,使用“標記-整理”算法。適用於Client模式下的虛擬機使用。
用途:
- 與Parallel Scavenge 收集器搭配使用
- 作爲 CMS 收集器的後備預言,在併發收集發生 Concurrent Mode Failure 時使用。
Parallel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多線程和“標記-整理”算法。適用於注重吞吐量以及 CPU 資源敏感的場合。
CMS 收集器
CMS(Concurrent Mark Sweep) 收集器是一種以獲取最短回收停頓時間爲目標的收集器。目前很大一部分的Java應用集中在互聯網網站或 B/S系統的服務端上(這類應用尤其重視服務的響應速度,希望系統停頓時間最短,以給用戶帶來較好的體驗)。
CMS收集器是基於“標記——清楚”算法實現,整個過程分爲 4 個步驟:
- 初始化標記(CMS initial mark)
- 併發標記(CMS concurrent mark)
- 重新標記 (CMS remark)
- 併發清除 (CMS concurrent sweep)
初始標記和重新標記需要“Stop The World”。
初始標記僅僅只是標記一下 GC Roots 能直接關聯到的對象,速度很快,併發標記階段就是進行 GC Roots Tracing 的過程。而重新標記則是爲了修改併發標記期間因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般會比初始標記長,但遠比並發標記的時間短。
併發標記和併發清除過程收集器線程都可以與用戶線程一起工作。所以從總體上來說,CMS 收集器的內存回收過程是與用戶線程一起併發執行的。
CMS 收集器優點:併發收集、低停頓。
併發:用戶線程與垃圾線程收集線程同時執行(但不一定是並行的,可能會交替執行),用戶程序繼續運行,而垃圾收集程序運行於另一個 CPU 上。
缺點:
- CMS 收集器會佔用一部分 CPU 資源而導致應用程序變慢,總吞吐量會降低。
- 無法處理浮動垃圾——由於 CMS 併發清理階段用戶線程還在運行着,伴隨程序運行自然就還會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在當次收集處理掉他們,只能留待下一次 GC 時再清理掉。由於在垃圾收集階段用戶線程還需要運行,那也就還需要預留有足夠的內存空間給用戶線程使用,因此 CMS 收集器不能像其他收集器那樣等到老年代幾乎完全填滿後再進行收集,需要預留一部分空間提供併發收集的程序運行使用。
- CMS 採用的是“標記-清除”算法,會產生大量碎片。如果碎片過多,當有大對象要分配時,無法找到足夠大的連續空間來分配,會提前觸發一次 Full GC。
G1 收集器
G1(Garbage-First)是一款面向服務器端應用的垃圾收集器。
G1特點:
- 並行與併發
- 分代收集: G1 可以獨立管理整個 GC 堆
- 空間整合:總體上看用“標記-整理”算法,局部(兩個Region)用“複製”算法
- 可預測的停頓
G1 將整個 Java 堆劃分爲多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的,它們都是一部分Region(不需要連續)的集合。
G1 收集器之所以能建立可預測的停頓時間模型,是因爲它可以有計劃的避免在整個 Java 堆中進行全區域的垃圾收集。G1 跟蹤各個 Region 裏面的垃圾堆積的價值大小,後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的 Region。
在 G1 收集器中,虛擬器都使用 Remembered Set 來避免全棧掃描。
G1 收集器的運作大致可劃分爲以下幾個步驟:
- 初始化標記 (Intial Marking)
- 併發標記 (Concurrent Marking)
- 最終標記 (Final Marking)
- 篩選標記 (Live Data Counting and Evacuation)
初始標記階段僅僅只是標記一下GC Roots 能直接關聯到的對象,並修改TAMS(Next Top at Mark Start) 的值,讓下一階段用戶程序併發運行時,能在正確可用的 Region 中創建新對象,這階段需要停頓線程,但耗時很短。併發標記是從 GC Root 開始對堆中對象進行可達性分析,找出存活的對象,這個階段耗時較長,但可與用戶程序併發執行。最終標記則是爲了修改併發標記期間因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,虛擬機將這段時間對象變化記錄在線程 Remenbered Set Logs 裏面,最終標記階段需要把 Remenbered Set Logs 的數據合併到 Remenbered Set 中,這階段需要停頓,但可並行執行。最後篩選回收階段首先對各個 Region 的回收價值和成本進行排序,根據用戶所期望的 GC 停頓時間來制定回收計劃。
Minor GC 和 Full GC 有什麼區別?
新生代 GC (Minor GC) :發生在新生代的垃圾收集動作。Minor GC 非常頻繁,回收速度比較快。
老年代 GC (Major GC/Full GC):發生在老年代的 GC, Major GC 一般比 Minor GC 慢 10 倍以上。
內存分配規則
對象優先在 Eden 分配
大多數情況下,對象在新生代 Eden 區中分配。當 Eden 區沒有足夠空間進行分配時,虛擬機將發起 Minor GC。
大對象直接進入老年代
長期存活的對象將進入老年代
虛擬機給每個對象定義了一個對象年齡(Age)計數器。如果對象在 Eden 出生在 Survivor 區中每熬過一次 Minor GC,年齡加 1 歲,當年齡到一定程度(默認15歲),就會晉升到老年代中。
動態對象年齡判定
在 Survivor 空間中相同年齡所有對象大小的總和大於 Survivor 空間的一半,年齡大於或等於該年齡的對象就可以直接進入老年代。
空間分配擔保
在發生 Minor GC 之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麼 Minor GC 可以確保是安全的。如果不成立,則虛擬機會查看 HandlePromotionFailure 設置值是否允許擔保失敗。如果允許,那麼會繼續檢查老年代最大可能連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試着進行一次 Minor GC,儘管這個 Minor GC 是有風險的;如果小於,或 HandlePromotionFailure 設置不允許冒險,那這次也要改爲進行一次 Full GC。
總結
參考
深入理解Java虛擬機
深入理解Java虛擬機這本書很好,上面總結基本都是對書上的知識點進行摘取,不過我發現自己總結了一遍後對於 GC 理解更加深刻了,感覺很有用!
有什麼問題歡迎指出,十分感謝!