一文讀懂Java垃圾回收機制原理
本文轉(zhuǎn)載自微信公眾號「碼農(nóng)私房話」,作者曹至梧。轉(zhuǎn)載本文請聯(lián)系碼農(nóng)私房話公眾號。
本文內(nèi)容源自閱讀《深入理解 Java 虛擬機》書籍后整理的筆記,內(nèi)容較多,概括了 Java 垃圾回收機制、垃圾回收器以及內(nèi)存分配策略等內(nèi)容,在了解 Java 垃圾回收機制前,建議先閱讀 Java 內(nèi)存區(qū)域。
Java 垃圾回收機制
1. 垃圾回收主要關(guān)注 Java 堆
圖摘自《碼出高效》
Java 內(nèi)存運行時區(qū)域中的程序計數(shù)器、虛擬機棧、本地方法棧隨線程而生滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執(zhí)行著出棧和入棧操作。每一個棧幀中分配多少內(nèi)存基本上是在類結(jié)構(gòu)確定下來時就已知的(盡管在運行期會由 JIT 編譯器進行一些優(yōu)化),因此這幾個區(qū)域的內(nèi)存分配和回收都具備確定性,不需要過多考慮回收的問題,因為方法結(jié)束或者線程結(jié)束時,內(nèi)存自然就跟隨著回收了。
而 Java 堆不一樣,一個接口中的多個實現(xiàn)類需要的內(nèi)存可能不一樣,一個方法中的多個分支需要的內(nèi)存也可能不一樣,我們只有在程序處于運行期間時才能知道會創(chuàng)建哪些對象,這部分內(nèi)存的分配和回收都是動態(tài)的,垃圾收集器所關(guān)注的是這部分內(nèi)存。
2. 判斷哪些對象需要被回收
有以下兩種方法:
- 引用計數(shù)法給對象添加一引用計數(shù)器,被引用一次計數(shù)器值就加 1;當(dāng)引用失效時,計數(shù)器值就減 1;計數(shù)器為 0 時,對象就是不可能再被使用的,簡單高效,缺點是無法解決對象之間相互循環(huán)引用的問題。
- 可達性分析算法通過一系列的稱為 "GC Roots" 的對象作為起始點,從這些節(jié)點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當(dāng)一個對象到 GC Roots 沒有任何引用鏈相連時,則證明此對象是不可用的。此算法解決了上述循環(huán)引用的問題。
在Java語言中,可作為 GC Roots 的對象包括下面幾種:a. 虛擬機棧(棧幀中的本地變量表)中引用的對象。b. 方法區(qū)中類靜態(tài)屬性引用的對象。c. 方法區(qū)中常量引用的對象。d. 本地方法棧中 JNI(Native方法)引用的對象。
作為 GC Roots 的節(jié)點主要在全局性的引用與執(zhí)行上下文中。要明確的是,tracing gc必須以當(dāng)前存活的對象集為 Roots,因此必須選取確定存活的引用類型對象。
GC 管理的區(qū)域是 Java 堆及方法區(qū),虛擬機棧、本地方法棧不被 GC 所管理,因此選用這些區(qū)域內(nèi)引用的對象作為 GC Roots,是不會被 GC 所回收的。
其中虛擬機棧和本地方法棧都是線程私有的內(nèi)存區(qū)域,只要線程沒有終止,就能確保它們中引用對象的存活,在方法區(qū)中類靜態(tài)屬性引用的對象顯然是存活的,常量引用的對象在當(dāng)前可能存活,也可能是 GC Roots 的一部分。
3. 強、軟、弱、虛引用
JDK1.2 以前,一個對象只有被引用和沒有被引用兩種狀態(tài)。
后來,Java 對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)以及虛引用(Phantom Reference)4 種,這 4 種引用強度依次逐漸減弱。
- 強引用就是指在程序代碼之中普遍存在的,類似"Object obj=new Object()"這類的引用,垃圾收集器永遠(yuǎn)不會回收存活的強引用對象。
- 軟引用:還有用但并非必需的對象。在系統(tǒng) 將要發(fā)生內(nèi)存溢出異常之前 ,將會把這些對象列進回收范圍之中進行第二次回收。
- 弱引用也是用來描述非必需對象的,被弱引用關(guān)聯(lián)的對象 只能生存到下一次垃圾收集發(fā)生之前 。當(dāng)垃圾收集器工作時,無論內(nèi)存是否足夠,都會回收掉只被弱引用關(guān)聯(lián)的對象。
- 虛引用是最弱的一種引用關(guān)系。無法通過虛引用來取得一個對象實例 。為一個對象設(shè)置虛引用關(guān)聯(lián)的唯一目的就是能在這個對象被收集器回收時收到一個系統(tǒng)通知。
圖摘自《碼出高效》
4. 可達性分析算法
不可達的對象將暫時處于“緩刑”階段,要真正宣告一個對象死亡,至少要經(jīng)歷兩次標(biāo)記過程:
- 如果對象在進行可達性分析后發(fā)現(xiàn)沒有與 GC Roots 相連接的引用鏈,那它將會被第一次標(biāo)記并且進行一次篩選,篩選的條件是此對象是否有必要執(zhí)行 finalize() 方法。
- 當(dāng)對象沒有覆蓋 finalize() 方法,或者 finalize() 方法已經(jīng)被虛擬機調(diào)用過,虛擬機將這兩種情況都視為“沒有必要執(zhí)行”,直接進行第二次標(biāo)記。
- 如果這個對象被判定為有必要執(zhí)行 finalize() 方法,那么這個對象將會放置在一個叫做 F-Queue 的隊列之中,并在稍后由一個由虛擬機自動建立的、低優(yōu)先級的 Finalizer 線程去執(zhí)行它。
這里所謂的“執(zhí)行”是指虛擬機會觸發(fā)這個方法,但并不承諾會等待它運行結(jié)束,因為如果一個對象在 finalize() 方法中執(zhí)行緩慢,將很可能會一直阻塞 F-Queue 隊列,甚至導(dǎo)致整個內(nèi)存回收系統(tǒng)崩潰,測試程序:
- public class FinalizerTest {
- public static FinalizerTest object;
- public void isAlive() {
- System.out.println("I'm alive");
- }
- @Override
- protected void finalize() throws Throwable {
- super.finalize();
- System.out.println("method finalize is running");
- object = this;
- }
- public static void main(String[] args) throws Exception {
- object = new FinalizerTest();
- // 第一次執(zhí)行,finalize方法會自救
- object = null;
- System.gc();
- Thread.sleep(500);
- if (object != null) {
- object.isAlive();
- } else {
- System.out.println("I'm dead");
- }
- // 第二次執(zhí)行,finalize方法已經(jīng)執(zhí)行過
- object = null;
- System.gc();
- Thread.sleep(500);
- if (object != null) {
- object.isAlive();
- } else {
- System.out.println("I'm dead");
- }
- }
- }
引用自 Java GC的那些事
輸出如下:
- Copymethod finalize is running
- I'm alive
- I'm dead
如果不重寫finalize(),輸出將會是:
- CopyI'm dead
- I'm dead
從執(zhí)行結(jié)果可以看出:
第一次發(fā)生 GC 時,finalize() 方法的確執(zhí)行了,并且在被回收之前成功逃脫;第二次發(fā)生 GC 時,由于 finalize() 方法只會被 JVM 調(diào)用一次,object 被回收。
值得注意的是,使用 finalize() 方法來“拯救”對象是不值得提倡的,它的運行代價高昂,不確定性大,無法保證各個對象的調(diào)用順序。finalize() 能做的工作,使用 try-finally 或者其它方法都更適合、及時。
5. Java 堆永久代的回收
永久代的垃圾收集主要回收兩部分內(nèi)容:廢棄常量和無用的類。
- 回收廢棄常量與回收 Java 堆中的對象非常類似。以常量池中字面量的回收為例,假如一個字符串"abc"已經(jīng)進入了常量池中,但是當(dāng)前系統(tǒng)沒有任何一個 String 對象是叫做"abc"的,也沒有其他地方引用了這個字面量,如果這時發(fā)生內(nèi)存回收,而且必要的話,這個"abc"常量就會被系統(tǒng)清理出常量池。常量池中的其他類(接口)、方法、字段的符號引用也與此類似。
- 類需要同時滿足下面 3 個條件才能算是“無用的類”:
該類所有的實例都已經(jīng)被回收,也就是 Java 堆中不存在該類的任何實例。
加載該類的 ClassLoader 已經(jīng)被回收。
該類對應(yīng)的 java.lang.Class 對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
虛擬機可以對滿足上述 3 個條件的無用類進行回收,這里說的僅僅是“可以”,而并不是和對象一樣,不使用了就必然會回收。
在大量使用反射、動態(tài)代理、CGLib 等 ByteCode 框架、動態(tài)生成 JSP 以及 OSGi 這類頻繁自定義 ClassLoader 的場景都需要虛擬機具備類卸載的功能,以保證永久代不會溢出。
垃圾收集算法
一共有 4 種:
- 標(biāo)記-清除算法
- 復(fù)制算法
- 標(biāo)記整理算法
- 分代收集算法
1. 標(biāo)記-清除算法
最基礎(chǔ)的收集算法是“標(biāo)記-清除”(Mark-Sweep)算法,分為“標(biāo)記”和“清除”兩個階段:首先標(biāo)記出所有需要回收的對象,在標(biāo)記完成后統(tǒng)一回收所有被標(biāo)記的對象。
它的主要不足有兩個:
- 效率問題,標(biāo)記和清除兩個過程的效率都不高。
- 空間問題,標(biāo)記清除之后會產(chǎn)生大量不連續(xù)的內(nèi)存碎片,空間碎片太多可能會導(dǎo)致以后在程序運行過程中需要分配較大對象時,無法找到足夠的連續(xù)內(nèi)存而不得不提前觸發(fā)另一次垃圾收集動作。
標(biāo)記—清除算法的執(zhí)行過程如下圖。
2. 復(fù)制算法
為了解決回收效率問題,一種稱為“復(fù)制”(Copying)的收集算法出現(xiàn)了,它將可用內(nèi)存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當(dāng)這一塊的內(nèi)存用完了,就將還存活著的對象復(fù)制到另外一塊上面,然后再把已使用過的內(nèi)存空間一次清理掉。
這樣使得每次都是對整個半?yún)^(qū)進行內(nèi)存回收,內(nèi)存分配時也就不用考慮內(nèi)存碎片等復(fù)雜情況,只要移動堆頂指針,按順序分配內(nèi)存即可,實現(xiàn)簡單,運行高效。只是這種算法的代價是將內(nèi)存縮小為了原來的一半。復(fù)制算法執(zhí)行過程如下圖:
現(xiàn)在的商業(yè)虛擬機都采用這種算法來回收新生代,IBM 研究指出新生代中的對象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例來劃分內(nèi)存空間,而是將內(nèi)存分為一塊較大的 Eden 空間和兩塊較小的 Survivor 空間,每次使用 Eden 和其中一塊 Survivor 。
當(dāng)回收時,將 Eden 和 Survivor 中還存活著的對象一次性地復(fù)制到另外一塊的 Survivor 空間上,最后清理掉 Eden 和剛才用過的 Survivor 空間。HotSpot 虛擬機默認(rèn) Eden:Survivor = 8:1,也就是每次新生代中可用內(nèi)存空間為整個新生代容量的 90%(其中一塊 Survivor 不可用),只有 10% 的內(nèi)存會被“浪費”。
當(dāng)然,98%的對象可回收只是一般場景下統(tǒng)計的數(shù)據(jù),我們沒有辦法保證每次回收都只有不多于 10% 的對象存活,當(dāng) Survivor 空間不夠用時,需要依賴其他內(nèi)存(這里指老年代)進行分配擔(dān)保(Handle Promotion)。
內(nèi)存的分配擔(dān)保就好比我們?nèi)ャy行借款,如果我們信譽很好,在 98% 的情況下都能按時償還,于是銀行可能會默認(rèn)我們下一次也能按時按量地償還貸款,只需要有一個擔(dān)保人能保證如果我不能還款時,可以從他的賬戶扣錢,那銀行就認(rèn)為沒有風(fēng)險了。
內(nèi)存分配擔(dān)保也一樣,如果另外一塊 Survivor 空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象將直接通過分配擔(dān)保機制進入老年代。
關(guān)于對新生代進行分配擔(dān)保的內(nèi)容,在本章講解垃圾收集器執(zhí)行規(guī)則時還會再詳細(xì)講解。
3. 標(biāo)記-整理算法
復(fù)制算法在對象存活率較高時就要進行較多的復(fù)制操作,效率將會變低。更關(guān)鍵的是,如果不想浪費 50% 的空間,就需要有額外的空間進行內(nèi)存分配擔(dān)保,以應(yīng)對被使用的內(nèi)存中所有對象都是 100% 存活的極端情況,所以在老年代一般不能直接選用這種算法。
根據(jù)老年代的特點,有人提出了另外一種“標(biāo)記-整理”(Mark-Compact)算法,標(biāo)記過程仍然與“標(biāo)記-清除”算法一樣,但后續(xù)步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內(nèi)存,“標(biāo)記-整理”算法示意圖如下:
4. 分代收集算法
當(dāng)前商業(yè)虛擬機的垃圾收集都采用“分代收集”算法—Generational Collection,根據(jù)對象存活周期的不同將內(nèi)存劃分為幾塊并采用不用的垃圾收集算法。
一般把 Java 堆分為新生代和老年代,這樣就可以根據(jù)各個年代的特點采用最適當(dāng)?shù)氖占惴ā?/p>
在新生代中,每次垃圾收集時都發(fā)現(xiàn)有大批對象死去,只有少量存活,那就選用復(fù)制算法,只需要付出少量存活對象的復(fù)制成本就可以完成垃圾收集。而老年代中因為對象存活率高、沒有額外空間對它進行分配擔(dān)保,就必須使用“標(biāo)記—清理”或者“標(biāo)記—整理”算法來進行回收。
HotSpot 的算法實現(xiàn)
1. 枚舉根節(jié)點
以可達性分析中從 GC Roots 節(jié)點找引用鏈這個操作為例,可作為 GC Roots 的節(jié)點主要在全局性的引用(例如常量或類靜態(tài)屬性)與執(zhí)行上下文(例如棧幀中的本地變量表)中,現(xiàn)在很多應(yīng)用僅僅方法區(qū)就有數(shù)百兆,如果要逐個檢查這里面的引用,那么必然會消耗很多時間。
另外,可達性分析對執(zhí)行時間的敏感性還體現(xiàn)在 GC 停頓上,因為這項分析工作必須不可以出現(xiàn)分析過程中對象引用關(guān)系還在不斷變化的情況,否則分析結(jié)果準(zhǔn)確性就無法得到保證。這點是導(dǎo)致 GC 進行時必須停頓所有運行的 Java 執(zhí)行線程(Sun將這件事情稱為"Stop The World")的其中一個重要原因,即使是在號稱(幾乎)不會發(fā)生停頓的 CMS 收集器中,枚舉根節(jié)點時也是必須要停頓的。
因此,目前的主流 Java 虛擬機使用的都是準(zhǔn)確式 GC(即虛擬機可以知道內(nèi)存中某個位置的數(shù)據(jù)具體是什么類型。),所以當(dāng)執(zhí)行系統(tǒng)停頓下來后,并不需要一個不漏地檢查完所有執(zhí)行上下文和全局的引用位置,虛擬機應(yīng)當(dāng)是有辦法直接得知哪些地方存放著對象引用。
在 HotSpot 的實現(xiàn)中,使用一組稱為 OopMap 的數(shù)據(jù)結(jié)構(gòu)來達到這個目的的,在類加載完成的時候,HotSpot 就把對象內(nèi)什么偏移量上是什么類型的數(shù)據(jù)計算出來,在 JIT 的編譯過程中,也會在特定的位置記錄棧和寄存器中哪些位置是引用,因此 GC 在掃描時就可以直接得知這些信息了。
2. 安全點(Safepoint)
在 OopMap 的協(xié)助下,HotSpot 可以快速且準(zhǔn)確地完成 GC Roots 枚舉,但一個很現(xiàn)實的問題隨之而來:可能導(dǎo)致引用關(guān)系變化,換種說法即 OopMap 內(nèi)容變化的指令非常多,如果為每一條指令都生成對應(yīng)的 OopMap,那將會需要大量的額外空間,這樣 GC 的空間成本將會變得很高。
實際上,HotSpot 也的確沒有為每條指令都生成 OopMap,前面已經(jīng)提到,只是在特定的位置記錄了這些信息,這些位置稱為安全點,即程序執(zhí)行時并非在所有地方都能停頓下來開始 GC ,只有在到達安全點時才能暫停。
Safepoint 的選定既不能太少以致于 GC 過少,也不能過于頻繁以致于過分增大運行時的負(fù)荷。
對于 Safepoint,另一個需要考慮的問題是如何在 GC 發(fā)生時讓所有線程都“跑”到最近的安全點上再停頓下來。這里有兩種方案可供選擇:搶先式中斷(Preemptive Suspension)和主動式中斷(Voluntary Suspension)。
其中搶先式中斷不需要線程的執(zhí)行代碼主動去配合,在 GC 發(fā)生時,首先把所有線程全部中斷,如果發(fā)現(xiàn)有線程中斷的地方不在安全點上,就恢復(fù)線程,讓它“跑”到安全點上?,F(xiàn)在幾乎沒有虛擬機實現(xiàn)采用搶先式中斷來暫停線程從而響應(yīng) GC 事件。
而主動式中斷的思想是當(dāng) GC 需要中斷線程的時候,不直接對線程操作,僅僅簡單地設(shè)置一個標(biāo)志,各個線程執(zhí)行時主動去輪詢這個標(biāo)志,發(fā)現(xiàn)中斷標(biāo)志為真時就自己中斷掛起。輪詢標(biāo)志的地方和安全點是重合的,另外再加上創(chuàng)建對象需要分配內(nèi)存的地方。
3. 安全區(qū)域(Safe Region)
使用 Safepoint 似乎已經(jīng)完美地解決了如何進入 GC 的問題,但實際情況卻并不一定。
Safepoint 機制保證了程序執(zhí)行時,在不太長的時間內(nèi)就會遇到可進入 GC 的 Safepoint 安全點,但是程序“不執(zhí)行”的時候呢?
所謂的程序不執(zhí)行就是沒有分配 CPU 時間,典型的例子就是線程處于 Sleep 狀態(tài)或 Blocked 狀態(tài),這時候線程無法響應(yīng) JVM 虛擬機的中斷請求,”走“到安全的地方去中斷掛起,JVM也顯然不太可能等待線程重新被分配 CPU 時間。對于這種情況,就需要安全區(qū)域(Safe Region)來解決。
安全區(qū)域是指在一段代碼片段之中,引用關(guān)系不會發(fā)生變化。
在這個區(qū)域中的任意地方開始 GC 都是安全的。我們也可以把 Safe Region 看做是被擴展了的 Safepoint。在線程執(zhí)行到 Safe Region 中的代碼時,首先標(biāo)識自己已經(jīng)進入了 Safe Region,那樣,當(dāng)在這段時間里 JVM 要發(fā)起 GC 時,就不用管標(biāo)識自己為 Safe Region 狀態(tài)的線程了。在線程要離開 Safe Region 時,它要檢查系統(tǒng)是否已經(jīng)完成了根節(jié)點枚舉(或者是整個 GC 過程),如果完成了,那線程就繼續(xù)執(zhí)行,否則它就必須等待直到收到可以安全離開 Safe Region 的信號為止。
垃圾收集器
如果說收集算法是內(nèi)存回收的方法論,那么垃圾收集器就是內(nèi)存回收的具體實現(xiàn)。這里討論的收集器基于JDK 1.7 Update 14之后的 HotSpot 虛擬機,這個虛擬機包含的所有收集器如下圖所示
上圖展示了 7 種作用于不同分代的收集器,如果兩個收集器之間存在連線,就說明它們可以搭配使用。虛擬機所處的區(qū)域,則表示它是屬于新生代收集器還是老年代收集器。接下來將逐一介紹這些收集器的特性、基本原理和使用場景,并重點分析 CMS 和 G1 這兩款相對復(fù)雜的收集器,了解它們的部分運作細(xì)節(jié)。
1. Serial收集器(串行收集器)
Serial 收集器是最基本、發(fā)展歷史最悠久的收集器,曾經(jīng)是虛擬機新生代收集的唯一選擇。這是一個單線程的收集器,但它的“單線程”的意義并不僅僅說明它只會使用一個 CPU 或一條收集線程去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有的工作線程,直到它收集結(jié)束。
"Stop The World"這個名字也許聽起來很酷,但這項工作實際上是由虛擬機在后臺自動發(fā)起和自動完成的,在用戶不可見的情況下把用戶正常工作的線程全部停掉,這對很多應(yīng)用來說都是難以接受的。下圖示意了 Serial/Serial Old 收集器的運行過程。
實際上到現(xiàn)在為止,該收集器依然是虛擬機運行在 Client 模式下的默認(rèn)新生代收集器。它也有著優(yōu)于其他收集器的地方:簡單而高效(與其他收集器的單線程比),對于限定單個 CPU 的環(huán)境來說,Serial 收集器由于沒有線程交互的開銷,專心做垃圾收集自然可以獲得最高的單線程收集效率。
在用戶的桌面應(yīng)用場景中,分配給虛擬機管理的內(nèi)存一般來說不會很大,收集幾十兆甚至一兩百兆的新生代(僅僅是新生代使用的內(nèi)存,桌面應(yīng)用基本上不會再大了),停頓時間完全可以控制在幾十毫秒最多一百多毫秒以內(nèi),只要不是頻繁發(fā)生,這點停頓是可以接受的。所以,Serial 收集器對于運行在 Client 模式下的虛擬機來說是一個很好的選擇。
2. ParNew收集器
ParNew 收集器其實就是 Serial 收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其余行為包括 Serial 收集器可用的所有控制參數(shù)(-XX:HandlePromotionFailure 以及設(shè)置閥值的-XX:PretenureSizeThreshold 、-XX:SurvivorRatio 等)、收集算法、Stop The World、對象分配規(guī)則、回收策略等都與 Serial 收集器完全一樣,在實現(xiàn)上,這兩種收集器也共用了相當(dāng)多的代碼。ParNew 收集器的工作過程如下圖所示:
ParNew 收集器除了多線程收集之外,其他與 Serial 收集器相比并沒有太多創(chuàng)新之處,但它卻是許多運行在 Server 模式下的虛擬機中首選的新生代收集器,其中有一個與性能無關(guān)但很重要的原因是,除了 Serial 收集器外,目前只有它能與 CMS 收集器(并發(fā)收集器,后面有介紹)配合工作。
ParNew 收集器在單 CPU 的環(huán)境中不會有比 Serial 收集器更好的效果,甚至由于存在線程交互的開銷,該收集器在通過超線程技術(shù)實現(xiàn)的兩個 CPU 的環(huán)境中都不能百分之百地保證可以超越 Serial 收集器。
當(dāng)然,隨著可以使用的 CPU 的數(shù)量的增加,它對于 GC 時系統(tǒng)資源的有效利用還是很有好處的。它默認(rèn)開啟的收集線程數(shù)與 CPU 的數(shù)量相同,在 CPU 非常多(如 32 個)的環(huán)境下,可以使用 -XX:ParallelGCThreads 參數(shù)來限制垃圾收集的線程數(shù)。
注意,從 ParNew 收集器開始,后面還會接觸到幾款并發(fā)和并行的收集器。這里有必要先解釋兩個名詞:并發(fā)和并行。這兩個名詞都是并發(fā)編程中的概念,在談?wù)摾占鞯纳舷挛恼Z境中,它們可以解釋如下。
- 并行(Parallel):指多條垃圾收集線程并行工作,但此時用戶線程仍然處于等待狀態(tài)。
- 并發(fā)(Concurrent):指用戶線程與垃圾收集線程同時執(zhí)行(但不一定是并行的,可能會交替執(zhí)行),用戶程序在繼續(xù)運行,而垃圾收集程序運行于另一個 CPU 上。
3. Parallel Scanvenge收集器
Parallel Scavenge 收集器是一個新生代收集器,它也是使用復(fù)制算法的收集器,又是并行的多線程收集器……看上去和 ParNew 都一樣,那它有什么特別之處呢?
Parallel Scavenge 收集器的特點是它的關(guān)注點與其他收集器不同,CMS 等收集器的關(guān)注點是盡可能地縮短垃圾收集時用戶線程的停頓時間,而 Parallel Scavenge 收集器的目標(biāo)則是達到一個可控制的吞吐量(Throughput)。
所謂吞吐量就是 CPU 用于運行用戶代碼的時間與 CPU 總消耗時間的比值,即吞吐量=運行用戶代碼時間/(運行用戶代碼時間+垃圾收集時間),虛擬機總共運行了 100 分鐘,其中垃圾收集花掉1分鐘,那吞吐量就是99% 。
停頓時間越短就越適合需要與用戶交互的程序,良好的響應(yīng)速度能提升用戶體驗,而高吞吐量則可以高效率地利用 CPU 時間,盡快完成程序的運算任務(wù),主要適合在后臺運算而不需要太多交互的任務(wù)。
Parallel Scavenge收集器提供了兩個參數(shù)用于精確控制吞吐量,分別是控制最大垃圾收集停頓時間的 -XX:MaxGCPauseMillis 參數(shù)以及直接設(shè)置吞吐量大小的 -XX:GCTimeRatio 參數(shù)。
MaxGCPauseMillis 參數(shù)允許的值是一個大于 0 的毫秒數(shù),收集器將盡可能地保證內(nèi)存回收花費的時間不超過設(shè)定值。
不過大家不要認(rèn)為如果把這個參數(shù)的值設(shè)置得稍小一點就能使得系統(tǒng)的垃圾收集速度變得更快,GC停頓時間縮短是以犧牲吞吐量和新生代空間來換取的:系統(tǒng)把新生代調(diào)小一些,收集300MB 新生代肯定比收集 500MB 快吧,這也直接導(dǎo)致垃圾收集發(fā)生得更頻繁一些,原來10秒收集一次、每次停頓100毫秒,現(xiàn)在變成5秒收集一次、每次停頓70毫秒。停頓時間的確在下降,但吞吐量也降下來了。
GCTimeRatio 參數(shù)的值應(yīng)當(dāng)是一個 0 到 100 的整數(shù),也就是垃圾收集時間占總時間的比率,相當(dāng)于是吞吐量的倒數(shù)。如果把此參數(shù)設(shè)置為 19,那允許的最大 GC 時間就占總時間的 5%(即 1/(1+19)),默認(rèn)值為 99 ,就是允許最大 1%(即 1/(1+99))的垃圾收集時間。
由于與吞吐量關(guān)系密切,Parallel Scavenge 收集器也經(jīng)常稱為“吞吐量優(yōu)先”收集器。除上述兩個參數(shù)之外,Parallel Scavenge 收集器還有一個參數(shù) -XX:+UseAdaptiveSizePolicy 值得關(guān)注。這是一個開關(guān)參數(shù),當(dāng)這個參數(shù)打開之后,就不需要手工指定新生代的大小參數(shù) -Xmn、Eden 與 Survivor 區(qū)的比例參數(shù) -XX:SurvivorRatio、晉升老年代對象的年齡閥值 -XX:PretenureSizeThreshold 等細(xì)節(jié)參數(shù)了,虛擬機會根據(jù)當(dāng)前系統(tǒng)的運行情況收集性能監(jiān)控信息,動態(tài)調(diào)整這些參數(shù)以提供最合適的停頓時間或者最大的吞吐量,這種調(diào)節(jié)方式稱為 GC 自適應(yīng)的調(diào)節(jié)策略(GC Ergonomics)。
4. Serial Old 收集器
Serial Old 是 Serial 收集器的老年代版本,它同樣是一個單線程收集器,使用“標(biāo)記-整理”算法。這個收集器的主要意義也是在于給 Client 模式下的虛擬機使用。如果在 Server 模式下,那么它主要還有兩大用途:一種用途是在 JDK 1.5 以及之前的版本中與 Parallel Scavenge 收集器搭配使用,另一種用途就是作為 CMS 收集器的后備預(yù)案,在并發(fā)收集發(fā)生 Concurrent Mode Failure 時使用。這兩點都將在后面的內(nèi)容中詳細(xì)講解。Serial Old 收集器的工作過程如下圖所示:
5. Parellel Old 收集器
Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多線程和“標(biāo)記-整理”算法。這個收集器是在 JDK 1.6 中才開始提供的,在此之前,新生代的 Parallel Scavenge 收集器一直處于比較尷尬的狀態(tài)。
原因是,如果新生代選擇了 Parallel Scavenge 收集器,老年代除了 Serial Old(PS MarkSweep)收集器外別無選擇(Parallel Scavenge 收集器無法與 CMS 收集器配合工作)。
由于老年代 Serial Old 收集器在服務(wù)端應(yīng)用性能上的“拖累”,使用了 Parallel Scavenge 收集器也未必能在整體應(yīng)用上獲得吞吐量最大化的效果,由于單線程的老年代收集中無法充分利用服務(wù)器多 CPU 的處理能力,在老年代很大而且硬件比較高級的環(huán)境中,這種組合的吞吐量甚至還不一定有 ParNew 加 CMS 的組合“給力”。
直到 Parallel Old 收集器出現(xiàn)后,“吞吐量優(yōu)先”收集器終于有了比較名副其實的應(yīng)用組合,在注重吞吐量以及 CPU 資源敏感的場合,都可以優(yōu)先考慮 Parallel Scavenge 加 Parallel Old 收集器。Parallel Old 收集器的工作過程如下圖所示:
6. CMS 收集器
CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標(biāo)的收集器。
目前很大一部分的 Java 應(yīng)用集中在互聯(lián)網(wǎng)站或者 B/S 系統(tǒng)的服務(wù)端上,這類應(yīng)用尤其重視服務(wù)的響應(yīng)速度,希望系統(tǒng)停頓時間最短,以給用戶帶來較好的體驗。CMS 收集器就非常符合這類應(yīng)用的需求。
從名字(包含"Mark Sweep")上就可以看出,CMS 收集器是基于“標(biāo)記—清除”算法實現(xiàn)的,它的運作過程相對于前面幾種收集器來說更復(fù)雜一些,整個過程分為4個步驟,包括:
- 初始標(biāo)記(CMS initial mark)
- 并發(fā)標(biāo)記(CMS concurrent mark)
- 重新標(biāo)記(CMS remark)
- 并發(fā)清除(CMS concurrent sweep)
其中,初始標(biāo)記、重新標(biāo)記這兩個步驟仍然需要"Stop The World"。初始標(biāo)記僅僅只是標(biāo)記一下 GC Roots 能直接關(guān)聯(lián)到的對象,速度很快,并發(fā)標(biāo)記階段就是進行 GC RootsTracing 的過程,而重新標(biāo)記階段則是為了修正并發(fā)標(biāo)記期間因用戶程序繼續(xù)運作而導(dǎo)致標(biāo)記產(chǎn)生變動的那一部分對象的標(biāo)記記錄,這個階段的停頓時間一般會比初始標(biāo)記階段稍長一些,但遠(yuǎn)比并發(fā)標(biāo)記的時間短。
由于整個過程中耗時最長的并發(fā)標(biāo)記和并發(fā)清除過程收集器線程都可以與用戶線程一起工作,所以,從總體上來說,CMS 收集器的內(nèi)存回收過程是與用戶線程一起并發(fā)執(zhí)行的。
CMS 是一款優(yōu)秀的收集器,它的主要優(yōu)點在名字上已經(jīng)體現(xiàn)出來了:并發(fā)收集、低停頓,但是 CMS 還遠(yuǎn)達不到完美的程度,它有以下 3 個明顯的缺點:
第一、導(dǎo)致吞吐量降低。CMS 收集器對 CPU 資源非常敏感。其實,面向并發(fā)設(shè)計的程序都對 CPU 資源比較敏感。在并發(fā)階段,它雖然不會導(dǎo)致用戶線程停頓,但是會因為占用了一部分線程(或者說CPU資源)而導(dǎo)致應(yīng)用程序變慢,總吞吐量會降低。
CMS 默認(rèn)啟動的回收線程數(shù)是(CPU數(shù)量+3)/4,也就是當(dāng) CPU 在4個以上時,并發(fā)回收時垃圾收集線程不少于 25% 的 CPU 資源,并且隨著 CPU 數(shù)量的增加而下降。但是當(dāng) CPU 不足 4 個(譬如2個)時,CMS 對用戶程序的影響就可能變得很大,如果本來 CPU 負(fù)載就比較大,還分出一半的運算能力去執(zhí)行收集器線程,就可能導(dǎo)致用戶程序的執(zhí)行速度忽然降低了 50%,其實也讓人無法接受。
第二、CMS 收集器無法處理浮動垃圾(Floating Garbage),可能出現(xiàn)"Concurrent Mode Failure"失敗而導(dǎo)致另一次 Full GC(新生代和老年代同時回收) 的產(chǎn)生。由于 CMS 并發(fā)清理階段用戶線程還在運行著,伴隨程序運行自然就還會有新的垃圾不斷產(chǎn)生,這一部分垃圾出現(xiàn)在標(biāo)記過程之后,CMS 無法在當(dāng)次收集中處理掉它們,只好留待下一次 GC 時再清理掉。這一部分垃圾就稱為“浮動垃圾”。
也是由于在垃圾收集階段用戶線程還需要運行,那也就還需要預(yù)留有足夠的內(nèi)存空間給用戶線程使用,因此 CMS 收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預(yù)留一部分空間提供并發(fā)收集時的程序運作使用。
在 JDK 1.5 的默認(rèn)設(shè)置下,CMS 收集器當(dāng)老年代使用了 68% 的空間后就會被激活,這是一個偏保守的設(shè)置,如果在應(yīng)用中老年代增長不是太快,可以適當(dāng)調(diào)高參數(shù) -XX:CMSInitiatingOccupancyFraction 的值來提高觸發(fā)百分比,以便降低內(nèi)存回收次數(shù)從而獲取更好的性能,在 JDK 1.6 中,CMS 收集器的啟動閾值已經(jīng)提升至 92% 。
要是 CMS 運行期間預(yù)留的內(nèi)存無法滿足程序需要,就會出現(xiàn)一次"Concurrent Mode Failure"失敗,這時虛擬機將啟動后備預(yù)案:臨時啟用 Serial Old 收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了,參數(shù) -XX:CM SInitiatingOccupancyFraction 設(shè)置得太高很容易導(dǎo)致大量"Concurrent Mode Failure"失敗,性能反而降低。
第三、產(chǎn)生空間碎片。CMS 是一款基于“標(biāo)記—清除”算法實現(xiàn)的收集器,這意味著收集結(jié)束時會有大量空間碎片產(chǎn)生??臻g碎片過多時,將會給大對象分配帶來很大麻煩,往往會出現(xiàn)老年代還有很大空間剩余,但是無法找到足夠大的連續(xù)空間來分配當(dāng)前對象,不得不提前觸發(fā)一次 Full GC 。
為了解決這個問題,CMS 收集器提供了一個 -XX:+UseCMSCompactAtFullCollection 開關(guān)參數(shù)(默認(rèn)就是開啟的),用于在CMS收集器頂不住要進行 FullGC 時開啟內(nèi)存碎片的合并整理過程,內(nèi)存整理的過程是無法并發(fā)的,空間碎片問題沒有了,但停頓時間不得不變長。虛擬機設(shè)計者還提供了另外一個參數(shù) -XX:CMSFullGCsBeforeCompaction,這個參數(shù)是用于設(shè)置執(zhí)行多少次不壓縮的 Full GC 后,跟著來一次帶壓縮的(默認(rèn)值為0,表示每次進入Full GC時都進行碎片整理)。
7. G1 收集器
G1(Garbage-First)收集器是當(dāng)今收集器技術(shù)發(fā)展的最前沿成果之一,從JDK 9 版本開始將 G1 變成默認(rèn)的垃圾收集器,它是一款面向服務(wù)端應(yīng)用的垃圾收集器,。HotSpot 開發(fā)團隊賦予它的使命是(在比較長期的)未來可以替換掉 JDK 1.5 中發(fā)布的 CMS 收集器。與其他 GC 收集器相比,G1 具備如下特點。
并行與并發(fā):G1 能充分利用多 CPU、多核環(huán)境下的硬件優(yōu)勢,使用多個CPU(CPU或者CPU核心)來縮短 Stop-The-World 停頓的時間,部分其他收集器原本需要停頓 Java 線程執(zhí)行的 GC 動作,G1 收集器仍然可以通過并發(fā)的方式讓 Java 程序繼續(xù)執(zhí)行。
分代收集: 與其他收集器一樣,分代概念在 G1 中依然得以保留。雖然 G1 可以不需要其他收集器配合就能獨立管理整個 GC 堆,但它能夠采用不同的方式去處理新創(chuàng)建的對象和已經(jīng)存活了一段時間、熬過多次 GC 的舊對象以獲取更好的收集效果。
空間整合: 與 CMS 的“標(biāo)記—清理”算法不同,G1 從整體來看是基于“標(biāo)記—整理”算法實現(xiàn)的收集器,從局部(兩個 Region 之間)上來看是基于“復(fù)制”算法實現(xiàn)的,但無論如何,這兩種算法都意味著 G1 運作期間不會產(chǎn)生內(nèi)存空間碎片,收集后能提供規(guī)整的可用內(nèi)存。這種特性有利于程序長時間運行,分配大對象時不會因為無法找到連續(xù)內(nèi)存空間而提前觸發(fā)下一次 GC 。
可預(yù)測的停頓: 這是 G1 相對于 CMS 的另一大優(yōu)勢,降低停頓時間是 G1 和 CMS 共同的關(guān)注點,但 G1 除了追求低停頓外,還能建立可預(yù)測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內(nèi),消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經(jīng)是實時 Java(RTSJ)的垃圾收集器的特征了。
在 G1 之前的其他收集器進行收集的范圍都是整個新生代或者老年代,而 G1 不再是這樣。使用 G1 收集器時,Java 堆的內(nèi)存布局就與其他收集器有很大差別,它將整個 Java 堆劃分為多個大小相等的獨立區(qū)域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分 Region (不需要連續(xù))的集合。
G1 收集器之所以能建立可預(yù)測的停頓時間模型,是因為它可以有計劃地避免在整個Java堆中進行全區(qū)域的垃圾收集。G1 在后臺維護一個優(yōu)先列表,每次根據(jù)允許的收集時間,優(yōu)先回收價值最大的 Region(這也就是Garbage-First名稱的來由),保證了 G1 收集器在有限的時間內(nèi)可以獲取盡可能高的收集效率。
在 G1 收集器中,Region 之間的對象引用以及其他收集器中的新生代與老年代之間的對象引用,虛擬機都是使用 Remembered Set 來避免全堆掃描的。
G1 中每個Region 都有一個與之對應(yīng)的 Remembered Set,虛擬機發(fā)現(xiàn)程序在對 Reference 類型的數(shù)據(jù)進行寫操作時,會產(chǎn)生一個 Write Barrier 暫時中斷寫操作,檢查 Reference 引用的對象是否處于不同的 Region 之中(在分代的例子中就是檢查是否老年代中的對象引用了新生代中的對象),如果是,便通過 CardTable 把相關(guān)引用信息記錄到被引用對象所屬的 Region 的 Remembered Set 之中。當(dāng)進行內(nèi)存回收時,在 GC 根節(jié)點的枚舉范圍中加入 Remembered Set 即可保證不對全堆掃描也不會有遺漏。
如果不計算維護 Remembered Set 的操作,G1 收集器的運作大致可劃分為以下幾個步驟:
- 初始標(biāo)記(Initial Marking)
- 并發(fā)標(biāo)記(Concurrent Marking)
- 最終標(biāo)記(Final Marking)
- 篩選回收(Live Data Counting and Evacuation)
G1 的前幾個步驟的運作過程和 CMS 有很多相似之處。
初始標(biāo)記階段僅僅只是標(biāo)記一下 GC Roots 能直接關(guān)聯(lián)到的對象,并且修改 TAMS(Next Top at Mark Start)的值,讓下一階段用戶程序并發(fā)運行時,能在正確可用的 Region 中創(chuàng)建新對象,這階段需要停頓線程,但耗時很短。
并發(fā)標(biāo)記階段是從 GC Root 開始對堆中對象進行可達性分析,找出存活的對象,這階段耗時較長,但可與用戶程序并發(fā)執(zhí)行。
而最終標(biāo)記階段則是為了修正在并發(fā)標(biāo)記期間因用戶程序繼續(xù)運作而導(dǎo)致標(biāo)記產(chǎn)生變動的那一部分標(biāo)記記錄,虛擬機將這段時間對象變化記錄在線程 Remembered Set Logs 里面,最終標(biāo)記階段需要把 Remembered Set Logs 的數(shù)據(jù)合并到 Remembered Set 中,這階段需要停頓線程,但是可并行執(zhí)行。
最后在篩選回收階段首先對各個 Region 的回收價值和成本進行排序,根據(jù)用戶所期望的 GC 停頓時間來制定回收計劃,從Sun公司透露出來的信息來看,這個階段其實也可以做到與用戶程序一起并發(fā)執(zhí)行,但是因為只回收一部分 Region,時間是用戶可控制的,而且停頓用戶線程將大幅提高收集效率。通過下圖可以比較清楚地看到G1收集器的運作步驟中并發(fā)和需要停頓的階段。
GC 日志
閱讀 GC 日志是處理 Java 虛擬機內(nèi)存問題的基礎(chǔ)技能,它只是一些人為確定的規(guī)則,沒有太多技術(shù)含量。
每一種收集器的日志形式都是由它們自身的實現(xiàn)所決定的,換而言之,每個收集器的日志格式都可以不一樣。但虛擬機設(shè)計者為了方便用戶閱讀,將各個收集器的日志都維持一定的共性,例如以下兩段典型的 GC 日志:
- 33.125:[GC[DefNew:3324K->152K(3712K),0.0025925 secs]3324K->152K(11904K),0.0031680 secs]
- 100.667:[Full GC[Tenured:0 K->210K(10240K),0.0149142secs]4603K->210K(19456K),[Perm:2999K->2999K(21248K)],0.0150007 secs][Times:user=0.01 sys=0.00,real=0.02 secs]
最前面的數(shù)字33.125: 和 100.667: 代表了 GC 發(fā)生的時間,這個數(shù)字的含義是從 Java 虛擬機啟動以來經(jīng)過的秒數(shù)。
GC 日志開頭的 [GC 和 [Full GC 說明了這次垃圾收集的停頓類型,而不是用來區(qū)分新生代 GC 還是老年代 GC 的。
如果有 Full ,說明這次 GC 是發(fā)生了 Stop-The-World 的,例如下面這段新生代收集器 ParNew 的日志也會出現(xiàn) [Full GC(這一般是因為出現(xiàn)了分配擔(dān)保失敗之類的問題,所以才導(dǎo)致 STW)。如果是調(diào)用 System.gc() 方法所觸發(fā)的收集,那么在這里將顯示 [Full GC(System)。
- [Full GC 283.736:[ParNew:261599K->261599K(261952K),0.0000288 secs]
接下來的 [DefNew、[Tenured、[Perm 表示 GC 發(fā)生的區(qū)域,這里顯示的區(qū)域名稱與使用的 GC 收集器是密切相關(guān)的,例如上面樣例所使用的 Serial 收集器中的新生代名為 "Default New Generation",所以顯示的是 [DefNew。如果是 ParNew 收集器,新生代名稱就會變?yōu)?[ParNew,意為 "Parallel New Generation"。如果采用 Parallel Scavenge 收集器,那它配套的新生代稱為 PSYoungGen,老年代和永久代同理,名稱也是由收集器決定的。
后面方括號內(nèi)部的 3324K->152K(3712K) 含義是GC 前該內(nèi)存區(qū)域已使用容量 -> GC 后該內(nèi)存區(qū)域已使用容量 (該內(nèi)存區(qū)域總?cè)萘?。而在方括號之外的 3324K->152K(11904K) 表示 GC 前 Java 堆已使用容量 ->GC 后 Java 堆已使用容量 (Java 堆總?cè)萘?。
再往后,0.0025925 secs 表示該內(nèi)存區(qū)域 GC 所占用的時間,單位是秒。有的收集器會給出更具體的時間數(shù)據(jù),如 [Times:user=0.01 sys=0.00,real=0.02 secs] ,這里面的 user、sys 和 real 與 Linux 的 time 命令所輸出的時間含義一致,分別代表用戶態(tài)消耗的 CPU 時間、內(nèi)核態(tài)消耗的 CPU 事件和操作從開始到結(jié)束所經(jīng)過的墻鐘時間(Wall Clock Time)。
CPU 時間與墻鐘時間的區(qū)別是,墻鐘時間包括各種非運算的等待耗時,例如等待磁盤 I/O、等待線程阻塞,而 CPU 時間不包括這些耗時,但當(dāng)系統(tǒng)有多 CPU 或者多核的話,多線程操作會疊加這些 CPU 時間,所以讀者看到 user 或 sys 時間超過 real 時間是完全正常的。
垃圾收集器參數(shù)總結(jié)
JDK 1.7 中的各種垃圾收集器到此已全部介紹完畢,在描述過程中提到了很多虛擬機非穩(wěn)定的運行參數(shù),在表3-2中整理了這些參數(shù)供讀者實踐時參考:
內(nèi)存分配與回收策略
對象的內(nèi)存分配,往大方向講,就是在堆上分配,對象主要分配在新生代的Eden區(qū)上。少數(shù)情況下也可能會直接分配在老年代中,分配的規(guī)則并不是百分之百固定的,其細(xì)節(jié)取決于當(dāng)前使用的是哪一種垃圾收集器組合,還有虛擬機中與內(nèi)存相關(guān)的參數(shù)的設(shè)置。
圖摘自《碼出高效》
1. 對象優(yōu)先在 Eden 分配
大多數(shù)情況下,對象在新生代Eden 區(qū)中分配。當(dāng) Eden 區(qū)沒有足夠空間進行分配時,虛擬機將發(fā)起一次 Minor GC。
虛擬機提供了 -XX:+PrintGCDetails 這個收集器日志參數(shù),告訴虛擬機在發(fā)生垃圾收集行為時打印內(nèi)存回收日志,并且在進程退出的時候輸出當(dāng)前的內(nèi)存各區(qū)域分配情況。
- private static final int_1MB=1024 * 1024;
- /**
- *VM參數(shù):-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails
- -XX:SurvivorRatio=8
- */
- public static void testAllocation () {
- byte[] allocation1,allocation2,allocation3,allocation4;
- allocation1 = new byte[2 * _1MB];
- allocation2 = new byte[2 * _1MB];
- allocation3 = new byte[2 * _1MB];
- allocation4 = new byte[4 * _1MB];//出現(xiàn)一次Minor GC
- }
運行結(jié)果:
- [GC[DefNew:6651K->148K(9216K),0.0070106 secs]6651K->6292K(19456K),
- 0.0070426 secs][Times:user=0.00 sys=0.00,real=0.00 secs]
- Heap
- def new generation total 9216K,used 4326K[0x029d0000,0x033d0000,0x033d0000)
- eden space 8192K,51%used[0x029d0000,0x02de4828,0x031d0000)
- from space 1024K,14%used[0x032d0000,0x032f5370,0x033d0000)
- to space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
- tenured generation total 10240K,used 6144K[0x033d0000,0x03dd0000,0x03dd0000)
- the space 10240K,60%used[0x033d0000,0x039d0030,0x039d0200,0x03dd0000)
- compacting perm gen total 12288K,used 2114K[0x03dd0000,0x049d0000,0x07dd0000)
- the space 12288K,17%used[0x03dd0000,0x03fe0998,0x03fe0a00,0x049d0000)
- No shared spaces configured.
上方代碼的 testAllocation() 方法中,嘗試分配 3 個 2MB 大小和 1 個 4MB 大小的對象,在運行時通過 -Xms20M、-Xmx20M、-Xmn10M 這 3 個參數(shù)限制了 Java 堆大小為 20MB ,不可擴展,其中 10MB 分配給新生代,剩下的 10MB 分配給老年代。-XX:SurvivorRatio=8 決定了新生代中 Eden 區(qū)與一個 Survivor 區(qū)的空間比例是 8:1,從輸出的結(jié)果也可以清晰地看到 eden space 8192K、from space 1024K、to space 1024K 的信息,新生代總可用空間為 9216KB(Eden區(qū)+1個Survivor區(qū)的總?cè)萘?。
執(zhí)行 testAllocation() 中分配 allocation4 對象的語句時會發(fā)生一次 Minor GC,這次 GC 的結(jié)果是新生代 6651KB 變?yōu)?148KB ,而總內(nèi)存占用量則幾乎沒有減少(因為 allocation1、allocation2、allocation3 三個對象都是存活的,虛擬機幾乎沒有找到可回收的對象)。
這次 GC 發(fā)生的原因是給 allocation4 分配內(nèi)存的時候,發(fā)現(xiàn) Eden 已經(jīng)被占用了 6MB,剩余空間已不足以分配 allocation4 所需的 4MB 內(nèi)存,因此發(fā)生 Minor GC。GC 期間虛擬機又發(fā)現(xiàn)已有的 3 個 2MB 大小的對象全部無法放入 Survivor 空間(Survivor 空間只有 1MB 大小),所以只好通過分配擔(dān)保機制提前轉(zhuǎn)移到老年代去。
這次 GC 結(jié)束后,4MB 的 allocation4 對象順利分配在 Eden 中,因此程序執(zhí)行完的結(jié)果是 Eden 占用 4MB(被allocation4占用),Survivor 空閑,老年代被占用 6MB(被allocation1、allocation2、allocation3占用)。通過 GC 日志可以證實這一點。
2. Minor GC 與 Full GC 的區(qū)別
新生代 GC(Minor GC):指發(fā)生在新生代的垃圾收集動作,因為 Java 對象大多都具備朝生夕滅的特性,所以 Minor GC 非常頻繁,一般回收速度也比較快。
老年代 GC(Major GC/Full GC):指發(fā)生在老年代的 GC,出現(xiàn)了 Major GC,經(jīng)常會伴隨至少一次的 Minor GC(但非絕對的,在 Parallel Scavenge 收集器的收集策略里就有直接進行 Major GC 的策略選擇過程)。Major GC 的速度一般會比 Minor GC 慢 10 倍以上。
3. 大對象直接進入老年代
所謂的大對象是指,需要大量連續(xù)內(nèi)存空間的 Java 對象,最典型的大對象就是那種很長的字符串以及數(shù)組( byte[] 數(shù)組就是典型的大對象)。大對象對虛擬機的內(nèi)存分配來說就是一個壞消息(特別是短命大對象,寫程序的時候應(yīng)當(dāng)避免),經(jīng)常出現(xiàn)大對象容易導(dǎo)致內(nèi)存還有不少空間時就提前觸發(fā)垃圾收集以獲取足夠的連續(xù)空間來“安置”它們。
虛擬機提供了 -XX:PretenureSizeThreshold 參數(shù),令大于這個設(shè)置值的對象直接在老年代分配。這樣做的目的是避免在 Eden 區(qū)及兩個 Survivor 區(qū)之間發(fā)生大量的內(nèi)存復(fù)制。
- private static final int_1MB=1024 * 1024;
- /**
- *VM參數(shù):-verbose:gc-Xms20M-Xmx20M-Xmn10M-XX:+PrintGCDetails-XX:SurvivorRatio=8
- *-XX:PretenureSizeThreshold=3145728
- */
- public static void testPretenureSizeThreshold () {
- byte[] allocation;
- allocation = new byte[4 * _1MB];//直接分配在老年代中
- }
運行結(jié)果:
Heap
def new generation total 9216K,used 671K[0x029d0000,0x033d0000,0x033d0000)
eden space 8192K,8%used[0x029d0000,0x02a77e98,0x031d0000)
from space 1024K,0%used[0x031d0000,0x031d0000,0x032d0000)
to space 1024K,0%used[0x032d0000,0x032d0000,0x033d0000)
tenured generation total 10240K,used 4096K[0x033d0000,0x03dd0000,0x03dd0000)
the space 10240K,40%used[0x033d0000,0x037d0010,0x037d0200,0x03dd0000)
compacting perm gen total 12288K,used 2107K[0x03dd0000,0x049d0000,0x07dd0000)
the space 12288K,17%used[0x03dd0000,0x03fdefd0,0x03fdf000,0x049d0000)
No shared spaces configured.
執(zhí)行以上代碼中的 testPretenureSizeThreshold() 方法后,我們看到 Eden 空間幾乎沒有被使用,而老年代的 10MB 空間被使用了 40%,也就是 4MB 的 allocation 對象直接就分配在老年代中,這是因為 PretenureSizeThreshold 參數(shù)被設(shè)置為 3MB(就是 3145728,這個參數(shù)不能像 -Xmx 之類的參數(shù)一樣直接寫 3MB),因此超過 3MB 的對象都會直接在老年代進行分配。
注意 PretenureSizeThreshold 參數(shù)只對 Serial 和 ParNew 兩款收集器有效,Parallel Scavenge 收集器不認(rèn)識這個參數(shù),Parallel Scavenge 收集器一般并不需要設(shè)置。如果遇到必須使用此參數(shù)的場合,可以考慮 ParNew 加 CMS 的收集器組合。
4. 長期存活的對象將進入老年代
虛擬機給每個對象定義了一個對象年齡(Age)計數(shù)器。
如果對象在 Eden 出生并經(jīng)過第一次 Minor GC 后仍然存活,并且能被 Survivor 容納的話,將被移動到 Survivor 空間中,并且對象年齡設(shè)為 1 。對象在 Survivor 區(qū)中每“熬過”一次 Minor GC,年齡就增加 1 歲,當(dāng)它的年齡增加到一定程度(默認(rèn)為 15 歲),就將會被晉升到老年代中。
對象晉升老年代的年齡閾值,可以通過參數(shù) -XX:MaxTenuringThreshold 設(shè)置。
5. 動態(tài)對象年齡判定
為了能更好地適應(yīng)不同程序的內(nèi)存狀況,無須等到 MaxTenuringThreshold 中要求的年齡,同年對象達到 Survivor 空間的一半后,他們以及年齡大于他們的對象都將直接進入老年代。
6. 空間分配擔(dān)保
在發(fā)生 Minor GC 之前,虛擬機會先檢查老年代最大可用的連續(xù)空間是否大于新生代所有對象總空間,如果這個條件成立,那么 Minor GC 可以確保是安全的。
只要老年代的連續(xù)空間大于新生代對象總大小或者歷次晉升的平均大小就會進行 Minor GC ,否則將進行 Full GC 。