Java垃圾收集機制
博客起名為Java垃圾收集機制,給人的感覺就像是垃圾收集是Java語言特有的。事實上,垃圾收集(Garbage Collection)遠比Java久遠。垃圾收集需要考慮3件事情:哪些內存需要回收、什么時候回收、如何回收。帶着這三個問題,我們去看看Java是如何實現垃圾回收的。
Java的垃圾回收(GC)機制主要作用於運行時數據區的哪些部分呢?在上篇博客“Java虛擬機工作原理”中我們介紹了JVM運行時數據區有程序計數器、虛擬機棧、本地方法棧、堆、方法區5個區域。其中前三個區域隨線程的創建而創建,隨線程的消亡而消亡;棧中的棧幀隨着方法的進入和退出而有條不紊地執行出棧和入棧操作。因此這三個區域的不需要過多的考慮垃圾回收問題。而Java堆和方法區則不一樣一個接口的多個實現類需要的內存可能不一樣,一個方法中的多個分支需要的內存也可能不一樣,只有在程序運行期間才能知道會創建哪些對象,這部分內存的分配和回收都是動態的。因此垃圾收集器所關注的也就是這部分內存。
回到垃圾收集的第一件事上:哪些內存需要回收?Java堆中存放着程序中幾乎所有的對象實例,垃圾收集器在對堆進行回收前,首先需要判斷哪些對象還“活着”,哪些已經“死去”。通常判斷的方法有引用計數算法、可達性分析算法。引用計數算法給對象中添加一個引用計數器,每當一個地方引用它時,計數器值加1;當引用失效時,計數器值減1,如果計數器的值為0,則說明對象不再被使用(死去了)。然而Java虛擬機中並沒有選用計數算法來管理內存,因為引用計數算法難以解決對象之間相互循環引用的問題。可達性分析算法是將一系列稱為“GC Roots”的對象作為起始節點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈,當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的(也死去了)。其中可作為GC Roots對象的有:虛擬機棧(棧幀中本地變量表)中引用的對象,方法去中靜態屬性引用的對象,方法區中常量引用的對象,本地方法棧中引用的對象。上邊說的都是Java堆中的內存回收,而方法區(HotSpot中的永久代)的垃圾收集主要回收兩部分內容:廢棄常量和無用類。判斷一個常量是否是廢棄常量只需判斷是否還存在對該常量有引用的對象。而判斷無用類需要同時滿足3個條件:該類所有的實例都已經被回收,即Java堆中不存在該類的任何實例;加載該類的ClassLoader已經被回收;該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
垃圾收集算法
- 標記-清除算法(Mark-Sweep算法)
算法分為兩個部分(標記、清除),首先標記出所有需要回收的對象,在標記完成后統一回收所有被標記的對象。該算法主要有兩個不足:一個是效率問題,標記和清除兩個過程的效率都不高;一個是空間問題,標記清除后會產生大量的內存碎片。標記清除算法的執行過程如下圖所示:
- 復制算法
為了解決標記-清除算法效率問題,復制算法將可用內存兩等分,每次只使用其中一部分,當使用的部分用完,就將存活的對象復制到令一塊中,然后將使用過的部分一次清除。這樣避免了內存碎片的額問題,但是內存空間的利用率不高。復制算法執行如下:
現在的商業虛擬機都采用這種方法收集新生代,因為新生代中的對象98%是“朝生夕死”,所以並不需要按照1:1分配內存空間,而是將內存分為一塊較大的Eden空間和兩塊較小的Survivor空間。當回收時,將Eden和Survivor中還存活的對象一次性地復制到另外一塊Survivor空間上,最后清理掉Eden和用過的Survivor空間。如果回收時,Eden和Survivor中還存活的對象空間大於另外一塊Survivor空間時,這些存活的對象可直接通過分配擔保機制進入老年代。
- 標記-整理算法
復制算法在對象存活率較高時就需要進行較多的復制操作,效率會變低。而且復制算法會浪費50%的內存空間。老年代中對象的存活率較高,所以在老年代一般不能直接選用復制算法。根據老年代對象存活率較高的特點,“標記-整理”算法應運而生,該算法首先也是標記所有需要回收的對象,然后將存活的對象都向一端移動,然后直接清理掉端邊界以外的內存。如下圖所示:
分代收集算法
該算法並沒有新的思想,只是根據對象存活周期的不同將內存划分為幾塊。一般把Java堆分為新生代和老年代,這樣就可以根據各年代的特點采用適當的收集算法,對於新生代中大批對象死去的特點,選擇復制算法;針對老年代中對象存活率高的特點,使用“標記-清除”或“標記-整理”算法進行回收。
垃圾收集器
垃圾收集器就是垃圾回收算法的具體實現了,我們先看一下JDK 1.7 Update 14之后的HotSpot虛擬機中包含的所有收集器如下圖,我們來依依分析如下垃圾回收器。
- Serial收集器、Serial Old收集器
Serial收集器是一個單線程的收集器,在單線程完成垃圾收集工作並回收垃圾時,必須停止其他所有的工作線程。Serial收集器新生代采取復制算法進行垃圾回收。而Serial Old收集器針對老年代采用標記-整理算法暫停所有用戶線程。其工作過程如下:
- ParNew收集器
ParNew收集器是Serial收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其余行為如Serial收集器的控制參數、收集算法、停頓、回收策略等都與Serial收集器完全一樣。其工作過程如下:
ParNew是Server模式下的虛擬機的首選新生代回收器,在新生代除了Serial回收器,也只有ParNew回收器能和CMS回收器配合工作。
- Parallel Scavenge收集器、Parallel Old收集器
Parallel Scavenge收集器是一個新生代收集器,也是采用復制算法,並行的多線程收集器。它和ParNew收集器關注的側重點不同,它的目標是達到一個可控制的吞吐量,吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間)。吞吐量越高說明CPU的利用率越高,盡快完成程序的運算任務,主要適合在后台運算而不需要太多交互的任務。Parallel Scavenge收集器提供兩個參數用於精確控制吞吐量,分別是控制最大垃圾收集停頓時間-XX:MaxGCPauseMillis參數以及直接設置吞吐量大小的-XX:GCTimeRatio參數。由於Parallel Scavenge收集器是與吞吐量密切相關的,因此也經常被稱為“吞吐量優先”收集器,除了如上兩個參數外,收集器還有-XX:+UseAdaptiveSizePolicy參數,這個參數打開后,就不需要手動指定新生代的大小(-Xmn)、Eden與Survivor的比例(-XX:SurvivorRatio)、晉升老年代對象的大小(-XX:PretenureSizeThreshold)等細節參數了,虛擬機會根據系統的運行情況收集性能監控信息,動態調整這些參數以提供最合適的停頓時間或者最大的吞吐量。
Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多線程和標記-整理算法。采用Parallel Old和Parallel Scavenge收集器組合可以達到較高的吞吐量。
- CMS收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。從名字看,CMS收集器是基於“標記-清除”算法實現的,它的運作過程比其他收集器更加復雜,整個過程分為4個步驟:初始標記、並發標記、重新標記、並發清除。其中初始標記、重新標記仍然需要暫停所有線程。初始標記僅僅標記GC Roots能直接關聯到的對象。並發標記就是進行GC Roots Tracing的過程。重新標記是為了修正並發標記期間因用戶程序繼續運行而導致標記產生變動的那一部分對象的標記記錄,這個階段的時間一般比初始標記階段稍長,但遠比並發標記的時間短。但收集器最長的並發標記和並發清除都可以和用戶線程一起執行。工作過程如下:
CMS是一款並發低停頓的收集器,但是還主要有一下3個明顯缺點:
1.CMS收集器對CPU資源非常敏感,在並發標記、並發清理階段雖然不會導致用戶線程停頓,但是會占用一部分CPU資源而導致應用程序變慢,總吞吐量會降低。CMS默認啟動的回收線程數是(CPU數量+3)/4。在CPU數量較少時,CMS對用戶程序的影響可能更大。
2.CMS收集器無法處理浮動垃圾,可能出現“Concurrent Mode Failure”而導致一次Full GC的產生。由於並發清理階段用戶線程還在運行,因此就會有新的垃圾產生,且CMS無法在當次收集中處理掉,需要留到下一次GC時再清理。這部分垃圾就是浮動垃圾。正是由於並發清理階段用戶線程還在執行,那也就是需要預留足夠的內存空間給用戶線程使用,因此CMS收集器不能像其它收集器一樣等到老年代幾乎完全被填滿才進行收集,需要預留一部分空間提供並發收集時的程序運作使用。如果CMS運行期間預留內存無法滿足需要會出現“Concurrent Mode Failure”失敗,從而觸發Serial Old收集器進行垃圾回收。
3.CMS是一款基於標記-清除算法的回收器,因此會產生內存碎片。為解決內存碎片問題,CMS提供了一個-XX:+UseCMSCompactAtFullCollection開關參數,用於在CMS收集器要進行FullGC時開啟內存碎片的合並整理過程,內存整理過程是無法並發的,因此停頓時間會變長。因此虛擬機設計者還提出了-XX:CMSFullGCsBeforeCompaction參數,這個參數用於設置執行多少次不壓縮的Full GC后,跟着來一次帶壓縮的(默認值為0,表示每次進入Full GC時都進行碎片整理)。
- G1收集器
G1(Garbage-First)收集器是一款面向服務端應用的垃圾收集器,與其他GC收集器相比,G1具備以下特點:
1.並行與並發:G1充分利用多CPU、多核環境下的硬件優勢,使用多個CPU來縮短停頓時間,部分其它收集器原本需要停頓Java線程執行GC動作,G1收集器仍然可以通過並發的方式讓Java程序繼續執行。
2.分代收集:雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但是還是保留了分代的概念。它能夠采用不同的方式去處理新創建的對象和已經存活了一段時間,熬過多次GC的舊對象以獲取更好的收集效果。
3.空間整合:與CMS的“標記--清理”算法不同,G1從整體來看是基於“標記整理”算法實現的收集器;從局部上來看是基於“復制”算法實現的。
4.可預測的停頓:這是G1相對於CMS的另一個大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。
其它收集器收集的范圍是新生代或老年代,而使用G1收集器時,Java堆內存布局與其他收集器有很大差別,它將Java堆划分成多個大小相等的獨立區域,雖保留了新生代和老年代的概念,但新生代和老年代不再是物理隔離的,它們都是一部分Region的集合。
G1收集器之所以能夠建立可預測的停頓時間模型,是因為它可以有計划的避免對整個堆中進行垃圾回收。G1跟蹤各個Region里面的垃圾堆積的價值大小(回收所需要的空間大小以及回收所需要時間的經驗值),在后台維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region,從而提高了在有限時間內獲取盡可能高的收集效率。
G1是把內存“化整為零”,但是各個Region並不是不相關的,因此在某個Region上進行垃圾回收時,對對象做可達性判定確定對象是否存活時,難道還需要掃描整個Java堆才能保證正確性嗎?其實不然,在G1收集器中,Region之間的對象引用,虛擬機使用Remembered Set來避免對全堆的掃描。每個Region都有一個Remembered Set,虛擬機發現程序在對Reference類型的數據進行寫操作時,都會產生一個Write Barrier暫時中斷寫操作,檢查Reference引用的對象是否處於不同的Region中,如果是,便通過CardTable把相關的引用信息記錄到被引用對象所屬的Region的Remembered Set中。當進行內存回收時,在GC根節點的枚舉范圍中加入Remembered Set即可保證不對全堆掃描也不會有遺漏。
如果不計算維護Remembered Set的操作,G1收集器的運作大致分為4個步驟:初始標記、並發標記、最終標記、篩選回收。初始標記階段僅僅只是標記一下GC Roots能直接關聯到的對象,並且修改TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序並發運行時,能在正確可用的Region中創建新對象,這階段需要停頓線程,但耗時很短。並發標記階段是從GC Roots開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序並發執行。而最終標記階段則是則是為了修正在並發標記期間因用戶程序繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機將這段時間對象變化記錄在線程Remembered Set Logs里面。最終標記階段需要把Remembered Set Logs的數據合並到Remembered Set中,這階段需要停頓線程,但是可並行執行。最后在篩選回收階段首先對各Region的回收價值和成本進行排序。根據用戶所期望的GC停頓時間來制定回收計划。