垃圾收集器(GC)的作用相信大家都知道,它將我們的不用的記憶體空間給回收,Java的垃圾收集器是”動態分配記憶體和垃圾收集”的。正因為它是動態的,所以很多人都忽略了它,但當出現一些記憶體洩漏、記憶體溢位的問題時,我們必須掌握JVM才能去解決問題
現在,我們從GC設計者的角度來看它需要完成哪些工作:
- 哪些記憶體需要回收
- 什麼時候回收
- 如何回收
對於第一個問題:
上文中,我們說到程式計數器、虛擬機器棧、本地方法棧這3個區域隨執行緒生,隨執行緒死。
棧中的棧幀隨著方法的進入和退出而出棧、入棧,每個棧幀分配多少記憶體基本在類結構確定時就是已知的(不包括JIT的優化)
而Java堆和方法區只在程式執行期間才會知道開闢的空間(),這部分記憶體分配和回收是動態的,所以垃圾收集器關注的這部分記憶體
對於第二個問題
當一段記憶體不再使用(不處於存活狀態)時就回收,下文會談到哪些記憶體將不再使用
對於第三個問題
這就是我們下文要講到的各種回收機制
判斷物件是否’存活’
首先,來看堆,堆中存放了幾乎所有的例項物件,在對堆進行回收記憶體時,要先判斷哪些物件能被回收(存活)。
引用計數法
每當有一個地方引用它,計數器+1,每當一個引用失效,計數器-1;任何時刻計數器為0的物件是不能被使用的。
存在的問題
這種分析雖然簡單,但有一個問題,如迴圈引用:
public class A {
Object obj;
public void testGC(){
A a1 = new A();
A a2 = new A();
a1.obj = a2;
a2.obj = a1;
a1 = null;
a2 = null;
System.gc();
//如果採用引用記數法則不回收
}
}複製程式碼
可達性分析演算法
所以,我們需要一種更全面的回收機制
思路:
通過一系列被稱為“GC Roots”的物件作為起點,從這些節點開始往下搜尋,搜所走過的路徑成為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連,則證明此物件是不可用的
可作為GC Roots的物件:
- 虛擬機器棧中引用的物件
- 方法去中類靜態屬性的物件
- 方法區中常量引用的物件
- 本地方法棧中JNI(native方法)引用的物件
引用
垃圾收集器判斷物件存活都和引用有關,下面來看引用有哪些型別
- 強引用:普遍存在,如Object obj = new Object();
只要引用在,就不會回收 - 軟引用:jdk1.2之後,SoftReference類實現軟引用。在系統發出記憶體溢位之前,會把這些物件二次回收,若還不夠,丟擲異常。
- 弱引用:jdk1.2之後,WeakReference來實現弱引用。只能生存到下一次垃圾收集發生之前
- 虛引用:jdk1.2之後,PhantomReference類實現虛引用。這個物件被系統回收時收到一個通知
生存還是死亡
一個物件要被宣告死亡,要經歷兩部:
- 如果物件進行可達分析後沒有和GC Roots相連,那她將會被第一次標記並且進行一次篩選,如果有finalize()方法則放置在F-Queue佇列中執行finalize()方法,(不保證它有執行結果)
- 如果是第二次被標記並且沒有引用,那就只有被回收了
一個物件的finalize()方法只會被執行一次
在Java9中,finalize()方法已被棄用,原因如下:
- finalize機制可能會導致效能問題,死鎖和執行緒掛起。
- finalize中的錯誤可能導致記憶體洩漏;如果不在需要時,也沒有辦法取消垃圾回收;並且沒有指定不同執行finalize物件的執行順序。
- 沒有辦法保證finlize的執行時間。
回收方法區
永久代的方法區分為兩部分:
- 廢棄常量(如String常量)
- 無用的類(同時滿足以下三種為無用的類)
- 該類所有例項都已經被回收,Java堆中不存在該類的任何例項
- 載入該類的ClassLoader已經被回收
- 對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問
垃圾收集演算法
知道了要回收哪些東西,我們還要知道如何回收,下面來看一下典型的垃圾回收演算法
標記-清除演算法
分為標記和清除兩個階段:首先標記出所有需要回收的物件,在標記完成後同意收回被標記的物件
不足:
- 效率:標記和清除兩個過程效率都不高
- 空間:標記清除後會產生大量不連續的記憶體碎片(這會導致若有大物件但找不到連續記憶體時必須再觸發一次垃圾收集)
複製演算法
他將記憶體分為兩塊,每次只使用其中一塊。當一塊記憶體用完後,就將還存活的物件複製到另一塊上面,再將已使用的記憶體一次清理掉。
但是,我們一般不將它對半分,而是分為一塊較大的Eden和兩塊較小的Survivor區域,HotSpot預設Eden:Survivor比例大小為8:1,即Eden為收集前的空間,一塊Survivor為收集後的大小,只浪費了10%的空間。
注:當每次回收有大於10%的物件存活時,通過分配擔保機制讓Survivor中剩餘存不下的進入老年代
標記-整理演算法
標記過程和標記-清除演算法一樣,清理之前,讓所有存活的物件都向一端移動,然後直接清理掉邊界以外的記憶體。
分帶收集演算法
根據物件存活週期的不同劃分為幾塊,一般為新生代和老年代
- 新生代中,有大量物件死去,用複製演算法
- 老年代中,存活率高,必須使用標記-清理或者標記-整理演算法來回收
HotSpot演算法實現
以上為理論的垃圾收集演算法,實際如HotSpot虛擬機器會對演算法有嚴格的考量。。。
列舉根節點
時間消耗:
- 查詢GC Roots節點
- GC停頓,整個分析期間整個執行系統就像被凍結在某個時間點上,因為查詢時不能出現分析時物件過程稿還在不停變化的情況
OopMap:虛擬機器用它來得知哪些地方存放著物件引用,在類載入完成後,HotSpot就把物件內什麼偏移量上是什麼型別的資料計算出來,在JIT編譯過程中,也會在特定位置記錄棧和暫存器中哪些位置是引用
安全點
程式在安全點才能暫停執行GC,所以安全點一般選定為“是否具有讓程式長時間執行的特徵”(如方法呼叫,迴圈跳轉,異常跳轉),前文“特定位置”就被稱為安全點。
如何讓所有執行緒都跑到最近安全點上停下來:
- 搶先式中斷:把所有執行緒中斷,如果有中斷執行緒不在安全點上,恢復執行緒,讓它跑回安全點上(幾乎沒有了)
- 主動式中斷:設定一個標誌,讓各個執行緒去輪詢這個標誌,發現中斷標誌為真時就自己中斷掛起,輪詢標誌的地方和安全點重合。
安全區域(Safe Region)
安全點保證了程式執行時在不太長的時間內就會遇到可進入的GC的安全點,例如執行緒處於SLeep或Blocked狀態,這時執行緒無法響應JVM中斷請求,這種情況,就需要安全區域來解決。
安全區域是指在一段程式碼中,引用關係不會發生變化。
當執行緒執行到了安全區域中的程式碼,標識自己進入了Safe Region,擋在這段時間裡JVM要發起GC時,就不用管標識自己為Safe Region狀態的執行緒了。線上程要離開Safe Region時,去檢查是否完成根節點列舉,如果完成,執行緒繼續執行;否則它就必須等待直到收到可以安全離開Safe Region的訊號為止。
垃圾收集器
可以理解為收集演算法是記憶體回收的方法論,垃圾收集器就是記憶體回收的具體實現
Serial收集器
特點:
- 單執行緒收集器:在他進行垃圾收集時,必須暫停其他工作執行緒,直到它收集結束
ParNew收集器
特點:
- Serial收集器的多執行緒版本,除了Serial收集器外,只有它能和CMS收集器合作
Parallel Scavenge收集器
特點:
- Parallel Scavenge收集器是一個新生代收集器,它也是使用複製演算法的收集器,又是並行的多執行緒收集器
- 該收集器的目標是達到一個可控制的吞吐量(Throughput)。所謂吞吐量就是CPU用於執行使用者程式碼的時間與CPU總消耗時間的比值,即 吞吐量=執行使用者程式碼時間/(執行使用者程式碼時間+垃圾收集時間)
- 自適應調節策略也是Parallel Scavenge收集器與ParNew收集器的一個重要區別
Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同樣是一個單執行緒收集器,使用標記整理演算法。這個收集器的主要意義也是在於給Client模式下的虛擬機器使用。
如果在Server模式下,主要兩大用途:
- (1)在JDK1.5以及之前的版本中與Parallel Scavenge收集器搭配使用
- (2)作為CMS收集器的後備預案,在併發收集發生Concurrent Mode Failure時使用
Parallel Old收集器
Parallel Old 是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。這個收集器在1.6中才開始提供。
CMS收集器
這類應用尤其重視伺服器的響應速度,希望系統停頓時間最短,以給使用者帶來較好的體驗。CMS收集器就非常符合這類應用的需求
CMS收集器是基於“標記-清除”演算法實現的。它的運作過程相對前面幾種收集器來說更復雜一些,整個過程分為4個步驟:
- 初始標記:標記一下GC Roots能直接關聯的物件,速度快
- 併發標記:GC Roots Tracing的過程
- 重新標記:修正併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,一般比初始標記階段稍長,但比並發標記時間短
- 併發清除:清除
其中,初始標記、重新標記這兩個步驟仍然需要“Stop The World”.
CMS收集器主要優點:
- 併發收集
- 低停頓。
CMS三個明顯的缺點:
- CMS收集器對CPU資源非常敏感。CPU個數少於4個時,CMS對於使用者程式的影響就可能變得很大,為了應付這種情況,虛擬機器提供了一種稱為“增量式併發收集器”的CMS收集器變種。所做的事情和單CPU年代PC機作業系統使用搶佔式來模擬多工機制的思想
- CMS收集器無法處理浮動垃圾,可能出現“Concurrent Mode Failure”失敗而導致另一次Full GC的產生。“浮動垃圾”是指CMS併發清理時使用者執行緒還在執行,伴隨程式執行有新垃圾出現,這一部分垃圾在標記之後出現,所以本次無法清理。在JDK1.5的預設設定下,CMS收集器當老年代使用了68%的空間後就會被啟用,這是一個偏保守的設定,如果在應用中藍年代增長不是太快,可以適當調高引數-XX:CMSInitiatingOccupancyFraction的值來提高觸發百分比,以便降低記憶體回收次數從而獲取更好的效能,在JDK1.6中,CMS收集器的啟動閥值已經提升至92%。
- CMS是基於“標記-清除”演算法實現的收集器,收集結束時會有大量空間碎片產生。空間碎片過多,可能會出現老年代還有很大空間剩餘,但是無法找到足夠大的連續空間來分配當前物件,不得不提前出發FullGC。為了解決這個問題,CMS收集器提供了一個-XX:+UseCMSCompactAtFullCollection開關引數(預設就是開啟的),用於在CMS收集器頂不住要進行FullGC時開啟記憶體碎片合併整理過程,記憶體整理的過程是無法併發的,空間碎片問題沒有了,但停頓時間變長了。虛擬機器設計者還提供了另外一個引數-XX:CMSFullGCsBeforeCompaction,這個引數是用於設定執行多少次不壓縮的Full GC後,跟著來一次帶壓縮的(預設值為0,標識每次進入Full GC時都進行碎片整理)
G1收集器
G1收集器的優勢:
- 並行與併發 (停頓時間少)
- 分代收集 (採用不同的收集方式處理不同年代的堆)
- 空間整理 (標記整理演算法,複製演算法)
- 可預測的停頓 (讓使用者能控制一次收集的時間長度?)
G1採用的堆佈局:
使用G1收集器時,Java堆的記憶體佈局是整個規劃為多個大小相等的獨立區域(稱為Region,大小為2的冪次方,如1M,2M,4M),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region的集合。
能夠預測停頓的時間的原因:
G1收集器之所以能建立可預測的停頓時間模型,是因為它可以有計劃地避免在真個Java堆中進行全區域的垃圾收集。G1跟蹤各個Region裡面的垃圾堆積的價值大小(回收所獲取的空間大小以及回收所需要的時間的經驗值),在後臺維護一個優先列表,每次根據允許的收集時間,優先回收價值最大的Region(這也就是Garbage-First名稱的又來)。這種使用Region劃分記憶體空間以及有優先順序的區域回收方式,保證了G1收集器在有限的時間內可以獲取儘量可能高的收集效率
G1 記憶體“化整為零”的思路:
問:若一個物件在Region中,但他如果有其他Region中、甚至整個堆任意物件有引用關係,做可達性判定物件存活時,要掃描整個對空間嗎?
答:
- 虛擬機器通過Remembered Set避免全堆掃描,每個Region都有與之對應的Remembered Set。
- 當程式對引用型別進行寫操作時,會產生一個Write Barrier暫停中斷寫操作,檢查引用的物件是否處於Region之中。是,就通過CardTable將引用資訊記錄到所屬Region的Remember Set中。
- 回收時,Remembered Set就可以保證不用進行全堆掃描了。
如果不計算維護Remembered Set的操作,G1收集器的運作大致可劃分為一下步驟:
- 初始標記:標記GC Roots能直接關聯的物件,修改Next Top at Mark Start的值,讓下一階段程式執行時,在正確的Region中建立物件,停頓執行緒,耗時短。
- 併發標記:從GC Root對堆物件進行可達性分析,找存活物件,可與使用者執行緒併發執行,耗時長。
- 最終標記:修正併發標記因使用者程式繼續運作導致標記變動的部分,JVM將這段變化記錄在Remembered Set Logs中,最終標記階段將Remembered Set Logs資料合併到Remembered Set中。
- 篩選回收:對各個Region的回收價值和成本進行排序,根據使用者期望GC停頓時間制定回收計劃。
G1的回收模式
G1中提供了三種模式垃圾回收模式,young gc、mixed gc 和 full gc,在不同的條件下被觸發。
- Young GC:一般物件(除了巨型物件)都是在eden region中分配記憶體,當所有eden region被耗盡無法申請記憶體時,就會觸發一次young gc,這種觸發機制和之前的young gc差不多,執行完一次young gc,活躍物件會被拷貝到survivor region或者晉升到old region中,空閒的region會被放入空閒列表中,等待下次被使用。
- 回收整個young region,還會回收一部分的old region
- 老年代被填滿,就會觸發一次full gc(儘量避免)
記憶體分配與回收策略
自動記憶體管理最終可以歸結為自動化地解決了兩個問題:
- 給物件分配記憶體
- 回收分配給物件的記憶體
簡單來說,物件記憶體分配主要是在堆中分配。但是分配的規則並不是固定的,取決於使用的收集器組合以及JVM記憶體相關引數的設定
物件優先在Eden分配
大多數情況下,物件在Eden區分配記憶體
Minor GC和Full GC的區別:
- 新生代GC(Minor GC):指發生在新生代的垃圾回收動作,頻繁,回收速度也快
- 老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少一次Minor GC(並非絕對,在Parallel Scavenege收集器的收集策略裡就有進行Major GC的策略過程選擇),它的速度一般比Minor GC慢十倍。
大物件直接進入老年代
大物件是指,需要連續記憶體空間的Java物件,例如很長的字串或陣列
長期存活的物件進入老年代
虛擬機器給每個物件定義了一個物件年齡(Age)計數器,如果物件在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,會被移動到Survivor空間中,並且物件年齡為1.每在Survivor區中渡過一次Minor GC,年齡增加1,當它的年齡增加到一定程度(預設15),就被晉升到老年代。
動態物件年齡判定
如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代
空間分配擔保
虛擬碼解釋:
//準備Minor GC: if (老年代中最大連續可用空間>
新生代所有物件總空間){
//開始Minor GC
} else {
if (允許擔保失敗){
if (老年代最大連續可用空間>
歷次晉升老年代物件平均大小){
//開始Minor GC
} else {
//開始Full GC
}
} else {
//開始Full GC
}
}複製程式碼
一般來說,新生代只使用一個survivor空間來進行輪換時的備份,所以當出現極端情況(即新生代空間在一次minor GC後全部存活)時survivor空間有可能爆滿,所以此時需要老年代進行分配擔保,即survivor區無法容納的物件都進入老年代。
在JDK 6 Updale 24 之後,Handle PromotionFailure 不會再影響到虛擬機器的空間分配擔保策略,只要老年代的連續空間大於新生代物件總大小或者歷次晉升的平均大小就會進行Minor GC,否則將進行Full GC。
總結
最後,記憶體回收和垃圾收集器很多時候都是影響系統效能,併發能力的原因,虛擬機器也提供了多種收集器和大量的調節引數,因為很多時候我們要選擇自己的業務來設定相應的收集方式