深入理解JVM讀書筆記二: 垃圾收集器與記憶體分配策略

衣舞晨風發表於2016-10-11

3.2物件已死嗎?

3.2.1 引用計數法

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

它很難解決物件之間相互迴圈引用的問題。

3.2.2 可達性分析演算法

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

小注:
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.標記-清除演算法

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

3.3.2.複製演算法

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

這裡寫圖片描述

現在的商業虛擬機器都採用這種收集演算法來回收新生代,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.標記-整理演算法

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

這裡寫圖片描述

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”區被填滿之後,會將所有物件移動到年老代中。

這裡寫圖片描述

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

深入理解Java虛擬機器——JVM高階特性與最佳實踐(第2版)PDF版下載:
http://download.csdn.net/detail/xunzaosiyecao/9648998

作者:jiankunking 出處:http://blog.csdn.net/jiankunking

相關文章