垃圾收集器與內(nèi)存分配策略
Java與C++之間有一堵由內(nèi)存動態(tài)分配和垃圾收集技術(shù)所圍成的“高墻”,墻外面的人想進(jìn)去,墻里面的人卻想出來。
1. 概述
GC需要完成的3件事情:
- 哪些內(nèi)存需要回收?
- 什么時候回收?
- 如何回收?
2. 對象已死嗎
1. 引用計數(shù)算法
給對象中添加一個引用計數(shù)器,每當(dāng)有一個地方引用它時,計數(shù)器值就加1;當(dāng)引用失效時,計數(shù)器值就減1;任何時刻計數(shù)器為0的對象就是不可能再被使用的。
客觀地說,引用計數(shù)算法(Reference Counting)的實(shí)現(xiàn)簡單,判定效率也很高,在大部分情況下它都是一個不錯的算法,也有一些比較著名的應(yīng)用案例。主流的Java虛擬機(jī)里面沒有選用引用計數(shù)算法來管理內(nèi)存,其中最主要的原因是它很難解決對象之間相互循環(huán)引用的問題。
2. 可達(dá)性分析
在主流的商用程序語言(Java、C#,甚至包括前面提到的古老的Lisp)的主流實(shí)現(xiàn)中,都是稱通過可達(dá)性分析(Reachability Analysis)來判定對象是否存活的。這個算法的基本思路就是通過一系列的稱為“GC Roots”的對象作為起始點(diǎn),從這些節(jié)點(diǎn)開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當(dāng)一個對象到GC Roots沒有任何引用鏈相連(用圖論的話來說,就是從GC Roots到這個對象不可達(dá))時,則證明此對象是不可用的。
在Java語言中,可作為GC Roots的對象包括下面幾種:
- 虛擬機(jī)棧(棧幀中的本地變量表)中引用的對象。
- 方法區(qū)中類靜態(tài)屬性引用的對象。
- 方法區(qū)中常量引用的對象。
- 本地方法棧中JNI(即一般說的Native方法)引用的對象。
3. 再談引用
在JDK 1.2以前,Java中的引用的定義很傳統(tǒng):如果reference類型的數(shù)據(jù)中存儲的數(shù)值代表的是另外一塊內(nèi)存的起始地址,就稱這塊內(nèi)存代表著一個引用。這種定義很純粹,但是太過狹隘,一個對象在這種定義下只有被引用或者沒有被引用兩種狀態(tài)。
在JDK 1.2之后,Java對引用的概念進(jìn)行了擴(kuò)充,將引用分為強(qiáng)引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)、虛引用(Phantom Reference)4種,這4種引用強(qiáng)度依次逐漸減弱。
強(qiáng)引用就是指在程序代碼之中普遍存在的,類似“Object obj=new Object()”這類的引用,只要強(qiáng)引用還存在,垃圾收集器永遠(yuǎn)不會回收掉被引用的對象。
軟引用是用來描述一些還有用但并非必需的對象。對于軟引用關(guān)聯(lián)著的對象,在系統(tǒng)將要發(fā)生內(nèi)存溢出異常之前,將會把這些對象列進(jìn)回收范圍之中進(jìn)行第二次回收。如果這次回收還沒有足夠的內(nèi)存,才會拋出內(nèi)存溢出異常。在JDK 1.2之后,提供了SoftReference類來實(shí)現(xiàn)軟引用。
弱引用也是用來描述非必需對象的,但是它的強(qiáng)度比軟引用更弱一些,被弱引用關(guān)聯(lián)的對象只能生存到下一次垃圾收集發(fā)生之前。當(dāng)垃圾收集器工作時,無論當(dāng)前內(nèi)存是否足夠,都會回收掉只被弱引用關(guān)聯(lián)的對象。在JDK 1.2之后,提供了WeakReference類來實(shí)現(xiàn)弱引用。
虛引用也稱為幽靈引用或者幻影引用,它是最弱的一種引用關(guān)系。一個對象是否有虛引用的存在,完全不會對其生存時間構(gòu)成影響,也無法通過虛引用來取得一個對象實(shí)例。為一個對象設(shè)置虛引用關(guān)聯(lián)的唯一目的就是能在這個對象被收集器回收時收到一個系統(tǒng)通知。在JDK 1.2之后,提供了PhantomReference類來實(shí)現(xiàn)虛引用。
4. 生存還是死亡
即使在可達(dá)性分析算法中不可達(dá)的對象,也并非是“非死不可”的,這時候它們暫時處于“緩刑”階段,要真正宣告一個對象死亡,至少要經(jīng)歷兩次標(biāo)記過程:如果對象在進(jìn)行可達(dá)性分析后發(fā)現(xiàn)沒有與GC Roots相連接的引用鏈,那它將會被第一次標(biāo)記并且進(jìn)行一次篩選,篩選的條件是此對象是否有必要執(zhí)行finalize()方法。當(dāng)對象沒有覆蓋finalize()方法,或者finalize()方法已經(jīng)被虛擬機(jī)調(diào)用過,虛擬機(jī)將這兩種情況都視為“沒有必要執(zhí)行”。
如果這個對象被判定為有必要執(zhí)行finalize()方法,那么這個對象將會放置在一個叫做F-Queue的隊列之中,并在稍后由一個由虛擬機(jī)自動建立的、低優(yōu)先級的Finalizer線程去執(zhí)行它。這里所謂的“執(zhí)行”是指虛擬機(jī)會觸發(fā)這個方法,但并不承諾會等待它運(yùn)行結(jié)束。finalize()方法是對象逃脫死亡命運(yùn)的最后一次機(jī)會,稍后GC將對F-Queue中的對象進(jìn)行第二次小規(guī)模的標(biāo)記,如果對象要在finalize()中成功拯救自己——只要重新與引用鏈上的任何一個對象建立關(guān)聯(lián)即可,譬如把自己(this關(guān)鍵字)賦值給某個類變量或者對象的成員變量,那在第二次標(biāo)記時它將被移除出“即將回收”的集合;如果對象這時候還沒有逃脫,那基本上它就真的被回收了。
5. 回收方法區(qū)
在方法區(qū)中進(jìn)行垃圾收集的“性價比”一般比較低:在堆中,尤其是在新生代中,常規(guī)應(yīng)用進(jìn)行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠(yuǎn)低于此。
永久代(方法區(qū)?)的垃圾收集主要回收兩部分內(nèi)容:廢棄常量和無用的類。
判定一個常量是否是“廢棄常量”比較簡單,而要判定一個類是否是“無用的類”的條件則相對苛刻許多。類需要同時滿足下面3個條件才能算是“無用的類”:
- 該類所有的實(shí)例都已經(jīng)被回收,也就是Java堆中不存在該類的任何實(shí)例。
- 加載該類的ClassLoader已經(jīng)被回收。
- 該類對應(yīng)的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
在大量使用反射、動態(tài)代理、CGLib等ByteCode框架、動態(tài)生成JSP以及OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機(jī)具備類卸載的功能,以保證永久代不會溢出。
3. 垃圾收集算法
1. 標(biāo)記-清除算法
最基礎(chǔ)的收集算法是“標(biāo)記-清除”(Mark-Sweep)算法,如同它的名字一樣,算法分為“標(biāo)記”和“清除”兩個階段:首先標(biāo)記出所有需要回收的對象,在標(biāo)記完成后統(tǒng)一回收所有被標(biāo)記的對象,它的標(biāo)記過程其實(shí)在前一節(jié)講述對象標(biāo)記判定時已經(jīng)介紹過了。它的主要不足有兩個:一個是效率問題,標(biāo)記和清除兩個過程的效率都不高;另一個是空間問題,標(biāo)記清除之后會產(chǎn)生大量不連續(xù)的內(nèi)存碎片,空間碎片太多可能會導(dǎo)致以后在程序運(yùn)行過程中需要分配較大對象時,無法找到足夠的連續(xù)內(nèi)存而不得不提前觸發(fā)另一次垃圾收集動作。
2. 復(fù)制算法
為了解決效率問題,一種稱為“復(fù)制”(Copying)的收集算法出現(xiàn)了,它將可用內(nèi)存按容量劃分為大小相等的兩塊,每次只使用其中的一塊。當(dāng)這一塊的內(nèi)存用完了,就將還存活著的對象復(fù)制到另外一塊上面,然后再把已使用過的內(nèi)存空間一次清理掉。只是這種算法的代價是將內(nèi)存縮小為了原來的一半,未免太高了一點(diǎn)。
HotSpot虛擬機(jī)默認(rèn)Eden和Survivor的大小比例是8:1,也就是每次新生代中可用內(nèi)存空間為整個新生代容量的90%(80%+10%),只有10%的內(nèi)存會被“浪費(fèi)”。
3. 標(biāo)記-整理法
根據(jù)老年代的特點(diǎn),有人提出了另外一種“標(biāo)記-整理”(Mark-Compact)算法,標(biāo)記過程仍然與“標(biāo)記-清除”算法一樣,但后續(xù)步驟不是直接對可回收對象進(jìn)行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內(nèi)存。
4. 分代收集法
當(dāng)前商業(yè)虛擬機(jī)的垃圾收集都采用“分代收集”(Generational Collection)算法,這種算法并沒有什么新的思想,只是根據(jù)對象存活周期的不同將內(nèi)存劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據(jù)各個年代的特點(diǎn)采用最適當(dāng)?shù)氖占惴?。在新生代中,每次垃圾收集時都發(fā)現(xiàn)有大批對象死去,只有少量存活,那就選用復(fù)制算法,只需要付出少量存活對象的復(fù)制成本就可以完成收集。而老年代中因?yàn)閷ο蟠婊盥矢?、沒有額外空間對它進(jìn)行分配擔(dān)保,就必須使用“標(biāo)記—清理”或者“標(biāo)記—整理”算法來進(jìn)行回收。
4. HotSpot的算法實(shí)現(xiàn)
1. 枚舉根節(jié)點(diǎn)
從可達(dá)性分析中從GC Roots節(jié)點(diǎn)找引用鏈這個操作為例,可作為GC Roots的節(jié)點(diǎn)主要在全局性的引用(例如常量或類靜態(tài)屬性)與執(zhí)行上下文(例如棧幀中的本地變量表)中,現(xiàn)在很多應(yīng)用僅僅方法區(qū)就有數(shù)百兆,如果要逐個檢查這里面的引用,那么必然會消耗很多時間。
另外,可達(dá)性分析對執(zhí)行時間的敏感還體現(xiàn)在GC停頓上,因?yàn)檫@項(xiàng)分析工作必須在一個能確保一致性的快照中進(jìn)行——這里“一致性”的意思是指在整個分析期間整個執(zhí)行系統(tǒng)看起來就像被凍結(jié)在某個時間點(diǎn)上,不可以出現(xiàn)分析過程中對象引用關(guān)系還在不斷變化的情況,該點(diǎn)不滿足的話分析結(jié)果準(zhǔn)確性就無法得到保證。
由于目前的主流Java虛擬機(jī)使用的都是準(zhǔn)確式GC(這個概念在第1章介紹Exact VM對Classic VM的改進(jìn)時講過),所以當(dāng)執(zhí)行系統(tǒng)停頓下來后,并不需要一個不漏地檢查完所有執(zhí)行上下文和全局的引用位置,虛擬機(jī)應(yīng)當(dāng)是有辦法直接得知哪些地方存放著對象引用。在HotSpot的實(shí)現(xiàn)中,是使用一組稱為OopMap的數(shù)據(jù)結(jié)構(gòu)來達(dá)到這個目的的,在類加載完成的時候,HotSpot就把對象內(nèi)什么偏移量上是什么類型的數(shù)據(jù)計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。
2. 安全點(diǎn)
在OopMap的協(xié)助下,HotSpot可以快速且準(zhǔn)確地完成GC Roots枚舉,但一個很現(xiàn)實(shí)的問題隨之而來:可能導(dǎo)致引用關(guān)系變化,或者說OopMap內(nèi)容變化的指令非常多,如果為每一條指令都生成對應(yīng)的OopMap,那將會需要大量的額外空間,這樣GC的空間成本將會變得很高。
實(shí)際上,HotSpot也的確沒有為每條指令都生成OopMap,前面已經(jīng)提到,只是在“特定的位置”記錄了這些信息,這些位置稱為安全點(diǎn)(Safepoint),即程序執(zhí)行時并非在所有地方都能停頓下來開始GC,只有在到達(dá)安全點(diǎn)時才能暫停。
3.安全區(qū)域
安全區(qū)域是指在一段代碼片段之中,引用關(guān)系不會發(fā)生變化。在這個區(qū)域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴(kuò)展了的Safepoint。
4. 垃圾收集器
此部分原書中例舉了大量垃圾收集器,本人認(rèn)為實(shí)際應(yīng)用得到具體場景才有用處,此處不作筆記。
5. 內(nèi)存分配回收策略
對象的內(nèi)存分配,往大方向講,就是在堆上分配(但也可能經(jīng)過JIT編譯后被拆散為標(biāo)量類型并間接地棧上分配[1]),對象主要分配在新生代的Eden區(qū)上,如果啟動了本地線程分配緩沖,將按線程優(yōu)先在TLAB上分配。少數(shù)情況下也可能會直接分配在老年代中,分配的規(guī)則并不是百分之百固定的,其細(xì)節(jié)取決于當(dāng)前使用的是哪一種垃圾收集器組合,還有虛擬機(jī)中與內(nèi)存相關(guān)的參數(shù)的設(shè)置。
1. 對象優(yōu)先在Eden分配
大多數(shù)情況下,對象在新生代Eden區(qū)中分配。當(dāng)Eden區(qū)沒有足夠空間進(jìn)行分配時,虛擬機(jī)將發(fā)起一次Minor GC。
Minor GC和Full GC有什么不一樣嗎?
新生代GC(Minor GC):指發(fā)生在新生代的垃圾收集動作,因?yàn)镴ava對象大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
老年代GC(Major GC/Full GC):指發(fā)生在老年代的GC,出現(xiàn)了Major GC,經(jīng)常會伴隨至少一次的Minor GC(但非絕對的,在Parallel Scavenge收集器的收集策略里就有直接進(jìn)行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。
2. 大對象直接進(jìn)入老年代
所謂的大對象是指,需要大量連續(xù)內(nèi)存空間的Java對象。大對象對虛擬機(jī)的內(nèi)存分配來說就是一個壞消息(替Java虛擬機(jī)抱怨一句,比遇到一個大對象更加壞的消息就是遇到一群“朝生夕滅”的“短命大對象”,寫程序的時候應(yīng)當(dāng)避免),經(jīng)常出現(xiàn)大對象容易導(dǎo)致內(nèi)存還有不少空間時就提前觸發(fā)垃圾收集以獲取足夠的連續(xù)空間來“安置”它們。
3. 長期存活的對象將進(jìn)入老年代
哪些對象應(yīng)放在老年代中。為了做到這點(diǎn),虛擬機(jī)給每個對象定義了一個對象年齡(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è)置。
4. 動態(tài)對象年齡判斷
為了能更好地適應(yīng)不同程序的內(nèi)存狀況,虛擬機(jī)并不是永遠(yuǎn)地要求對象的年齡必須達(dá)到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進(jìn)入老年代,無須等到MaxTenuringThreshold中要求的年齡。
5. 空間分配擔(dān)保
在發(fā)生Minor GC之前,虛擬機(jī)會先檢查老年代最大可用的連續(xù)空間是否大于新生代所有對象總空間,如果這個條件成立,那么Minor GC可以確保是安全的。如果不成立,則虛擬機(jī)會查看HandlePromotionFailure設(shè)置值是否允許擔(dān)保失敗。如果允許,那么會繼續(xù)檢查老年代最大可用的連續(xù)空間是否大于歷次晉升到老年代對象的平均大小,如果大于,將嘗試著進(jìn)行一次Minor GC,盡管這次Minor GC是有風(fēng)險的;如果小于,或者HandlePromotionFailure設(shè)置不允許冒險,那這時也要改為進(jìn)行一次Full GC。