天天看點

深入了解JVM讀書筆記二: 垃圾收集器與記憶體配置設定政策

3.2對象已死嗎?

3.2.1 引用計數法

給對象添加一個引用計數器,每當有一個地方引用它的地方,計數器值+1;當引用失效,計數器值就減1;任何時候計數器為0,對象就不可能再被引用了。

它很難解決對象之間互相循環引用的問題。

3.2.2 可達性分析算法

這個算法的基本思路就是通過一系列的稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為引用鍊(Reference Chain),當一個對象到GC Roots沒有任何引用鍊相連(用圖論的話來說,就是從GC Roots到這個對象不可達)時,則證明此對象是不可用的。

深入了解JVM讀書筆記二: 垃圾收集器與記憶體配置設定政策

小注:

object5 6 7 對于GC Roots是不可達的,是以會被判定為回收對象

可以作為Gc Roots的對象包括下面幾種:

  1. 虛拟機棧(棧幀中的本地變量表)中引用的對象。
  2. 方法區中類靜态屬性引用的對象。
  3. 方法區中常量引用的對象。
  4. 本地方法棧中JNI(即一般說的Native方法)引用的對象。

3.2.3 再談引用

1、強引用(StrongReference)

強引用就是指在程式代碼之中普遍存在的,比如下面這段代碼中的object和str都是強引用:

Object object = new Object(); 
String str = "hello";       

隻要某個對象有強引用與之關聯,JVM必定不會回收這個對象,即使在記憶體不足的情況下,JVM甯願抛出OutOfMemory錯誤也不會回收這種對象。

如果想中斷強引用和某個對象之間的關聯,可以顯示地将引用指派為null,這樣一來的話,JVM在合适的時間就會回收該對象。

2、軟引用(SoftReference)

軟引用是用來描述一些有用但并不是必需的對象,在Java中用java.lang.ref.SoftReference類來表示。對于軟引用關聯着的對象,隻有在記憶體不足的時候JVM才會回收該對象。是以,這一點可以很好地用來解決OOM的問題,并且這個特性很适合用來實作緩存:比如網頁緩存、圖檔緩存等。

3、弱引用(WeakReference)

弱引用也是用來描述非必需對象的,當JVM進行垃圾回收時,無論記憶體是否充足,都會回收被弱引用關聯的對象。在java中,用java.lang.ref.WeakReference類來表示。

4、虛引用(PhantomReference)

虛引用也稱為幽靈引用或者是幻影引用,是最弱的一種引用關系。一個對象是否會有虛引用的存在,完全不會對其生存時間造成影響,也無法通過一個虛引用來獲得一個對象執行個體。為一個對象設定虛引用關聯的唯一目的就是 希望能在這個對象被收集器回收時得到一個系統的通知。類PhantomReference實作虛引用。

3.2.4生存還是死亡?

即使在可達性分析算法中不可達的對象,也并非是“非死不可”的,這時候它們暫時處于“緩刑”階段,要真正宣告一個對象死亡,至少要經曆兩次标記過程:如果對象在進行可達性分析後發現沒有與GC Roots相連接配接的引用鍊,那它将會被第一次标記并且進行一次篩選,篩選的條件是此對象是否有必要執行finalize()方法。當對象沒有覆寫finalize()方法,或者finalize()方法已經被虛拟機調用過,虛拟機将這兩種情況都視為“沒有必要執行”。

如果這個對象被判定為有必要執行finalize()方法,那麼這個對象将會放置在一個叫做F-Queue的隊列之中,并在稍後由一個由虛拟機自動建立的、低優先級的Finalizer線程去執行它。這裡所謂的“執行”是指虛拟機會觸發這個方法,但并不承諾會等待它運作結束,這樣做的原因是,如果一個對象在finalize()方法中執行緩慢,或者發生了死循環(更極端的情況),将很可能會導緻F-Queue隊列中其他對象永久處于等待,甚至導緻整個記憶體回收系統崩潰。finalize()方法是對象逃脫死亡命運的最後一次機會,稍後GC将對F-Queue中的對象進行第二次小規模的标記,如果對象要在finalize()中成功拯救自己——隻要重新與引用鍊上的任何一個對象建立關聯即可,譬如把自己(this關鍵字)指派給某個類變量或者對象的成員變量,那在第二次标記時它将被移除出“即将回收”的集合;如果對象這時候還沒有逃脫,那基本上它就真的被回收了。

建議大家盡量避免使用它,因為它不是C/C++中的析構函數,而是Java剛誕生時為了使C/C++程式員更容易接受它所做出的一個妥協。它的運作代價高昂,不确定性大,無法保證各個對象的調用順序。

3.2.5回收方法區

在堆中,尤其是在新生代中,正常應用進行一次垃圾收集一般可以回收70%~95%的空間,而永久代的垃圾收集效率遠低于此。

永久代的垃圾收集主要回收兩部分内容:廢棄常量和無用的類。

類需要同時滿足下面3個條件才能算是“無用的類”

  1. 該類所有的執行個體都已經被回收,也就是Java堆中不存在該類的任何執行個體。
  2. 加載該類的ClassLoader已經被回收。
  3. 該類對應的java.lang.Class對象沒有在任何地方被引用,無法在任何地方通過反射通路該類的方法。

在大量使用反射、動态代理、Cglib等ByteCode架構、動态生成JSP以及OSGI這類頻繁自定義ClassLoader的場景都需要虛拟機具備類解除安裝的功能,以保證永久代不會溢出。

3.3垃圾收集算法

3.3.1.标記-清除算法

如同它的名字一樣,算法分為“标記”和“清除”兩個階段:首先标記出所有需要回收的對象,在标記完成後統一回收所有被标記的對象。它的主要不足有兩個:一個是效率問題,标記和清除兩個過程的效率都不高;另一個是空間問題,标記清除之後會産生大量不連續的記憶體碎片,空間碎片太多可能會導緻以後在程式運作過程中需要配置設定較大對象時,無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集動作。标記—清除算法的執行過程如下圖所示:

深入了解JVM讀書筆記二: 垃圾收集器與記憶體配置設定政策

3.3.2.複制算法

為了解決效率問題,一種稱為“複制”(Copying)的收集算法出現了,它将可用記憶體按容量劃分為大小相等的兩塊,每次隻使用其中的一塊。當這一塊的記憶體用完了,就将還存活着的對象複制到另外一塊上面,然後再把已使用過的記憶體空間一次清理掉。這樣使得每次都是對整個半區進行記憶體回收,記憶體配置設定時也就不用考慮記憶體碎片等複雜情況,隻要移動堆頂指針,按順序配置設定記憶體即可,實作簡單,運作高效。隻是這種算法的代價是将記憶體縮小為了原來的一半,未免太高了一點。複制算法的執行過程如下圖所示:

深入了解JVM讀書筆記二: 垃圾收集器與記憶體配置設定政策

現在的商業虛拟機都采用這種收集算法來回收新生代,IBM公司的專門研究表明,新生代中的對象98%是“朝生夕死”的,是以并不需要按照1:1的比例來劃分記憶體空間,而是将記憶體分為一塊較大的Eden空間和兩塊較小的Survivor空間,每次使用Eden和其中一塊Survivor。當回收時,将Eden和Survivor中還存活着的對象一次性地複制到另外一塊Survivor空間上,最後清理掉Eden和剛才用過的Survivor空間。HotSpot虛拟機預設Eden和Survivor的大小比例是8:1,也就是每次新生代中可用記憶體空間為整個新生代容量的90%(80%+10%),隻有10%的記憶體會被“浪費”。當然,98%的對象可回收隻是一般場景下的資料,我們沒有辦法保證每次回收都隻有不多于10%的對象存活,當Survivor空間不夠用時,需要依賴其他記憶體(這裡指老年代)進行配置設定擔保(Handle Promotion)

配置設定擔保:如果另外一塊Survivor空間沒有足夠空間存放上一次新生代收集下來的存活對象時,這些對象将直接通過配置設定擔保機制進入老年代。

3.3.3.标記-整理算法

标記過程仍然與“标記-清除”算法一樣,但後續步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然後直接清理掉端邊界以外的記憶體,“标記-整理”算法的示意圖如圖所示。

深入了解JVM讀書筆記二: 垃圾收集器與記憶體配置設定政策

3.3.4.分代收集算法

目前商業虛拟機的垃圾收集都采用“分代收集”(Generational Collection)算法,這種算法并沒有什麼新的思想,隻是根據對象存活周期的不同将記憶體劃分為幾塊。一般是把Java堆分為新生代和老年代,這樣就可以根據各個年代的特點采用最适當的收集算法。在新生代中,每次垃圾收集時都發現有大批對象死去,隻有少量存活,那就選用複制算法,隻需要付出少量存活對象的複制成本就可以完成收集。而老年代中因為對象存活率高、沒有額外空間對它進行配置設定擔保,就必須使用“标記—清理”或者“标記—整理”算法來進行回收。

3.4 HotSpot的算法實作

3.4.1枚舉根節點

從可達性分析中從GC Roots節點找引用鍊這個操作為例,可作為GC Roots的節點主要在全局性的引用(例如常量或類靜态屬性)與執行上下文(例如幀棧中的本地變量表)中,現在很多應用僅僅方法區就有數百兆,如果要逐個檢查這裡面的引用,那麼必然會消耗很多時間。

另外,可達性分析對執行時間的敏感還展現在GC停頓上,因為這項分析工作必須在一個能確定一緻性的快照中進行–這裡“一緻性”的意思是指在整個分析期間整個執行系統看起來就像被當機在某個時間點上,不可以出現分析過程中對象引用關系還在不斷變化的情況,該點不滿足的話分析結果準确性就無法得到保證。這點是導緻GC進行時必須停頓所有Java執行線程(Sun将這件事情稱為“Stop The World”)的其中一個重要原因,即使是在号稱(幾乎)不會發生停頓的CMS收集器中,枚舉根節點也是必須要停頓的。

由于目前的主流Java虛拟機使用的都是準确式GC,是以當執行系統停頓下來後,并不需要一個不漏地檢查完所有執行上下文和全局的引用位置,虛拟機應當是有辦法直接得知哪些地方存放着對象的引用。在HotSpot的實作中,是使用一組稱為OopMap的資料結構來達到這個目的的,在類加載完成的時候,HotSpot就把對象内什麼偏移量上是什麼類型的資料計算出來,在JIT編譯過程中,也會在特定的位置記錄下棧和寄存器中哪些位置是引用。這樣,GC在掃描時就可以直接得知這些資訊了。

3.4.2安全點

在OopMap的協助下,HotSpot可以快速且準确地完成GC Roots枚舉,但一個很現實的問題随之而來:可能導緻引用關系變化,或者說OopMap内容變化的指令非常多,如果為每一條指令都生成對應的OopMap,那将會需要大量的額外空間,這樣GC的空間成本将會變得很高。

實際上,HotSpot也的确沒有為每條指令都生成OopMap,前面已經提到,隻是在“特定的位置”記錄了這些資訊,這些位置稱為安全點(Safepoint),即程式執行時并非在所有地方都能停頓下來開始GC,隻有在到達安全點時才能暫停。Safepoint的標明即不能太少以至于讓GC等待時間太長,也不能過于頻繁以至于過分增大運作時負荷。是以,安全點的標明基本上是以程式“是否具有讓程式長時間執行的特征”為标準進行標明的–因為每條指令執行的時間都非常短暫,程式不太可能因為指令流長度太長這個原因而過長時間運作,“長時間執行”的最明顯特征就是指令序列複用,例如方法調用、循環跳轉、異常跳轉等,是以具有這些功能的指令才會産生Safepoint。

對于Safepoint,另一個需要考慮的問題是如何在GC發生時讓是以線程(這裡不包括執行JNI調用的線程)都“跑”到最近的安全點上再停頓下來。

這裡有兩種方案可供選擇:

1)搶先式中斷(Preemptive Suspension)

2)主動式中斷(Voluntary Suspension)

其中搶先式中斷不需要線程的執行代碼主動去配合,在GC發生時,首先把所有線程全部中斷,如果發現有線程中斷的地方不在安全點上,就恢複線程,讓它“跑”到安全點上。現在幾乎沒有虛拟機實作采用搶先式中斷來暫停線程進而響應GC事件。

而主動式中斷的思想是當GC需要中斷線程的時候,不直接對線程操作,僅僅簡單地設定一個标志,各個線程執行時主動去輪詢這個标志,發現中斷标志為真時就自己中斷挂起。輪詢标志的地方和安全點是重合的,另外再加上建立對象需要配置設定記憶體的地方。

3.4.3安全區域

使用Safepoint似乎已經完美地解決了如何進入GC的問題,但實際情況卻并不一定。Safepoint機制保證了程式執行時,在不太長的時間内就會遇到可進入GC的Safepoint。但是,程式就”不執行“的時候呢?所謂的程式不執行就是沒有配置設定CPU時間,典型的例子就是線程處于Sleep狀态或者Blocked狀态,這時候線程無法響應JVM的中斷請求,”走“到安全的地方去中斷挂起,JVM也顯然不太可能等待線程重新被配置設定CPU時間。對于這種情況,就需要安全區域(Safe Region)來解決。

安全區域是指在一段代碼片段之中,引用關系不會發生變化。在這個區域中的任意地方開始GC都是安全的。我們也可以把Safe Region看做是被擴充了的Safepoint。

線上程執行到Safe Region中的代碼時,首先辨別自己已經進入了Safe Region,那樣,當在這段時間裡JVM要發起GC時,就不用管辨別自己為Safe Region狀态的線程了。線上程要離開Safe Region時,它要檢查系統是否已經完成了根節點枚舉(或者是整個GC過程),如果完成了,那線程就繼續執行,否則它就必須繼續等待直到收到可以安全離開Safe Region的信号為止。

3.5垃圾收集器(略)

3.6記憶體配置設定與回收政策

對象的記憶體配置設定,從大方向上将,就是在堆上配置設定(但也可能經過JIT編譯後被拆散為标量類型并間接地在棧上配置設定),對象主要配置設定在新生代的Eden區上,如果啟動了本地線程配置設定緩沖,将按線程優先在TLAB上配置設定。少數情況也可能直接配置設定在老年代中,配置設定的規則并不是百分之百固定的,其細節取決于目前使用的是哪一種垃圾收集器組合,還有虛拟機中與記憶體相關的參數的設定。

3.6.1.對象優先在Eden配置設定

大多數情況下,對象在新生代Eden區中配置設定。當Eden區沒有足夠空間配置設定時,虛拟機将發起一次Minor GC。

虛拟機提供了-XX:+PrintGCDetails這個收集器日志參數,告訴虛拟機在發生垃圾收集行為時列印記憶體回收日志,并且在程序退出的時候輸出目前記憶體各區域的配置設定情況。在實際應用中,記憶體回收日志一般都是列印到檔案後通過日志工具進行分析。

Minor和Full GC有什麼不一樣?

新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java對象大多都具備朝生夕死的特性,是以Minor GC非常頻繁,一般回收速度也比較快。

老年代GC(Major GC/Full GC):指發生在老年代的GC,出現Major GC,經常會伴随至少一次的Minor GC(但并非絕對的,在Parallel Scavenge收集器的收集政策裡就有直接進行Major GC的政策選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。

3.6.2大對象直接進入老年代

所謂大對象,就是指需要大量連續記憶體空間的Java對象,最典型的大對象就是那種很長的字元串及數組(byte[]數組就是典型的大對象)。大對象對虛拟機的記憶體配置設定來說就是一個壞消息(更加壞的情況就是遇到一群朝生夕死的短命大對象,寫程式時應該避免),經常出現大對象容易導緻記憶體還有不少空間時就提前觸發垃圾收集以擷取足夠的連續空間來安置大對象。

虛拟機提供了一個-XX:PretenureSizeThreshold參數,令大于這個設定值的對象直接進入老年代中配置設定。這樣避免在Eden區及兩個Survivor區之間發生大量的記憶體拷貝。

3.6.3長期存活的對象将進入老年代

虛拟機采用了分代收集的思想來管理記憶體,那記憶體回收時就必須識别哪些對象應該放在新生代,哪些對象應該放在老年代中。為了做到這點,虛拟機給每個對象定義了一個對象年齡(Age)計數器。如果對象在Eden出生并經過第一次Minor GC後仍然存活,并且能被Survivor容納的話,将被移動到Survivor空間中,并将對象年齡設為1。對象在Survivor區中沒熬過一次Minor GC,年齡就增加1,當它的年齡增加到一定程度(預設為15)時,就會被晉升到老年代中。對象晉升到老年代的年齡閥值,可以通過參數-XX:MaxTenuringThreshold來設定。

3.6.4動态對象年齡判定

為了能更好地适應不同程式的記憶體狀況,虛拟機并不總是要求對象的年齡必須達到MaxTenuringThreshold才能晉升到老年代,如果在Survivor空間中相同年齡所有對象大小的綜合大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡。

3.6.5空間配置設定擔保

當發生Minor GC時,虛拟機會檢測之前每次晉升到老年代的平均大小是否大于老年代的剩餘空間大小,如果大于,則改為直接進行一次Full GC。如果小于,則檢視HandlePromotionFailure設定是否允許擔保失敗;如果允許,那隻會進行Minor GC;如果不允許,則也要改為進行一次Full GC。

新生代使用複制收集算法,但為了記憶體使用率,隻使用其中一個Survivor空間來作為輪換備份,是以當出現大量對象在Minor GC後仍然存活的情況時(最極端就是記憶體回收後新生代中所有對象都存活),就需要老年代進行配置設定擔保,讓Survivor無法容納的對象直接進入老年代。與生活中的貸款擔保類似,老年代要進行這樣的擔保,前提是老年代本身還有容納這些對象的剩餘空間,一共有多少對象會活下去,在實際完成記憶體回收之前是無法明确知道的,是以隻好取之前每一次回收晉升到老年代對象容量的平均大小值作為經驗,與老年代的剩餘空間進行對比,決定是否進行Full GC來讓老年代騰出更多空間。

取平均值進行比較其實仍然是一種動态機率的手段,也就是說如果某次Minor GC存活後的對象突增,遠遠高于平均值時,依然會導緻擔保失敗(Handle Promotion Failure)。如果出現HandlePromotionFailure失敗,那就隻好在失敗後重新發起一次Full GC。雖然擔保失敗時繞的圈子是最大的,但大部分情況下都還是會将HandlePromotionFailure開關打開,避免Full GC過于頻繁。

拓展補充

(整理自:​​聊聊JVM的年輕代​​)

HotSpot JVM把年輕代分為了三部分:1個Eden區和2個Survivor區(分别叫from和to)。預設比例為8:1,新建立的對象都會被配置設定到Eden區(一些大對象特殊處理),這些對象經過第一次Minor GC後,如果仍然存活,将會被移到Survivor區。對象在Survivor區中每熬過一次Minor GC,年齡就會增加1歲,當它的年齡增加到一定程度時,就會被移動到年老代中。

因為年輕代中的對象基本都是朝生夕死的(80%以上),是以在年輕代的垃圾回收算法使用的是複制算法,複制算法的基本思想就是将記憶體分為兩塊,每次隻用其中一塊,當這一塊記憶體用完,就将還活着的對象複制到另外一塊上面。複制算法不會産生記憶體碎片。

在GC開始的時候,對象隻會存在于Eden區和名為“From”的Survivor區,Survivor區“To”是空的。緊接着進行GC,Eden區中所有存活的對象都會被複制到“To”,而在“From”區中,仍存活的對象會根據他們的年齡值來決定去向。年齡達到一定值(年齡門檻值,可以通過-XX:MaxTenuringThreshold來設定)的對象會被移動到年老代中,沒有達到門檻值的對象會被複制到“To”區域。經過這次GC後,Eden區和From區已經被清空。這個時候,“From”和“To”會交換他們的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎樣,都會保證名為To的Survivor區域是空的。Minor GC會一直重複這樣的過程,直到“To”區被填滿,“To”區被填滿之後,會将所有對象移動到年老代中。

深入了解JVM讀書筆記二: 垃圾收集器與記憶體配置設定政策

我是一個普通的java對象,我出生在Eden區,在Eden區我還看到和我長的很像的小兄弟,我們在Eden區中玩了挺長時間。有一天Eden區中的人實在是太多了,我就被迫去了Survivor區的“From”區,自從去了Survivor區,我就開始漂了,有時候在Survivor的“From”區,有時候在Survivor的“To”區,居無定所。直到我18歲的時候,爸爸說我成人了,該去社會上闖闖了。于是我就去了年老代那邊,年老代裡,人很多,并且年齡都挺大的,我在這裡也認識了很多人。在年老代裡,我生活了20年(每次GC加一歲),然後被回收。

繼續閱讀