深入JVM之 垃圾收集算法、垃圾收集器

一. 簡述

前文列舉了JVM自Java誕生起,多年來的大概發展以及內存佈局,這次我們就來聊一聊JVM中常見的垃圾收集算法以及這些歷史與現在的垃圾回收器。
深入JVM之JVM發展史、內存佈局

二. 垃圾收集算法

要了解垃圾收集算法,還是要建立在分代理論的基礎上才更容易明白它們的存在價值。
新生代(包括Survivor)、老年代、永久代是堆的主要組成部分,這是我們在之前就已經瞭解到的了。那麼我們的實例對象又是如何在這之中分配的呢?

垃圾收集中的三個代

  1. 新生代。
    當我們使用new關鍵字或者其他方式實例化一個對象的時候,這個對象大部分情況下一開始都是被分配到新生代,當該對象經歷過多次新生代的GC還沒有被回收,就會被移動到老年代。
    當實例化的新對象在新生代放不下,或者這個對象所佔內存大於我們設置的PretenureSizeThreshold參數時,都會被直接放入老年代(在Java8默認的G1收集器中,大對象會直接放到‘大對象區’)。
    更具體的說,新生代又分爲Eden區、兩個Survivor區。新對象都是先來到Eden區,若Eden區放不下新對象則會觸發一次GC,將當前Eden區活下來的對象放置一個Survivor區中(兩個Survivor區的作用見下方標記-複製算法),在Survivor區又活過了多次GC,被移動到老年代。
    上述GC指的是隻發生在新生代的Minor GC。而對象進入老年代的觸發條件也不只是“年齡大”這一種,其他還有:經過Minor GC之後仍放不下的對象、大於等於某一年齡的對象超過了Survivor區的一半,這些大於等於的會進入老年代。

  2. 老年代。
    上面已經介紹了新生代進入老年代、大對象直接進入老年代的方式,那麼想必我們都會有一個問題:新生代的對象年齡大了會進入到老年代,那麼老年代的對象不會很快被填滿嗎?還是到一定年齡就死亡了?又或是到一定年齡就進入所謂的永久代長生不死了?
    答案:放置老年代被填滿確實是一個很重要的問題,一旦這裏被填滿且無垃圾可回收,就會導致OOM(Out Of Memory)異常,所以老年代是重點調優部位,同時老年代也有它的垃圾回收方式,一般被稱爲Major GC(也有說法是Full GC==Major GC)。另外,老年代的對象除了被回收之外,另外一種方式只有繼續存在於老年代,想要進入永久代?我們作爲凡人,不要想太多了,稍後介紹永久代。
    除了注意代碼上的bug,不要產生內存泄漏(無用的對象因爲引用被其他對象持有而導致該對象無法被回收)之外;還要注意不要讓一些“朝生夕滅”的大對象直接進入老年代,比如文件緩存,這樣本該用完就回收的對象很容易在佔滿老年代,頻繁引發Full GC。
    頻繁引發Full GC會有什麼壞處?簡單來首,Full GC會導致整個JVM會有一個相對而言較長的Stop The World時間(稍後在標記-清除算法中再做介紹),也就是停止jvm工作線程的時間。JVM頻繁的長時間罷工,C端產品是無法接受的。

  3. 永久代。
    永久代聽上去好像是和新生代、老年代是一夥的,實際上並不是。永久代也存在於堆內存中,但是它被用來實現方法區,也就如上篇文章提到的,它只會存儲方法區該存有的東西:class文件解析之後的class信息,常量池等(Class的信息都存在於方法區,但是在堆內存中也會有一個java.lang.Class對象作爲該class的數據訪問入口)
    永久代,即方法區存儲的主要數據是class信息,自然可能造成方法區OOM的情況就是 類加載 過多,應該考慮是自己的方法區配置太小還是自定義類加載器的使用有問題,又或是spring aop產生的代理對象太多。永久代也存在GC,在Full GC時觸發
    == 方法區在JDK8改名元空間,且不再使用永久代實現,而是挪動到了直接內存(宿主機的內存)中。這樣元空間的大小便不會守永久代、堆的限制,而是隨着宿主機硬件資源而動態調整。 ==

垃圾收集的三種算法

上面我們介紹了堆內存的分代論,那麼這之中反覆提到的GC究竟是如何工作的?對象什麼情況下被判定爲可以回收?回收的方式又是什麼?
引用計數法是最基礎的判斷對象是否可回收的算法,它會給對象一個計數器,每當被其他對象引用則加一,引用它的對象消亡一個則減一,當計數器爲0該對象就可以被回收了。
另一種則是當前JVM中一直被使用的判斷算法——GC roots:有一組GC roots元素,如果一個對象可以通過這些GC roots元素直接或間接被訪問到,則該元素就還有用,反之則可以回收。GC roots顯然是當前程序正在使用的這些對象,具體如何明確這個定義,大家可以自行百度。聊瞭如何判斷對象,下面來聊聊如何回收對象。

  1. 標記-清除算法。
    當一個對象被認定可回收,那麼該對象就會被標記,當所有對象都被標記一遍之後,就開始清除工作,將所有被標記的對象清除掉。
    說起來簡單,但是我們需要考慮一個問題:如何標記-清除工作是與用戶線程併發的,那麼必然會有髒數據產生,即該回收的未被標記或不該回收的反被標記。爲了防止這類問題,最簡單的方式就是STW:暫時停止所有的用戶線程,標記-清除結束之後再開始用戶線程。
  2. 標記-複製算法。
    標記-清除算法還有一個問題:被清除的對象的大小、位置在內存中都是隨機的,也就是說GC之後會導致內存空間變得十分零散。這樣最直接的問題之一就是需要分配一個大對象的時,內存空間總和是足夠的,但是沒有一篇碎片空間足以容納這個大對象,就會導致內存溢出。
    爲了解決這個問題,提出了標記-複製算法。該算法會在GC之後將活下來的對象全部移動到另一片空白空間,反過來另一片空間被用完的時候就挪動會這片空白的空間。兩個Survivor區就是該算法的應用,開始Eden和一個Survivor正常使用,另一個Survivor空着,當Eden需要GC時,他們的對象全部移動到空白的Survivor。接下來兩個Survivor交換職責,空着的空着,有對象的正常使用直到下一次GC。因爲新生代的對象往往生命週期短,一次GC會產生大量的碎片空間,所以應用這種算法可以解決碎片空間問題
  3. 標記-整理。
    對於老年代來說,標記-複製卻不那麼適用了,因爲老年代本身都是一些穩定、老、大的對象,一次GC可能不會有很多對象被回收。但是碎片空間也是存在的,當老年代使用一段時間後也可能變成一個千瘡百孔的蜂窩。
    爲了解決這個問題,標記-整理算法登場,它和標記-清除算法的區別僅僅是在GC之後,對當前空間的對象進行內存位置的重整。
    JVM老年代一直都是使用標記-清除算法的,但是當老年代產生過多碎片空間的時候也會切換使用標記-整理算法。

三.垃圾收集器

垃圾收集器(如上面提到的G1收集器)顧名思義,是JVM中專門標記垃圾、處理垃圾的部分,而收集器使用的收集方法也都是以上述幾種垃圾回收算法爲基礎的。
*收集器主要進行如何具體實行垃圾回收算法的事情,它們有專門處理新生代的、也有專門處理老年代的、更有當前兩代通用的。傳統的及目前常用的垃圾收集器有:Serials收集器、Serials Old收集器、ParNew收集器、Parallel Scavenge收集器、Parallel Old收集器、CMS收集器、G1收集器。

  1. Serials收集器
    串行收集器,最古老的新生代收集器,只能串行,即不能與用戶線程(我們的程序線程)併發且自己只能一條線程處理垃圾。
    優點:單CPU單核情況下,使用Serials沒有線程切換的開銷。
  2. Serials Old收集器
    同上,Serials Old是單線程的專門處理老年代的垃圾收集器。Serials Old採用標記-整理回收算法。
  3. ParNew收集器
    ParNew是Serials的並行版本,可以進行多線程回收,用於新生代。
  4. Parallel Scavenge收集器
    Parallel Scavenge收集器是一個併發的收集器。和ParNew的並行不同,並行只是指垃圾回收可以都線程執行,用戶線程還是要STW,而Parallel則可以和用戶線程併發執行。
  5. CMS收集器
    CMS(Concurrent Mark Sweep),是G1收集器之前的主流老年代收集器,它的STW時間短,常常與ParNew收集器合作工作。(不和parallel配合是因爲設計上的不匹配。)
    CMS一次GC主要分爲四個階段:
    初始標記:標記GC ROOTS直接關聯的對象。
    併發標記:併發標記其他對象。
    重新標記:標記在併發標記期間,用戶新產生的引用變動。
    併發清除:併發清除垃圾對象。
    主要耗時的是併發標記和併發清除階段,但是這兩個階段可以與用戶線程併發。
  6. G1收集器
    G1收集器是Java8中默認的新生代老年代通喫的收集器。通喫最主要的原因是它變更了之前的內存佈局,將整個內存切割成許多小空間(Region),每個Region可以在一個GC週期內動態用做新生代/老年代/大對象區。
    G1收集器對我們來說最大的好處是 ‘可控GC的最大暫停時間’,也就是說我們可以指定一次GC的最大時間,垃圾收集器自己計算最高收益的Region進行回收。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章