Java——GC(垃圾回收)

gary-liu發表於2016-10-05

垃圾回收機制的意義

C++程式設計師非常頭疼的一個問題就是記憶體管理,而垃圾回收機制使得Java程式設計師不用關心記憶體動態分配和垃圾回收的問題,交由JVM去處理。由於有個垃圾回收機制,Java中的物件不再有“作用域”的概念,只有物件的引用才有“作用域”。垃圾回收可以有效的防止記憶體洩露,有效的使用空閒的記憶體。記憶體洩露是指該記憶體空間使用完畢之後未回收,在不涉及複雜資料結構的一般情況下,Java 的記憶體洩露表現為一個記憶體物件的生命週期超出了程式需要它的時間長度,我們有時也將其稱為“物件遊離”。

存在記憶體洩漏的可能原因:

  1. 靜態集合類像HashMap、Vector等的使用最容易出現記憶體洩露,這些靜態變數的生命週期和應用程式一致,所有的物件 Object 也不能被釋放,因為他們也將一直被Vector等應用著。

  2. 各種連線,資料庫連線,網路連線,IO連線等沒有顯示呼叫close關閉,不被GC回收導致記憶體洩露。

  3. 監聽器的使用,在釋放物件的同時沒有相應刪除監聽器的時候也可能導致記憶體洩露。

記憶體溢位和記憶體洩漏:

記憶體溢位(out of memory): 是指程式在申請記憶體時,沒有足夠的記憶體空間供其使用,出現out of memory;

記憶體洩露 (memory leak): 是指程式在申請記憶體後,無法釋放已申請的記憶體空間,一次記憶體洩露危害可以忽略,但記憶體洩露堆積後果很嚴重,無論多少記憶體,遲早會被佔光。memory leak會最終會導致out of memory!

哪些物件會被GC

可達性分析法

一個物件在沒有任何強引用指向他或該物件通過根節點不可達時需要被垃圾回收器回收。不過要注意的是被判定為不可達的物件不一定就會成為可回收物件,被判定為不可達的物件要成為可回收物件必須至少經歷兩次標記過程,如果在這兩次標記過程中仍然沒有逃脫成為可回收物件的可能性,則基本上就真的成為可回收物件了。

當一個物件通過一系列根物件(比如靜態屬性引用的常量)都不可達時就會被回收。簡而言之,當一個物件的所有引用都為null。迴圈依賴不算做引用,如果物件A有一個指向物件B的引用,物件B也有一個指向物件A的引用,除此之外,它們沒有其他引用,那麼物件A和物件B都、需要被回收。

java中可作為 GC Root 的物件有:

  1. 虛擬機器棧中引用的物件(本地變數表)
      
  2. 方法區中靜態屬性引用的物件
      
  3. 方法區中常量引用的物件
      
  4. 本地方法棧中引用的物件(Native物件)

堆記憶體的劃分

Java 中物件都在堆上建立,為了GC,堆記憶體分為三個部分,也可以說三代,分別稱為新生代,老年代和永久代。

新生代(Young generation)

其中新生代又進一步分為Eden區,Survivor 1區和Survivor 2區(比例一般8:1:1)。新建立的物件會分配在Eden區,當該區滿了後,第一次Minor GC將存活的物件複製到一個Survivor區,在經歷下一次Minor GC後,將Eden和已使用的Survivor區中存活的物件複製到另一個Survivor 區,並且將這些物件的年齡加1,以後物件在 Survivor 區每熬過一次 Minor GC,就將物件的年齡 + 1,當物件的年齡達到某個值時 ( 預設是 15 歲,可以通過引數 -XX:MaxTenuringThreshold 來設定 ),這些物件就會成為老年代。需要注意的是,一些大物件(大物件是指需要大量連續儲存空間的物件,比如長字串或陣列)可能會直接存放到老年代。

老年代(Tenured / Old Generation)

老年代記憶體比新生代也大很多(大概比例是2:1),當老年代記憶體滿時觸發 Major GC 即 Full GC,Full GC 發生頻率比較低,老年代物件存活時間比較長,存活率標記高。

永久代(Perm Area)

永久代一般用來儲存類的元資訊、靜態檔案,如Java類、常量、方法描述等。對永久代的回收主要回收兩部分內容:廢棄常量和無用的類。

Metaspace 元空間

這裡特別注意java8 中已經沒有持久代,其實,移除永久代的工作從JDK1.7就開始了。JDK1.7中,儲存在永久代的部分資料就已經轉移到了Java Heap或者是 Native Heap。但永久代仍存在於JDK1.7中,並沒完全移除,譬如符號引用(Symbols)轉移到了native heap;字面量(interned strings)轉移到了java heap;類的靜態變數(class statics)轉移到了java heap。

元空間的本質和永久代類似,都是對JVM規範中方法區的實現。不過元空間與永久代之間最大的區別在於:元空間並不在虛擬機器中,而是使用本地記憶體。因此,預設情況下,元空間的大小僅受本地記憶體限制,但可以通過以下引數來指定元空間的大小:
  -XX:MetaspaceSize,初始空間大小,達到該值就會觸發垃圾收集進行型別解除安裝,同時GC會對該值進行調整:如果釋放了大量的空間,就適當降低該值;如果釋放了很少的空間,那麼在不超過MaxMetaspaceSize時,適當提高該值。
  -XX:MaxMetaspaceSize,最大空間,預設是沒有限制的。
  除了上面兩個指定大小的選項以外,還有兩個與 GC 相關的屬性:
  -XX:MinMetaspaceFreeRatio,在GC之後,最小的Metaspace剩餘空間容量的百分比,減少為分配空間所導致的垃圾收集
  -XX:MaxMetaspaceFreeRatio,在GC之後,最大的Metaspace剩餘空間容量的百分比,減少為釋放空間所導致的垃圾收集

JDK 8 中永久代向元空間的轉換,為什麼要做這個轉換?
  1、字串存在永久代中,容易出現效能問題和記憶體溢位。
 2、類及方法的資訊等比較難確定其大小,因此對於永久代的大小指定比較困難,太小容易出現永久代溢位,太大則容易導致老年代溢位。
 3、永久代會為 GC 帶來不必要的複雜度,並且回收效率偏低。
參考:Java8記憶體模型—永久代(PermGen)和元空間(Metaspace)

為什麼這樣劃分

  1. 為什麼要分新生代和老年代

    Java的垃圾回收器採用的演算法是分代回收演算法。分代回收演算法=複製演算法+標記整理演算法。不同型別的物件生命週期(新生代和老年代)決定了更適合採用哪種演算法。

  2. 新生代為什麼分一個Eden區和兩個Survivor區
    假設新生代只有一個Eden區,當GC操作後,需要將Eden區存活物件複製到另外一塊區,所以新生代需要額外劃分一塊Survivor區,用於存放GC後存活的物件。
為什麼要有兩個Survivor區?
因為第二次GC操作Eden區和Survivor區也需要被清理,這時就需要另一塊空間,所以Survivor區需要一分為二。

  3. 一個Eden區和兩個Survivor區的比例為什麼是8:1:1
    新建立的物件都是放在Eden空間,這是很頻繁的,尤其是大量的區域性變數產生的臨時物件,這些物件絕大部分都應該馬上被回收,能存活下來被轉移到survivor空間的往往不多。所以,設定較大的Eden空間和較小的Survivor空間是合理的,大大提高了記憶體的使用率。
8:1:1這個比例是可以調整的,包括上面的新生代和老年代的1:2的比例也是可以調整的。
    參考:JVM之垃圾回收機制

何時GC

  • 當年輕代記憶體滿時,會引發一次普通minor GC,該GC僅回收年輕代。需要強調的時,年輕代滿是指Eden代滿,Survivor滿不會引發GC
  • 當年老代滿時會引發Full(major) GC,Full GC將會同時回收年輕代、年老代
  • 當永久代滿時也會引發Full GC,會導致Class、Method元資訊的解除安裝

GC演算法

複製演算法

把記憶體空間劃為兩個區域,每次只使用其中一個區域。垃圾回收時,遍歷當前使用區域,把正在使用中的物件複製到另外一個區域中。演算法每次只處理正在使用中的物件,因此複製成本比較小,同時複製過去以後還能進行相應的記憶體整理,不會出現“碎片”問題。優點:實現簡單,執行高效,克服控制程式碼的開銷和解決堆碎片。缺點:會浪費一定的記憶體。一般新生代採用這種演算法。

標記清除演算法

分為標記和清除兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。該演算法的缺點是效率不高並且會產生不連續的記憶體碎片,一般用於老年代的垃圾回收。

標記整理演算法

標記階段與標記清除演算法一樣,但後續並不是直接對可回收的物件進行清理,而是讓所有存活物件都向一端移動,然後清理。該演算法不會造成記憶體碎片,一般用於老年代的垃圾回收。在基於該演算法的收集器的實現中,一般會增加控制程式碼和控制程式碼表。

垃圾收集器

新生代常用的垃圾收集器:Serial、PraNew、Parallel Scavenge
老年代常用的垃圾收集器:Serial Old、Parallel Old、CMS

  1. Serial 收集器:新生代單執行緒收集器,一種古老的收集器,標記和清理都是單執行緒,優點是簡單高效,缺點必須暫停所有使用者執行緒。

  2. Serial Old 收集器:老年代單執行緒收集器,Serial收集器的老年代版本,採用的是Mark-Compact(標記整理)演算法。它的優點是實現簡單高效,但是缺點是會給使用者帶來停頓。

  3. ParNew 收集器:新生代收集器,可以認為是Serial收集器的多執行緒版本,在多核CPU環境下有著比Serial更好的表現。

  4. Parallel Scavenge 收集器:新生代並行收集器,追求高吞吐量,高效利用CPU,能夠了達到一個可控的吞吐量,它在回收期間不需要暫停其他使用者執行緒。吞吐量一般為99%,吞吐量= 使用者執行緒時間/(使用者執行緒時間+GC執行緒時間)。適合後臺應用等對互動相應要求不高的場景。

  5. Parallel Old 收集器:Parallel Scavenge 收集器的老年代版本,並行收集器,吞吐量優先,使用多執行緒和Mark-Compact演算法

  6. CMS(Concurrent Mark Sweep)收集器:高併發、低停頓,追求最短GC回收停頓時間,cpu佔用比較高,響應時間快,停頓時間短,多核cpu 追求高響應時間的選擇

  7. G1:G1收集器是當今比較流行的收集器,它是一款面向服務端應用的收集器,它能充分利用多CPU、多核環境。因此它是一款並行與併發收集器,並且它能建立可預測的停頓時間模型。

Full GC和併發垃圾回收

併發垃圾回收器的記憶體回收過程是與使用者執行緒一起併發執行的。通常情況下,併發垃圾回收器可以在使用者執行緒執行的情況下完成大部分的回收工作,所以應用停頓時間很短。但由於併發垃圾回收時使用者執行緒還在執行,所以會有新的垃圾不斷產生。作為擔保,如果在老年代記憶體都被佔用之前,如果併發垃圾回收器還沒結束工作,那麼應用會暫停,在所有使用者執行緒停止的情況下完成回收。這種情況稱作Full GC,這意味著需要調整有關併發回收的引數了。

由於Full GC很影響應用的效能,要儘量避免或減少。特別是如果對於高容量低延遲的電商系統,要儘量避免在交易時間段發生 Full GC。在對JVM調優的過程中,很大一部分工作就是對於 Full GC 的調節。有如下原因可能導致Full GC:

  1. 年老代(Tenured)被寫滿

  2. 持久代(Perm)被寫滿

  3. System.gc()被顯示呼叫

  4. 上一次GC之後Heap的各域分配策略動態變化

與垃圾回收相關的JVM引數

  • -Xms / -Xmx — 堆的初始大小 / 堆的最大大小
  • -Xmn — 堆中年輕代的大小
  • -XX:-DisableExplicitGC — 讓System.gc()不產生任何作用
  • -XX:+PrintGCDetails — 列印GC的細節
  • -XX:+PrintGCDateStamps — 列印GC操作的時間戳
  • -XX:NewSize / XX:MaxNewSize — 設定新生代大小/新生代最大大小
  • -XX:NewRatio — 可以設定老生代和新生代的比例
  • -XX:PrintTenuringDistribution — 設定每次新生代GC後輸出倖存者樂園中物件年齡的分佈
  • -XX:InitialTenuringThreshold / -XX:MaxTenuringThreshold:設定老年代閥值的初始值和最大值
  • -XX:TargetSurvivorRatio:設定倖存區的目標使用率

總結

  • 在Java中,物件例項都是在堆上建立;一些類資訊,常量,靜態變數等儲存在方法區。堆和方法區都是執行緒共享的。

  • 在Java中,GC是由一個被稱為垃圾回收器的守護執行緒執行的。

  • 在從記憶體回收一個物件之前會呼叫物件的finalize()方法。

  • 作為一個Java開發者不能強制JVM執行GC;GC的觸發由JVM依據堆記憶體的大小來決定。

  • System.gc()和Runtime.gc()會向JVM傳送執行GC的請求,但是JVM不保證一定會執行GC。

  • 發生Major GC時使用者執行緒會暫停,會降低系統效能和吞吐量。

  • JVM的引數-Xmx和-Xms用來設定 Java 堆記憶體的初始大小和最大值。依據個人經驗這個值的比例最好是1:1或者1:1.5。比如,你可以將-Xmx和-Xms都設為1GB,或者-Xmx和-Xms設為1.2GB和1.8GB。

  • Java中不能手動觸發GC,但可以用不同的引用類來輔助垃圾回收器工作(比如弱引用或軟引用)。

擴充套件

Java記憶體模型
JVM調優



[參考資料]
Java中的垃圾回收機制
深入理解java垃圾回收機制

相關文章