再看JVM:垃圾回收那些事

Mr_小白發表於2018-09-10

前言

JVM虛擬機器為使用者提供了自動記憶體管理機制,使的程式設計師在使用完物件後手動釋放佔用記憶體的工作中解脫出來。記憶體的動態分配和回收完全使得一切都看起來那麼美妙,但是再好的機器也有出問題的時候不是。在專案中需要排查各種記憶體溢位、記憶體洩漏問題時,就有必要來了解了解JVM內部對記憶體回收的那些事了。小白因為要在組內做一次JVM垃圾回收的技術分享,於是又再次研讀了《深入理解Java虛擬機器》一書中垃圾收集相關章節。實在是感覺每看一遍,都有不同的收穫,本文參考虛擬機器神書對GC相關知識加以梳理,同時有的地方談了一些小白自己的理解,有失偏頗,還望指正。

一、垃圾的確定

何為垃圾? 數數JVM執行期的記憶體結構,也就方法區和堆記憶體兩塊記憶體區域是執行緒共享的,虛擬機器棧、程式計數器、本地方法棧都是執行緒私有的,私有就意味著這部分記憶體會隨執行緒的結束而釋放,因此垃圾回收是無須關注執行緒私有的記憶體的。反倒是方法區和堆(主要),由於是執行緒共享的,每個執行緒都可以在這塊區域寫資料。隨著執行緒的結束,這部分記憶體就會存在大量無用的資料。這些資料就是我們常說的垃圾,而這些垃圾佔用的記憶體,就是垃圾回收的目標記憶體。堆記憶體中的垃圾便是無用、或者稱之為死亡的物件,方法區中的垃圾便是無用的常量和類資料。

1.1 物件的死亡宣告機制

空間緊張的記憶體世界,對於物件而言實在太為殘酷,可以說毫無人道主義。只要你沒什麼用了,那麼不好意思,法官便要宣判你的死亡了,然後交由劊子手行刑。但是殘酷歸殘酷,法官是有原則的,就是它需要科學的機制來準確的判定你是否無用,因為只有這種原則才能保證法官所在的世界正常運轉。

1.1.1 判定演算法

物件是否無用的判定演算法有如下兩種:

引用技術演算法:於物件內部維護一計數器,每有一處運用某個物件,該物件的引用計數器便加一,每有一處的引用失效,該物件的引用計數器減一。計數器為0的物件便是無用的,也就是死亡物件。

  • 優點:實現簡單,判定簡單
  • 缺點:無法解決物件互相引用的問題(A的屬性引用B,B的屬性引用A,除此之外,A、B兩個物件毫無用處)

可達性分析演算法:選取特定性質的物件作為根物件(GC Roots),像從樹的根節點往下遍歷一樣,從GC Roots向下遍歷其引用鏈,若存在物件到GC Roots怎麼都不可達(無任何一條呼叫鏈),那麼這些物件便可被回收。

  • 優點:判定準確,不存在物件互相引用的問題
  • 缺點:實現複雜,判定效率比計數法低

主流的Java虛擬機器採用的基本都是可達性分析演算法,主要看重便是其不存在物件互相引用無法回收的問題。該演算法中存在一個概念GC Roots,虛擬機器會遍歷這些物件的呼叫鏈來確定其他物件是否存活。那便有一個前提,可以作為GC Roots的物件必須保證是存活的物件。

  • 虛擬機器棧中(本地變數表)引用的物件。這些物件隨執行緒生而存在,隨執行緒死而被釋放,因此這類物件只要存在,就一定存活。
  • 本地方法棧中引用的物件。原因同上。
  • 方法區中類的靜態屬性引用的物件。一般很少會進行方法區的記憶體回收,且類的回收判定較為嚴苛,因此這中物件基本都是存活的。另外小白也認為這類物件中選作GC Roots的應該是以jdk自身的類為主的。
  • 方法區中常量引用的物件。

1.1.2 物件的引用

判定物件是否無用,其實歸根到底是判定物件的引用是否還存在。引用這個概念是比較java特色的詞語,可以類比C、C++中的指標去理解。一個引用型別的變數的值,是另一塊記憶體的起始地址。更為java特色的是,1.2之後,對引用(Reference)進行了具體化的擴充,也就是常說的強、軟、弱、虛四種。

強引用:就是我們日常new物件前宣告的引用。比如:Object obj = new Object()。其中obj就是強引用。

軟引用(SoftReference):一般用來表示可以存在但非必須的物件。這類物件在記憶體充足時是可以存在的,但是在記憶體不足即將溢位時,會被回收掉。可使用SoftReference類例項化,構造引數為要引用的物件。適用場景小白覺的應該是一些非必要的快取資料,比如圖片檔案的流物件,記憶體充足時快取下來,每次使用直接讀流,記憶體緊張時被回收,下次使用再從原路徑讀取。

弱引用(WeakReference):也是描述非必須的物件。但這個引用關係比軟引用更弱,弱引用引用的物件只要發生垃圾回收,便會被回收,但是在發生垃圾回收之前,還是可以通過若引用獲取到該物件的。適用場景和軟引用類似。

虛引用(PhantomReference):準確叫幻影引用吧,也就是引用是假的。虛引用和物件的生存週期毫無關係。無法通過虛引用獲取到對應物件。唯一的作用就是使這個物件在被回收時收到一個系統通知。可以被例項化,但必須和一個引用佇列關聯使用。虛擬機器在回收這個物件的時候便會把該引用新增進引用佇列,程式便可通過監控引用佇列來實現在物件回收前進行一些操作。

1.2 確定物件真正死亡的過程

虛擬機器不會簡單地通過一次可達性分析就判定某個物件死亡繼而進行回收的。一個物件在確定要回收時至少已經經歷了兩次判定標記。這裡說的每一次標記可以理解為一次可達性判斷。虛擬機器標記物件的過程如下圖(小白根據自己理解的畫的圖,歡迎討論):

JVM標記物件進行回收流程圖
虛擬機器對物件進行第一次標記的時候,對不可達的物件進行篩選,判斷是否有必要執行finalize()方法。若物件沒有覆蓋該方法或已經執行過該方法,JVM會認為該物件沒有必要執行finalize()方法。

而有必要的物件,會被放進一個F-Quene佇列,由低優先順序的Finalizer執行緒觸發這個佇列中物件的finalize()方法。稍後,JVM對該佇列中的所有物件進行一次小規模(佇列中)標記。如果有物件在finalize()方法中拯救了自己,也就是在這個方法中建立了存活物件到this(自己)的引用鏈(具體如何拯救可以百度或去書裡看程式碼),這個物件會在這次小規模標記中標記為可達,否則依舊是不可達。

在第二輪標記開始後,JVM會再次判定物件,將被兩次及其以上被標記為不可達的物件記憶體回收,將拯救了自己的物件移出待回收集合。

注意:

  • 所有重寫過finalize()方法的物件在被回收前才會被執行finalize()方法,並且只要是同一個物件,這個方法在這個物件的整個生命週期中也只會被執行一次。
  • Finalizer執行緒觸發F-Quene佇列裡物件的finalize()方法時並不保證該方法執行結束,底層應該是有時間限制,超過這個時間會被強制結束。因為如果某個物件的finalize()方法執行緩慢甚至是發生了死迴圈,便會使Finalizer執行緒無法觸發佇列中其他物件的finalize()方法。
  • 一個物件只能拯救自己一次,因為每個物件重寫的finalize()只能被觸發一次。這次如果救活了,下次該物件被回收時便不會進入F-Quene佇列。

1.3 方法區垃圾的判定

對於方法區,並不強制要求虛擬機器實現這部分的垃圾回收。主要是因為收集效率低,即耗時長、回收空間少。

方法區主要回收廢棄常量和無用類。廢棄常量的判定與堆記憶體中物件的判定相同。類是否需要回收是由開發人員決定的,HotSpot虛擬機器提供的配置引數為-Xnoclassgc

類的判定取決於下面三個因素:

  • 堆中不存在該類的例項;
  • 載入該類的ClassLoader已經被回收;
  • 任何地方都不存在該類對應的java.lang.Class的引用。

二、垃圾的回收

垃圾由誰來回收,又是怎樣回收呢? 虛擬機器內部提供了適合不同場景下的垃圾收集器來進行垃圾回收,程式設計師可以自己設定。這些垃圾收集器在程式執行時就是虛擬機器內部的一個執行緒,需要注意的一點是這個執行緒是守護執行緒,它會伴隨著我們程式(主執行緒)一起結束。GC執行緒在回收垃圾時,是根據特定的收集演算法取進行垃圾記憶體釋放的。

2.1 垃圾收集演算法

  • 標記-清除演算法:如名字一般,先標記記憶體中需要回收的物件(這裡所說的標記,就是物件被最終判定死亡的標記過程),標記完成後統一對所有被標記的物件進行回收,釋放其所佔用的記憶體。
  • 複製演算法:需要將記憶體劃分為大小相等的兩塊,每次只使用其中一塊。需要回收時將使用的這塊記憶體中所有存活的物件(也是需要對物件進行判定的)複製到沒用的那塊記憶體上,然後將之前使用的那塊記憶體整塊清理,再改用複製過來的這塊記憶體。
  • 標記-整理演算法:與標記清除演算法類似,不同的是標記完成後不直接清理,而是先將存活的物件統一向一端移動,移動完成後直接清理存活物件區域以外的空間。
  • 分代收集演算法:依賴於上述演算法。主要是根據物件的生命週期將記憶體劃分為幾塊,每塊記憶體採取合適的收集演算法。一般來說,JVM把堆記憶體分為新生代和老年代。

上面簡單描述了各種演算法的基本思想。小白這裡梳理各種演算法的優缺點及適應場景如下:

標記-清除演算法標記和清除兩個過程效率都不太高,在死亡物件特別多的情況下尤為突出。另外收集完成後會造成記憶體碎片化嚴重,回收的空間不連續。這兩個特點決定了該演算法適合在物件存活週期特別長的情況下使用,因為這種情況下每次收集時死亡物件小,在清理時對特定空間的清理就會變少。
複製演算法:很明顯的缺點是浪費一半記憶體,但其簡單高效,且回收後記憶體連續的優點也很突出。該演算法中回收時是清理使用的記憶體半區,然後切換複製後的記憶體半區來使用,相比標記-清理演算法肯定實現簡單,執行高效。但是需要注意的是,在物件存活較多的情況下,對應的複製操作就會越多,效率就會越低。因此,複製演算法適合在物件存活週期較短的情況使用
標記-整理演算法:很好的彌補了標記-清理演算法的缺點,回收後空間連續,無記憶體碎片化問題。效率上小白感覺大多數情況下是比標記-清理演算法略微差一些的,這個沒有深入研究,只是推測,本身多了一個移動的步驟,如果效率也好的話,那標記-清除演算法就沒有必要存在了。也適用於物件存活週期特別長的情況
分代收集演算法:集百家之長,一般是首選堆記憶體被分為新生代和老年代新生代物件存活週期短,大都朝生夕死,採用複製演算法。HotSpot虛擬機器預設按8:1:1的比例將新生代分為Eden區域和兩塊一樣大的Survivor區域,每次使用Eden和一塊S區,回收時將存活的物件複製到另一塊S區,回收完成後再使用這塊S區和Eden區。這樣每次只會閒置10%的新生代空間,對於獲得了高效率的結果來說這個代價還可以接受。老年代一般存放存活週期長的物件,每次收集物件存活率高,只能使用標記-清除(整理)演算法。注意:新生代中,若收集時存活物件預留的那塊S區放不下時,會依賴老年代存放,具體的機制下面會提到。

2.2 回收的執行者-垃圾收集器

上面提到了HotSpot虛擬機器對堆記憶體的劃分以及收集演算法的選用,這裡簡單梳理下收集演算法在新生代和老年代具體實現,也就是各個區域的垃圾收集器。

新生代收集器:Serial、ParNew、Parallel Scavenge、G1
老年代收集器:Serial Old、CMS、Parallel Old、G1

搭配組合使用於整個堆記憶體的回收,可搭配的方式如圖:

再看JVM:垃圾回收那些事

各收集器的工作原理這裡不羅列了,感興趣的朋友看下書就知道了,小白只梳理各自的優缺點及適用場景:

  • Serial:新生代收集器、單執行緒,適用於單CPU單核環境,需設定合適停頓時間
  • ParNew:新生代收集器、多執行緒,預設開啟收集執行緒數和CPU數目相同,適用於多核多CPU場景
  • Parallel Scavenge:新生代收集器、多執行緒、與使用者執行緒並行、可設定自適應調節(JVM自調優)、關注點是吞吐量(使用者程式執行時間與其加上垃圾回收時間和的比值)、適合在後臺運算,不適合存在太多互動的場景
  • Serial Old:老年代收集器、單執行緒,搭配合適的新生代收集器以及CMS收集器發生問題時的備案
  • Parallel Old:老年代收集器、多執行緒、適合注重推圖量以及CPU資源敏感的場合
  • CMS:老年代收集器、併發收集、低停頓,無法處理浮動垃圾、使用Serial Old作備案,基於標記-清除演算法,適用網際網路站或者B/S系統的服務端
  • G1:JDK1.7及以後可用,並行併發、可獨立進行分代收集、空間整合、可預測的低停頓,主要用來取代CMS

需要注意的一點是,上面提到的並行是指GC和應用程式執行緒並行,併發則指的是多執行緒回收。

2.3 GC執行緒工作機制(HotSpot)

HotSpot虛擬機器中GC執行緒在開始工作時是需要掛起應用程式的所有執行緒以保證回收操作的準確性的,準確說是保證選擇的GC Roots物件和程式當前上下文的一致性。小白畫了流程圖如下,來更形象地描述GC如何停止工作執行緒。

HotSpot虛擬機器GC執行緒工作機制
圖裡引入了兩個概念,這裡簡單說一下。安全點是在程式執行的特定位置,記錄了該位置的指令執行時記憶體中可作為GC Roots的引用的記憶體地址,方便虛擬機器直接去具體位置列舉根節點,而不是在整個記憶體中查詢。設定安全點也是避免虛擬機器為每條指令都記錄引用資訊浪費太多空間。安全域是指該區域內的指令不會導致當前記憶體中的引用發生變化,也就是說執行緒在安全域執行不會影響GC的準確性。安全域解決了處於某種狀態(比如Sleep或是Blocked)執行緒無法響應JVM中斷要求的問題。

三、垃圾何時回收

瞭解了什麼是垃圾以及如何回收,接下來就簡單聊聊虛擬機器什麼時候會進行垃圾回收(不會去詳細說明記憶體如何分配以及各種虛擬機器引數)。首先需要明確的是,進行垃圾回收會發生STW問題,無法避免,所謂的並行也只是整體看上去是並行的,那麼就意味著頻繁的垃圾回收會極為影響應用程式的效能,因此垃圾的回收只能發生在必要的時候,也就是可用記憶體不足以為物件分配的時候。

HotSpot虛擬機器將堆記憶體劃分為新生代和老年代新生代又劃分為三塊,一塊較大的Eden空間兩塊較小的Survivor空間,預設比例為8:1:1。劃分的目的是因為HotSpot採用複製演算法來回收新生代,設定這個比例是為了充分利用記憶體空間,減少浪費。新生成的物件在Eden區分配(大物件除外,大物件直接進入老年代,大小的判別閾值可配置),當Eden區沒有足夠的空間進行分配時,虛擬機器將發起一次Minor GC。GC開始時,物件只會存在於Eden區和From Survivor區,To Survivor區是空的(作為保留區域)。GC進行時,Eden區中所有存活的物件都會被複制到To Survivor區,而在From Survivor區中,仍存活的物件會根據它們的年齡值決定去向,年齡值達到年齡閥值(預設為15,新生代中的物件每熬過一輪垃圾回收,年齡值就加1,GC分代年齡儲存在物件的header中)的物件會被移到老年代中,沒有達到閥值的物件會被複制到To Survivor區。然後清空Eden區和From Survivor區,新生代中存活的物件都在To Survivor區。接著, From Survivor區和To Survivor區會交換它們的角色,也就是新的To Survivor區就是上次GC清空的From Survivor區,新的From Survivor區就是上次GC的To Survivor區,總之,不管怎樣都會保證To Survivor區在一輪GC後是空的。GC時當To Survivor區沒有足夠的空間存放上一次新生代收集下來的存活物件時,需要依賴老年代進行分配擔保,將這些物件存放在老年代中。另外有個特殊情況是,在Minor GC後,如果S區有相同年齡的存活物件,且相同年齡的物件佔用空間超過了S區的50%,這些物件也會被提前放入老年代。

當有物件放進老年代而最終記憶體不足時,老年代才會進行Major GC,其經常伴隨至少一次的Minor GC。老年代的GC一般比新生代的GC慢10倍以上。因此一般來說要儘量減少虛擬機器進行老年代GC。

HotSpot提供的優化措施是分配擔保機制,可通過HandlePromotionFailure引數設定是否允許擔保失敗。一般在進行Minor GC前,此次GC後存活的物件有多少是無法預知的,最壞的情況就是所有物件都存活,那麼一塊Survivor區域是絕對放不下,這個時候就需要把存活的物件提前放入老年代。但是老年代也無法保證能放下啊,所以絕對安全的情況就是老年代的最大可用的連續空間(不確定)大於新生代所有物件總空間。分配擔保機制就是在非絕對安全的情況下,檢查老年代最大可用的連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於,擔保此次Minor GC安全(有風險),如果小於(擔保失敗)直接Full GC。另外,如果設定不允許擔保失敗(其實就是關閉擔保機制)就意味著每次新生代空間不足都會Full GC。

注意:Full GC究竟是哪裡的GC眾說紛紜,小白這裡認為其並不單單指老年代GC,而是一次整個堆記憶體及永久帶的GC。但是在去永久帶後,也就只是整個堆記憶體的GC了。

總結

JVM的垃圾回收一定要搞清楚的是回收什麼、如何回收、何時回收這三個問題。小白寫這篇文章的時候本來也是按這個思路去嘗試表達自己的理解的,沒想到會寫這麼多。只是寫的過程中考慮到一些東西的重要性就還是寫進來了,最後卻感覺質量太差,被書中的知識點佔了太多內容,希望各位朋友諒解,權當複習了。本篇文章主要是梳理《深入理解Java虛擬機器——JVM高階特性與最佳實踐》一書中垃圾回收章節的知識點,談談小白自己的理解,若有疑惑的地方歡迎留言探討。

相關文章