JVM(2)--一文讀懂垃圾回收

帥地發表於2018-08-12

與其他語言相比,例如c/c++,我們都知道,java虛擬機器對於程式中產生的垃圾,虛擬機器是會自動幫我們進行清除管理的,而像c/c++這些語言平臺則需要程式設計師自己手動對記憶體進行釋放。
雖然這種自動幫我們回收垃圾的策略少了一定的靈活性,但卻讓程式碼編寫者省去了很多工作,同時也提高了很多安全性。(因為像C/C++假如你建立了大量的物件,但卻由於自己的疏忽忘了將他們進行釋放,可能會造成記憶體溢位)。

何為垃圾?

剛才說了,虛擬機器會自動幫助我們進行垃圾的清除,那什麼樣的物件我們才可以稱為是垃圾物件呢?
假如你建立了一個物件

Man m = new Man();

你用一個變數指向了這個物件,顯然對於這個物件,你可以用變數m對這個物件進行利用,但過了一段時間,你執行了

m = null;

並且也並沒有新的變數來指向剛才建立的物件。此時對於這個沒有任何變數指向的物件,你覺得它還有用處嗎?
顯然,對於這種沒有被變數指向的物件,它是一點卵用也沒有的,它只能在隨風漂流。
因此,對於這樣的物件,我們就可以把它稱為垃圾了,它早晚會被垃圾回收器給幹掉。

怎麼知道它已經是垃圾物件了?

假如程式碼是你自己編寫的,你可能知道這個物件啥時候應該被拋棄,你可以隨時讓它成為垃圾物件。
但是,你畢竟是你,虛擬機器則沒那麼智慧。那虛擬機器是如何知道的呢?
上面已經說了,沒有變數引用這個物件時,它就是垃圾物件了,基於這個原理,我們可以這樣做啊:
我們可以為這個物件設定一個計數器,初始值為0,假如有一個變數指向它,那麼計數器就加1,如果這個變數不在指向它了,計數器就減1。那麼我們就可以判斷,如果這個計數器為0的話,那它就是垃圾物件了,否則就是有用的物件。
對於這種方法,我們稱之為引用計數法

好吧,我們先來誇一誇引用計數法這種方法:
1.實現簡單。
2.效率高(一個if語句就能解決的問題想不高效都難)。
不好意思,接下來得說說它那個致命的缺點
實際上,對於這種引用計數的方法,假如它遇到物件互相引用的話,是很難解決的。
先看一段程式碼:

Man m1 = new Man();
Man m2 = new Man();
//互相引用
m1.instance = m2;//假設Man有instance這個屬性
m2.instance = m1;
m1 = null;
m2 = null;
System.gc();//按道理物件應該被回收

這段程式碼m1和m2都指向null了,按道理兩個物件已經是無用物件,應該被回收,但是,兩個物件之間彼此有一個instance的屬性互相牽引的對方,導致兩個物件並沒有被回收。
這個缺點夠致命吧?
所以,虛擬機器並沒有採用這種引用計數的方法。

可達性分析

除了這種方法,我們還有其他的方法嗎?
答案是有的,必須得有啊。這種方法就是傳說中的可達性分析,(我靠,聽名字是真的高階啊)。它的工作原理是這樣的:
在程式開始時,會建立一個引用根節點(GC Roots),並構建一個引用圖。當需要判斷誰是垃圾時,我們可以從這個根節點進行遍歷,如果沒有被遍歷到的節點則是垃圾物件,否則就是有用物件。如下圖:


這個方法可以解決迴圈相互引用的問題,但是這個方法並沒有引用計數法高效,畢竟要遍歷圖啊。
總結下判斷是否為垃圾物件的演算法:
1.引用計數法。
2.可達性分析。

何時進行垃圾回收

可能有人會覺得這個問題很奇怪,覺得看到垃圾就回收不是很好。對於這個我只能說:
1.看到房間有一點垃圾你會馬上掃?還是等到某個時間點或者當垃圾積累到一定的數量再掃?
2.虛擬機器可沒那麼智慧可以馬上識別這個物件是垃圾物件,它還得遍歷所有物件才能知道有哪些是垃圾物件。
所以說,你總不能幾秒(我們假設幾秒是賊短的時間)就讓虛擬機器遍歷一下所有物件吧?

這裡先說明一下,當垃圾回收器在進行垃圾回收的時候,為了保證垃圾回收不受干擾,是會暫停所有執行緒的,此時程式無法對外部的請求進行響應。(因為你想啊,當你在可達性分析的時候,那些引用關係還在不斷著變化,那不很難受)。
而且頻繁的垃圾回收,對於有一些程式,是很影響使用者體驗的,例如你在玩遊戲,系統動不動就停頓一下,怕你是要把這遊戲給刪了。
所以說,垃圾回收是會等到記憶體被使用了一定的比例的時候,才會觸發垃圾回收。至於這個比例是多少,這可能就是人為規定的了。

怎麼回收?

當我們標記好了哪些是垃圾,想要進行回收的時候,該怎麼回收比較好呢?
可能有一些人就覺得奇怪,這還不簡單,看見它是垃圾,直接回收不就得了。
其實這也不無道理,簡單粗暴,直接回收。
是的,確實有這樣的演算法,看哪些是被我們標記的垃圾,看見了就直接回收。這種演算法我們稱之為標記—清除演算法
標記-清除演算法工作原理:就是先標記出所有需要回收的物件,然後在統一回收所有被標記過的物件。
不過,那些人你可別得意啊,因為這種方法雖然簡單暴力,但它有個致命的缺點就是:
標記清除過後,會產生大量的不連續記憶體碎片,如果不連續的碎片過多的話,,可能會導致有一些大的物件存不進去。這樣,會導致下面兩個問題:

1.有些記憶體浪費了。
2.物件存不進去,會又一次觸發垃圾回收。

複製演算法

為了解決這種問題,另外一種演算法出現了—-複製演算法。就是說,它會將可用的記憶體按容量劃分成兩塊。然後每次只使用其中的一塊,當這一塊快用完的時候,就會觸發垃圾回收,它會把還存活的物件全部複製到另外一塊記憶體中去,然後把這塊記憶體全部清理了。
這樣,就不會出現碎片問題了。
居然幫我們解決了我們必須誇一下它:不僅幫我們解決了問題,而且實現上也簡單、執行也高效。
但是(凡事都有個但是的),它也是有缺點的,缺點很明顯,發現了沒有。假如每次存活的物件都很少很少,那另外一塊記憶體不是幾乎沒有用到?所以說,這種方法有可能導致另外一半記憶體幾乎沒用了。記憶體那麼寶貴,這可是很嚴重的問題。

優化策略:可以告訴你,有研究顯示,其實有98%的物件都是朝生夕死的,也就是說,每次存活的物件確實很少很少。既然我們都知道存活的物件很少很少了,那我們幹嘛還1:1的比例來分配?所以說,HotShot虛擬機器是預設按8:1的比例來分配的。這樣,就不會出現很多記憶體沒用到的問題了。
可能有人會說,萬一佔比為1/9的記憶體不夠用了怎麼辦?不就沒地方存那些活的物件?實際上,當記憶體不夠用時,可以向其他地方借些記憶體來使用,例如老年代裡的記憶體。

這裡說明一下新生代和老年代:說白了,新生代就是剛剛建立不久的物件,而老年代是已經活了挺久的物件。也就是說,有一些物件是確實活的比較久的,對於這種物件,我們另外給它分配記憶體來養老,而且垃圾回收時,我們不用每次都來這裡查詢有沒垃圾物件,因為這些物件是垃圾的機率會比較小。

下面在簡單介紹另外兩種演算法:
1.標記-整理演算法:這種演算法和標記-清除演算法類似,不過它把垃圾清除了之後,會讓存活的物件往一個方向靠攏,以此來整理碎片。
2.分代收集演算法:所謂分代就是把物件分成類似上面說的老年代和新生代,在新手代一般每次垃圾回收時死的物件一般都會比較多,而老年代會比較少,基於這種關係,我們就可以採取不同的演算法來針對了。

總結下垃圾回收的幾種演算法:
1.標記-清除演算法。
2.複製演算法。
3.標記-整理演算法。
4.分代收集演算法。

最後給大家幾種垃圾回收器

對於垃圾的回收,你是想一邊執行程式其他程式碼一邊進行垃圾回收?還是想把垃圾全收好再來執行程式的其他程式碼?雖然說最終使用cpu的時間是一樣,但兩種方式還是有區別的。
下面簡單介紹幾種垃圾回收器,看看他們都使用哪種方。
(1).Serial收集器
serial(序列),看這個英文單詞就知道這是一個單執行緒收集器。也就是說,它在進行垃圾回收時,必須暫停其他所有執行緒。顯然,有時垃圾回收停頓的比較久的話,這對於使用者來說是很難受的。

(2).ParNew
這個收集器和Serial很類似,進行垃圾回收的時候,也是得暫停其他所有執行緒,不過,它可以多條執行緒工作進行垃圾回收。


(3).Parallel Scavenge收集器
parallel,並行的意思。也是可以多執行緒進行垃圾回收處理,但是它與ParNew不同。它會嚴格控制垃圾回收的時間與執行其他程式碼的時間之間的比例。我們來看一個名詞:吞吐量
吞吐量 = 執行使用者程式碼時間 / (執行使用者程式碼時間 + 垃圾收集時間)。
也就是說,Parallet Scavenge收集器會嚴格控制吞吐量,至於這個吞吐量是多少,這個可以人為設定。

下面兩個收集器重點介紹下

(4).CMS(Concurrent Mark Sweep)收集器

CMS收集器是基於“標記-清除”演算法實現的,它的運作過程相對於前面幾種收集器來說要更復雜一些,整個過程分為4個步驟,包括:
1.初始標記(CMS initial mark)
2.併發標記(CMS concurrent mark)
3.重新標記(CMS remark)
4.併發清除(CMS concurrent sweep)
其中初始標記、重新標記這兩個步驟仍然需要暫停其他執行緒。但另外兩個步驟可以和其他執行緒併發執行。初始標記僅僅只是標記一下GCRoots能直接關聯到的物件,速度很快,併發標記階段就是進行GC Roots Tracing的過程 (說白了就是把整個圖都遍歷了,找出沒有的物件)
而重新標記階段則是為了修正併發標記期間,因使用者程式繼續運作而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段稍長一些,但遠比並發標記的時間短。
由於整個過程中耗時最長的併發標記和併發清除過程中,收集器執行緒都可以與使用者執行緒一起工作,所以總體上來說,CMS收集器的記憶體回收過程幾乎是與與使用者執行緒一起併發地執行。

(5).G1收集器
這個估計是最牛的收集器了。該收集器具有如下特點:
1.並行與併發:G1能充分利用現代計算器多CPU,多核的硬體優勢,可以使用併發或並行的方式來縮短讓其他執行緒暫停的優勢。
2.分代收集:就是類似像分出新生代和老年代那樣處理。
3.空間整合:採用了複製演算法+標記-整合演算法的特點來回收垃圾。就是整體採用標記-整理演算法,區域性採用複製演算法
4.可預測停頓:這個就牛了,就是說,它能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不超過N毫秒。

它的執行過程大體如下:
1.初始標記。
2.併發標記。
3.最終標記。
4.篩選回收。

這個流程和CMS很相似,它也是在初始標記最終標記需要暫停其他執行緒,但其他兩個過程就可以和其他執行緒併發執行。
剛才我們說了G1收集器哪些優點,例如可預測停頓,這也使得篩選回收,是可以預測停頓垃圾回收的時間的,也就是說,停頓的時間是使用者自己可以控制的,這也使得一般情況下,在篩選回收的時候,我們會暫停其他執行緒的執行,把所有時間都用到篩選回收上。

本次講解到這裡。

相關文章