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