1. 程式人生 > >初步瞭解JVM第三篇(堆和GC回收演算法)

初步瞭解JVM第三篇(堆和GC回收演算法)

在《初步瞭解JVM第一篇》和《初步瞭解JVM第二篇》中,分別介紹了:

  • 類載入器:負責載入*.class檔案,將位元組碼內容載入到記憶體中。其中類載入器的型別有如下:執行引擎:負責解釋命令,提交給作業系統執行。
    • 啟動類載入器(Bootstrap)
    • 擴充套件類載入器(Extension)
    • 應用程式類載入器(AppClassLoader)
    • 使用者自定義載入器(User-Defined) 
  • 執行引擎:負責解釋命令,提交給作業系統執行。
  • 本地介面:目的是為了融合不同的程式語言提供給Java所用,但是企業中已經很少會用到了。
  • 本地方法棧:將本地介面的方法在本地方法棧中登記,在執行引擎執行的時候載入本地方法庫
  • PC暫存器:是執行緒私有的,記錄方法的執行順序,用以完成分支、迴圈、跳轉、異常處理、執行緒恢復等基礎功能。
  • 方法區:存放類的架構資訊,ClassLoader載入的class檔案內容存放在方法區中。
  • 棧:執行緒私有,用來管理Java程式的執行。

進行簡單的回顧後,接下來為大家介紹Java中的堆。

堆(Heap)

大家可會分不清棧和堆,其實可以簡單記住一句話:棧管執行,堆管儲存。堆是執行緒共享的,而棧是執行緒私有的。那麼什麼是堆呢?

在一個JVM例項中,堆記憶體只存在一個。對記憶體的大小是可以進行調節的,類載入器讀取了類檔案之後需要把類、方法、常變數放到堆記憶體中,儲存所有引用型別的真實資訊,以便執行器執行。

首先拋給一個大的概念給大家先,為大家介紹堆記憶體的三大部分(這裡我們講的以JDK8的版本為準,也就是將永久代變改為元空間):

  • 新生區:我們new出來的物件的存放地址,而新生區又分為三部分:
    • Eden(伊甸區)
    • Survivor 0 Space(倖存者0區)
    • Survivor 1 Space(倖存者1區)
  • 養老區:新生區的物件經過15次的GC回收(垃圾回收)之後存活下來的物件就放在這裡,養老區如果滿了也會進行GC回收,只不過發生的頻率小於新生區
  • 元空間:元空間我們上一篇已經講過了,主要是用來存放類的結構資訊,類似一個模板。

 

上以就是堆記憶體的大三部分:伊甸區、養老區、元空間。上圖是邏輯上的結構,但是在物理上只有新生區和養老區,而且我們需要區分新生代和養老代用的是JVM的記憶體,但是元空間用的是系統記憶體。如果看得有點懵,不要緊,先來我們來一個一個介紹,首先第一部分新生區。

新生區

新生區就是類的誕生、成長、消亡的區域。一個類在這裡產生、然後應用,最後被垃圾回收器回收,結束了的生命的過程釋放出記憶體。那麼我們來簡單說一下,一個類被new出來之後從開始到消亡的一個過程:

  • 1)假設有一個程式是一直不斷在new物件,那麼new出來的物件首先就是存放在新生區的伊甸區,(注意:一般new的物件是放在新生區的伊甸區的,大的物件會特殊處理)。
  • 2)伊甸區的記憶體也是有限,程式一直在不斷的new物件,終於!!!在某一個時刻,伊甸園的空間快沒有地方可以存放新的物件了。也就是達到伊甸區存放物件的閾值。這時候,注意!!!伊甸區就開始進行垃圾回收,也就是我們常說的輕GC,將大部分不再使用的物件Kill掉!!留下還在使用的物件。因為堆記憶體裡面的物件絕大多數都是臨時物件,所以一次垃圾回收會Kill掉90%以上的物件,能存活下來的數量非常少。
  • 3)存活下來的物件就從伊甸區移到了倖存者0區,注意倖存者0區還有一個別名就做From。
  • 4)雖然垃圾回收會Kill掉大部分的物件,但是我們還是不能排除有個別現象存在伊甸區和倖存者0區再一次滿了的情況,因為程式new的速度肯定是比Kill的速度快的,終於又在某一時刻!!!伊甸區又達到了一定的閾值,再次進行垃圾回收,這時候就會將伊甸區和倖存者0區(注意:遷移的物件包括倖存者0區)存活下來的物件遷移到倖存者1區(倖存者1區的另外一個別名為To)。
  • 5)一直如此反覆,等到倖存者1區也滿了,就將存活的物件移到養老區進行養老,能到養老區的一般都一些長期使用的物件。那養老區怎麼確定哪些才是長期使用的物件呢?在新生區中,一個物件經過每次垃圾回收之後倖存下來的,都會進行計數,經過了15次垃圾回收之後依然存在的,就會進入到養老區。

(注意:講到這裡,是大部分物件消亡了,但是還是有經過15次垃圾回收之後存活下來的物件進入了養老區)

養老區

在新生區中,我們已經描述了一個類從開始到消亡或者進入養老區的過程,要麼就是被kill了,要麼就是進入了養老區。進入養老區之後就可以舒舒服服的摸魚了嗎?你想得太簡單了,接下來看看,養老區又有怎麼樣的一番搏鬥呢:

  • 1)從新生區倖存下來的幸運兒來到了養老區養老,養老區就相當一個養老院,但是一個養老院也會滿員。這時候,沒辦法了,只能清出一部分老人,讓新的一批從新生區來的老人入住,這時候就發生了垃圾回收,也就是我們說的重GC。
  • 2)雖然在養老區也會發生垃圾回收機制,但是還是會有一天,這個養老院實在是騰不出空位了,即使是進行重GC也騰不出幾個空間,這時候沒辦法了!!!代表已經沒有記憶體了,玩不轉了,所以系統就會報錯,也就是我們常看到的OOM(“OutOfMemoryError”):對記憶體溢位。
  • 3)於是乎,程式就異常停止了,所有物件都消亡了,這個就是程式中一個物件從開始到消亡的整個過程。

堆的記憶體大小分配:

 注:

  • From就是上面說的倖存者0區的別名
  • To就是上面說的倖存者1區的別名

這個比例我們一定要記住,非常重要,這是在GC時選取何種演算法的一個依據之一,新生代跟老年代是1:2,而新生代中的三個分割槽中分別是8:1:1。

看完了堆記憶體的結構,接下來我們就要講講GC垃圾回收演算法了。在上面我們描述了一個物件從開始到結束的過程,中間會發生GC回收,其中:

  • 新生代:發生的GC叫做輕GC也叫MinorGC,所用的演算法叫做複製演算法。
  • 老年代:發生的GC叫做重GC也叫Full GC,所用的演算法叫做標記清除演算法和標記壓縮演算法

  這裡過個眼熟,下面我們在GC垃圾回收演算法的時候會講到。

垃圾回收演算法

在進行垃圾回收的時候,JVM需要根據不同的堆記憶體和結構去選取適合的演算法來提高垃圾回收的效率,而垃圾回收演算法主要有:

  • 引用計數法
  • 複製演算法
  • 標記清除演算法
  • 標記壓縮演算法

1)引用計數演算法

原理:給物件中每一個物件分配一個引用計數器,每當有地方引用該物件時,引用計數器的值加一,當引用失效時,引用計數器的值減一,不管什麼時候,只要引用計數器的值等於0了,說明該物件不可能再被使用了。

優點:

  • 實現原理簡單,而且判定效率很高。大部分情況下都是一個不錯的演算法。

缺點:

  • 每次對物件複製時均要維護引用計數器,且計數器本身也有一定的消耗。
  • 較難處理迴圈引用。

在JVM中一般不採用這種方式實現,所以就不展開來講了。

2)複製演算法(Copying)——新生代使用

在新生代中的GC,用的主要演算法就是複製演算法,而且發生GC的過程中From區和To區會發生一次交換(請記住這句話)。在堆的記憶體分配圖中JVM把年輕代分為了三部分:1個Eden區和2個Survivor區(別名叫From和To)。預設比例為8:1:1,一般情況下,新建立的物件都會被分配到Eden區(一些大物件特殊處理),當Eden區進行了GC還存留下來的就會被移到Survivor區。物件在Survivor區每經過一輪GC存留下來年齡就會加1。直到它存活到了一定歲數的是時候就會被移到養老區。由於新生區中的絕大部分物件都是臨時物件,不會存活太久,所以經過每一輪的GC之後存活下來的對像都不多,所以新生區所用的GC演算法就是複製演算法。

複製演算法原理:

首先先給大家介紹一個名詞叫做根集合(GC Root):

  • 通過System Class Loader或者Boot Class Loader載入的class物件,通過自定義類載入器載入的class不一定是GC Root
  • 處於啟用狀態的執行緒
  • 棧中的物件
  • JNI棧中的物件
  • JNI中的全域性物件
  • 正在被用於同步的各種鎖物件
  • JVM自身持有的物件,比如系統類載入器等

有了上面的瞭解我們就可以來學學複製演算法:

  • 複製演算法從根集合(GC Root)開始,從From區中找到經過GC存活下來的物件(注意:雖然說是From區,但是這裡的From區是包括了伊甸區和倖存者1區(別名From),所以大家不要認為From區就是單單包括From區而已)。拷貝到To中;
  • 上面我們說過From和To會發生一次交換就是發生在這裡,From將倖存下來的物件拷貝到To之後,這時From區就沒有物件,空出來了,而To現在不是空的,存放了From的倖存的物件(預設狀態是From有物件,To是空的)。這時候From和To就會發生身份的互換,下次記憶體分配從To開始。也就是說發生一次GC之後From就會變成To,To就會變成From(當誰是空的,誰就是To)
  • 一直這樣反覆GC,一直再一次發生GC的時候,From存活的物件拷貝到To時,To會被填滿,這時候就會把這些物件(滿足年齡為15的物件,這個值可以通過-XX:MaxTenuringThreshold來設定,預設是15)移動到養老區。

  下面我們用一張圖來描述一下複製演算法發生的過程:

我們一直都在反覆強調,Eden區的物件存活率是比較低的,所以一般就是拿兩塊10%的記憶體作為空閒區(To)和活動區(From),拿80%的記憶體來儲存新建的物件。一但GC過後,就會將這10%的活動區和80%的Eden區存留下來的物件移到空閒區(To)中。然後之前的記憶體就得到了釋放,依次類推。

複製演算法的缺點:

  • 複製的時候需要耗費一般的記憶體,記憶體消耗大(但是效率的快的,而且新生區的存活效率低,並不需要複製太多的物件,所以新生區用這種演算法效率是比我們下面要講的演算法效率高的)。
  • 如果物件的存活率很高,需要複製的物件太多,這時候效率就大大降低了。

複製演算法的優點:

  • 沒有標記和清除的過程,效率高。
  • 因為是直接對物件進行復制的,所以不會產生記憶體碎片。

3)標記清除演算法(Mark-Sweep)

老年代主要由標記清除演算法和標記壓縮演算法混合使用。

標記演算法的步驟從名字其實就可以看出來是怎麼回事了:

  • 標記需要清除的物件
  • 清除標記的物件

在複製演算法中我們就說了它的缺點是浪費空間,所以為了解決這個問題,就不將物件進行復制了,因為複製一份需要同等大小的記憶體。標記清除演算法採用標記的方式,將要清除的物件進行標記然後直接清除掉,這樣就就大大節省了空間了。同上,繼續來通過一張圖來理解:

上圖就是標記清除演算法的過程,從過程中可以看出一些問題:

由於回收的物件是進行標記後直接刪除的,所以就像上圖回收後所展示的一樣,記憶體空間是不連續的,也就是會有記憶體碎片的產生。第二個問題是複製演算法是直接複製的,但是標記清除演算法是需要掃描兩次,耗時嚴重。

標記清除演算法的優點:

  • 對需要回收的物件進行標記清除,不需要額外的空間。

標記清除演算法的缺點:

  • 效率低,在進行GC時,需要停止整個程式。
  • 清理出來的記憶體空間是不連續的,存在記憶體碎片。由於空間不連續,查詢的效率也會降低

但是由於養老區存活下來的物件會比新生區的物件多,所以用標記清除是比複製演算法好的。

4)標記壓縮演算法(Mark-Compact)

理解了標記清除演算法後,其實這一個演算法就比較簡單理解了。就是多了一步整理的階段,清除記憶體碎片使空間變得連續。過程如下圖:

標記壓縮演算法的優點:

  • 可以看到,標記的存活物件將會被整理,按照記憶體地址依次排列,而未被標記的記憶體會被清理掉。如此一來,當我們需要給新物件分配記憶體時,JVM只需要持有一個記憶體的起始地址即可,這比維護一個空閒列表顯然少了許多開銷。 
  • 標記/整理演算法不僅可以彌補標記清除演算法當中,記憶體區域分散的缺點,也消除了複製演算法當中,記憶體減半的高額代價。

標記壓縮演算法的缺點:

  • 雖然這個演算法解決了上兩個演算法的一些缺點,但是這個演算法卻是耗時最長的。從效率來看是低於標記清除演算法和複製演算法的。

以上就是GC的四大演算法,當然出了這四大演算法還有標記清除壓縮演算法(Mark-Sweep-Compact),這個也很好理解就是在整理階段不再是GC一次就整理一次,而是每隔一段時間整理一次,減少移動物件的成本。

分代收集演算法:

當有人問你哪個演算法是最好的時候,你的回答應該是:無,沒有最好的演算法,只有最合適的演算法。使用哪個演算法應該看GC發生在什麼地方:

  • 新生代:複製演算法
    • 原因:存活率低,需要複製的物件很少,所需要用到的空間不是很多。另外一方面,新生代發生的頻率是非常高的,而複製演算法的效率在新生代是最高的,所以新生代用複製演算法是最合適的。
  • 老年代:標記清除和標記壓縮演算法混合使用
    • 原因:存在大量存活率高的對像,複製演算法明顯變得不合適。一般是由標記清除或者是標記清除與標記整理的混合實現。
    • Mark階段的開銷與存活對像的數量成正比,這點上說來,對於老年代,標記清除或者標記整理有一些不符,但可以通過多執行緒利用,對併發、並行的形式提高標記效率。
    • Sweep階段的開銷與所管理區域的大小成正相關,但是清除“就地處決”的特點,回收的過程沒有移動物件。使其相對其它有移動對像步驟的回收演算法,仍然是效率最好的。但是需要解決記憶體碎片問題。
    • Compact階段的開銷與存活對像的資料成開比,如上一條所描述,對於大量對像的移動是很大開銷的,做為老年代的第一選擇並不合適。
    • 基於上面的考慮,老年代一般是由標記清除或者是標記清除與標記整理的混合實現。以hotspot中的CMS回收器為例,CMS是基於Mark-Sweep實現的,對於對像的回收效率很高,而對於碎片問題,CMS採用基於Mark-Compact演算法的Serial Old回收器做為補償措施:當記憶體回收不佳(碎片導致的Concurrent Mode Failure時),將採用Serial Old執行Full GC以達到對老年代記憶體的整理。

終於寫完了,以上便是本人對JVM的理解,如有不足歡迎提出,謝謝!!!