1. 程式人生 > >Java GC機制詳解

Java GC機制詳解

垃圾收集 Garbage Collection 通常被稱為“GC”,即就是Java垃圾回收機制。

導讀:

1、什麼是GC

2、GC常用演算法

3、垃圾收集器

4、finalize()方法詳解

5、總結--根據GC原理來優化程式碼

正式閱讀之前需要了解相關概念:

Java 堆記憶體分為新生代和老年代,新生代中又分為1個 Eden 區域 和 2個 Survivor 區域。

一、什麼是GC:

每個程式設計師都遇到過記憶體溢位的情況,程式執行時,記憶體空間是有限的,那麼如何及時的把不再使用的物件清除將記憶體釋放出來,這就是GC要做的事。

理解GC機制就從:“GC的區域在哪裡”“GC的物件是什麼”

“GC的時機是什麼”“GC做了哪些事”幾方面來分析。

1、需要GC的記憶體區域

jvm 中,程式計數器、虛擬機器棧、本地方法棧都是隨執行緒而生隨執行緒而滅,棧幀隨著方法的進入和退出做入棧和出棧操作,實現了自動的記憶體清理,因此,我們的記憶體垃圾回收主要集中於 java 堆和方法區中,在程式執行期間,這部分記憶體的分配和使用都是動態的。

2、GC的物件

需要進行回收的物件就是已經沒有存活的物件,判斷一個物件是否存活常用的有兩種辦法:引用計數和可達分析。

(1)引用計數:每個物件有一個引用計數屬性,新增一個引用時計數加1,引用釋放時計數減1,計數為0時可以回收。此方法簡單,無法解決物件相互迴圈引用的問題。

(2)可達性分析(Reachability Analysis):從GC Roots開始向下搜尋,搜尋所走過的路徑稱為引用鏈。當一個物件到GC Roots沒有任何引用鏈相連時,則證明此物件是不可用的。不可達物件。

在Java語言中,GC Roots包括:

虛擬機器棧中引用的物件。

方法區中類靜態屬性實體引用的物件。

方法區中常量引用的物件。

本地方法棧中JNI引用的物件。

3、什麼時候觸發GC

(1)程式呼叫System.gc時可以觸發

(2)系統自身來決定GC觸發的時機(根據Eden區和From Space區的記憶體大小來決定。當記憶體大小不足時,則會啟動GC執行緒並停止應用執行緒)

GC又分為 minor GC 和 Full GC (也稱為 Major GC )

Minor GC觸發條件:當Eden區滿時,觸發Minor GC。

Full GC觸發條件:

  a.呼叫System.gc時,系統建議執行Full GC,但是不必然執行

  b.老年代空間不足

  c.方法去空間不足

  d.通過Minor GC後進入老年代的平均大小大於老年代的可用記憶體

  e.由Eden區、From Space區向To Space區複製時,物件大小大於To Space可用記憶體,則把該物件轉存到老年代,且老年代的可用記憶體小於該物件大小

4、GC做了什麼事

 主要做了清理物件,整理記憶體的工作。Java堆分為新生代和老年代,採用了不同的回收方式。(回收方式即回收演算法詳見後文)

二、GC常用演算法

GC常用演算法有:標記-清除演算法標記-壓縮演算法複製演算法分代收集演算法。

目前主流的JVM(HotSpot)採用的是分代收集演算法。

 1、標記-清除演算法

為每個物件儲存一個標記位,記錄物件的狀態(活著或是死亡)。分為兩個階段,一個是標記階段,這個階段內,為每個物件更新標記位,檢查物件是否死亡;第二個階段是清除階段,該階段對死亡的物件進行清除,執行 GC 操作。

優點最大的優點是,標記—清除演算法中每個活著的物件的引用只需要找到一個即可,找到一個就可以判斷它為活的。此外,更重要的是,這個演算法並不移動物件的位置。

缺點它的缺點就是效率比較低(遞迴與全堆物件遍歷)。每個活著的物件都要在標記階段遍歷一遍;所有物件都要在清除階段掃描一遍,因此演算法複雜度較高。沒有移動物件,導致可能出現很多碎片空間無法利用的情況。

圖例

 2、標記-壓縮演算法(標記-整理)

標記-壓縮法是標記-清除法的一個改進版。同樣,在標記階段,該演算法也將所有物件標記為存活和死亡兩種狀態;不同的是,在第二個階段,該演算法並沒有直接對死亡的物件進行清理,而是將所有存活的物件整理一下,放到另一處空間,然後把剩下的所有物件全部清除。這樣就達到了標記-整理的目的。

優點該演算法不會像標記-清除演算法那樣產生大量的碎片空間。缺點如果存活的物件過多,整理階段將會執行較多複製操作,導致演算法效率降低。圖例

 

左邊是標記階段,右邊是整理之後的狀態。可以看到,該演算法不會產生大量碎片記憶體空間。

 3、複製演算法

該演算法將記憶體平均分成兩部分,然後每次只使用其中的一部分,當這部分記憶體滿的時候,將記憶體中所有存活的物件複製到另一個記憶體中,然後將之前的記憶體清空,只使用這部分記憶體,迴圈下去。

注意: 這個演算法與標記-整理演算法的區別在於,該演算法不是在同一個區域複製,而是將所有存活的物件複製到另一個區域內。

優點

實現簡單;不產生記憶體碎片

缺點每次執行,總有一半記憶體是空的,導致可使用的記憶體空間只有原來的一半。

圖例

4、分代收集演算法

現在的虛擬機器垃圾收集大多采用這種方式,它根據物件的生存週期,將堆分為新生代(Young)和老年代(Tenure)。在新生代中,由於物件生存期短,每次回收都會有大量物件死去,那麼這時就採用複製演算法。老年代裡的物件存活率較高,沒有額外的空間進行分配擔保,所以可以使用標記-整理 或者 標記-清除。

 具體過程:新生代(Young)分為Eden區,From區與To區

當系統建立一個物件的時候,總是在Eden區操作,當這個區滿了,那麼就會觸發一次YoungGC,也就是年輕代的垃圾回收。一般來說這時候不是所有的物件都沒用了,所以就會把還能用的物件複製到From區。 

 

這樣整個Eden區就被清理乾淨了,可以繼續建立新的物件,當Eden區再次被用完,就再觸發一次YoungGC,然後呢,注意,這個時候跟剛才稍稍有點區別。這次觸發YoungGC後,會將Eden區與From區還在被使用的物件複製到To區, 

再下一次YoungGC的時候,則是將Eden區與To區中的還在被使用的物件複製到From區

經過若干次YoungGC後,有些物件在From與To之間來回遊蕩,這時候From區與To區亮出了底線(閾值),這些傢伙要是到現在還沒掛掉,對不起,一起滾到(複製)老年代吧。 

老年代經過這麼幾次折騰,也就扛不住了(空間被用完),好,那就來次集體大掃除(Full GC),也就是全量回收。如果Full GC使用太頻繁的話,無疑會對系統性能產生很大的影響。所以要合理設定年輕代與老年代的大小,儘量減少Full GC的操作。

三、垃圾收集器

如果說收集演算法是記憶體回收的方法論,垃圾收集器就是記憶體回收的具體實現

1.Serial收集器

序列收集器是最古老,最穩定以及效率高的收集器可能會產生較長的停頓,只使用一個執行緒去回收-XX:+UseSerialGC

  • 新生代、老年代使用序列回收
  • 新生代複製演算法
  • 老年代標記-壓縮

2. 並行收集器

2.1 ParNew

-XX:+UseParNewGC(new代表新生代,所以適用於新生代)

  • 新生代並行
  • 老年代序列

Serial收集器新生代的並行版本在新生代回收時使用複製演算法多執行緒,需要多核支援-XX:ParallelGCThreads 限制執行緒數量

2.2 Parallel收集器

類似ParNew 新生代複製演算法 老年代標記-壓縮 更加關注吞吐量 -XX:+UseParallelGC  
  • 使用Parallel收集器+ 老年代序列
-XX:+UseParallelOldGC 
  • 使用Parallel收集器+ 老年代並行

2.3 其他GC引數

-XX:MaxGCPauseMills

  • 最大停頓時間,單位毫秒
  • GC盡力保證回收時間不超過設定值

-XX:GCTimeRatio 

  • 0-100的取值範圍
  • 垃圾收集時間佔總時間的比
  • 預設99,即最大允許1%時間做GC

這兩個引數是矛盾的。因為停頓時間和吞吐量不可能同時調優

3. CMS收集器

  • Concurrent Mark Sweep 併發標記清除(應用程式執行緒和GC執行緒交替執行)
  • 使用標記-清除演算法
  • 併發階段會降低吞吐量(停頓時間減少,吞吐量降低)
  • 老年代收集器(新生代使用ParNew)
  • -XX:+UseConcMarkSweepGC

CMS執行過程比較複雜,著重實現了標記的過程,可分為

1. 初始標記(會產生全域性停頓)

  • 根可以直接關聯到的物件
  • 速度快

2. 併發標記(和使用者執行緒一起) 

  • 主要標記過程,標記全部物件

3. 重新標記 (會產生全域性停頓) 

  • 由於併發標記時,使用者執行緒依然執行,因此在正式清理前,再做修正

4. 併發清除(和使用者執行緒一起) 

  • 基於標記結果,直接清理物件

這裡就能很明顯的看出,為什麼CMS要使用標記清除而不是標記壓縮,如果使用標記壓縮,需要多物件的記憶體位置進行改變,這樣程式就很難繼續執行。但是標記清除會產生大量記憶體碎片,不利於記憶體分配。 

CMS收集器特點:

儘可能降低停頓會影響系統整體吞吐量和效能

  • 比如,在使用者執行緒執行過程中,分一半CPU去做GC,系統性能在GC階段,反應速度就下降一半

清理不徹底 

  • 因為在清理階段,使用者執行緒還在執行,會產生新的垃圾,無法清理

因為和使用者執行緒一起執行,不能在空間快滿時再清理(因為也許在併發GC的期間,使用者執行緒又申請了大量記憶體,導致記憶體不夠) 

  • -XX:CMSInitiatingOccupancyFraction設定觸發GC的閾值
  • 如果不幸記憶體預留空間不夠,就會引起concurrent mode failure

一旦 concurrent mode failure產生,將使用序列收集器作為後備。

CMS也提供了整理碎片的引數:

-XX:+ UseCMSCompactAtFullCollection Full GC後,進行一次整理

  • 整理過程是獨佔的,會引起停頓時間變長

-XX:+CMSFullGCsBeforeCompaction  

  • 設定進行幾次Full GC後,進行一次碎片整理

-XX:ParallelCMSThreads 

  • 設定CMS的執行緒數量(一般情況約等於可用CPU數量)

CMS的提出是想改善GC的停頓時間,在GC過程中的確做到了減少GC時間,但是同樣導致產生大量記憶體碎片,又需要消耗大量時間去整理碎片,從本質上並沒有改善時間。 

4. G1收集器

G1是目前技術發展的最前沿成果之一,HotSpot開發團隊賦予它的使命是未來可以替換掉JDK1.5中釋出的CMS收集器。

與CMS收集器相比G1收集器有以下特點:

(1) 空間整合,G1收集器採用標記整理演算法,不會產生記憶體空間碎片。分配大物件時不會因為無法找到連續空間而提前觸發下一次GC。

(2)可預測停頓,這是G1的另一大優勢,降低停頓時間是G1和CMS的共同關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為N毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒,這幾乎已經是實時Java(RTSJ)的垃圾收集器的特徵了。

上面提到的垃圾收集器,收集的範圍都是整個新生代或者老年代,而G1不再是這樣。使用G1收集器時,Java堆的記憶體佈局與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔閡了,它們都是一部分(可以不連續)Region的集合。

G1的新生代收集跟ParNew類似,當新生代佔用達到一定比例的時候,開始出發收集。

和CMS類似,G1收集器收集老年代物件會有短暫停頓。

步驟:

(1)標記階段,首先初始標記(Initial-Mark),這個階段是停頓的(Stop the World Event),並且會觸發一次普通Mintor GC。對應GC log:GC pause (young) (inital-mark)

(2)Root Region Scanning,程式執行過程中會回收survivor區(存活到老年代),這一過程必須在young GC之前完成。

(3)Concurrent Marking,在整個堆中進行併發標記(和應用程式併發執行),此過程可能被young GC中斷。在併發標記階段,若發現區域物件中的所有物件都是垃圾,那個這個區域會被立即回收(圖中打X)。同時,併發標記過程中,會計算每個區域的物件活性(區域中存活物件的比例)。

(4)Remark, 再標記,會有短暫停頓(STW)。再標記階段是用來收集 併發標記階段 產生新的垃圾(併發階段和應用程式一同執行);G1中採用了比CMS更快的初始快照演算法:snapshot-at-the-beginning (SATB)。

(5)Copy/Clean up,多執行緒清除失活物件,會有STW。G1將回收區域的存活物件拷貝到新區域,清除Remember Sets,併發清空回收區域並把它返回到空閒區域連結串列中。

(6)複製/清除過程後。回收區域的活性物件已經被集中回收到深藍色和深綠色區域。

四、finalize()方法詳解

1. finalize的作用

(1)finalize()是Object的protected方法,子類可以覆蓋該方法以實現資源清理工作,GC在回收物件之前呼叫該方法。(2)finalize()與C++中的解構函式不是對應的。C++中的解構函式呼叫的時機是確定的(物件離開作用域或delete掉),但Java中的finalize的呼叫具有不確定性(3)不建議用finalize方法完成“非記憶體資源”的清理工作,但建議用於:① 清理本地物件(通過JNI建立的物件);② 作為確保某些非記憶體資源(如Socket、檔案等)釋放的一個補充:在finalize方法中顯式呼叫其他資源釋放方法。其原因可見下文[finalize的問題]

2. finalize的問題(1)一些與finalize相關的方法,由於一些致命的缺陷,已經被廢棄了,如System.runFinalizersOnExit()方法、Runtime.runFinalizersOnExit()方法(2)System.gc()與System.runFinalization()方法增加了finalize方法執行的機會,但不可盲目依賴它們(3)Java語言規範並不保證finalize方法會被及時地執行、而且根本不會保證它們會被執行(4)finalize方法可能會帶來效能問題。因為JVM通常在單獨的低優先順序執行緒中完成finalize的執行(5)物件再生問題:finalize方法中,可將待回收物件賦值給GC Roots可達的物件引用,從而達到物件再生的目的(6)finalize方法至多由GC執行一次(使用者當然可以手動呼叫物件的finalize方法,但並不影響GC對finalize的行為)

3. finalize的執行過程(生命週期)

(1) 首先,大致描述一下finalize流程:當物件變成(GC Roots)不可達時,GC會判斷該物件是否覆蓋了finalize方法,若未覆蓋,則直接將其回收。否則,若物件未執行過finalize方法,將其放入F-Queue佇列,由一低優先順序執行緒執行該佇列中物件的finalize方法。執行finalize方法完畢後,GC會再次判斷該物件是否可達,若不可達,則進行回收,否則,物件“復活”。(2) 具體的finalize流程:  物件可由兩種狀態,涉及到兩類狀態空間,一是終結狀態空間 F = {unfinalized, finalizable, finalized};二是可達狀態空間 R = {reachable, finalizer-reachable, unreachable}。各狀態含義如下:

  • unfinalized: 新建物件會先進入此狀態,GC並未準備執行其finalize方法,因為該物件是可達的
  • finalizable: 表示GC可對該物件執行finalize方法,GC已檢測到該物件不可達。正如前面所述,GC通過F-Queue佇列和一專用執行緒完成finalize的執行
  • finalized: 表示GC已經對該物件執行過finalize方法
  • reachable: 表示GC Roots引用可達
  • finalizer-reachable(f-reachable):表示不是reachable,但可通過某個finalizable物件可達
  • unreachable:物件不可通過上面兩種途徑可達

狀態變遷圖:

變遷說明:

  (1)新建物件首先處於[reachable, unfinalized]狀態(A)  (2)隨著程式的執行,一些引用關係會消失,導致狀態變遷,從reachable狀態變遷到f-reachable(B, C, D)或unreachable(E, F)狀態  (3)若JVM檢測到處於unfinalized狀態的物件變成f-reachable或unreachable,JVM會將其標記為finalizable狀態(G,H)。若物件原處於[unreachable, unfinalized]狀態,則同時將其標記為f-reachable(H)。  (4)在某個時刻,JVM取出某個finalizable物件,將其標記為finalized並在某個執行緒中執行其finalize方法。由於是在活動執行緒中引用了該物件,該物件將變遷到(reachable, finalized)狀態(K或J)。該動作將影響某些其他物件從f-reachable狀態重新回到reachable狀態(L, M, N)  (5)處於finalizable狀態的物件不能同時是unreahable的,由第4點可知,將物件finalizable物件標記為finalized時會由某個執行緒執行該物件的finalize方法,致使其變成reachable。這也是圖中只有八個狀態點的原因  (6)程式設計師手動呼叫finalize方法並不會影響到上述內部標記的變化,因此JVM只會至多呼叫finalize一次,即使該物件“復活”也是如此。程式設計師手動呼叫多少次不影響JVM的行為  (7)若JVM檢測到finalized狀態的物件變成unreachable,回收其記憶體(I)  (8)若物件並未覆蓋finalize方法,JVM會進行優化,直接回收物件(O)  (9)注:System.runFinalizersOnExit()等方法可以使物件即使處於reachable狀態,JVM仍對其執行finalize方法

五、總結

根據GC的工作原理,我們可以通過一些技巧和方式,讓GC執行更加有效率,更加符合應用程式的要求。一些關於程式設計的幾點建議: 

1.最基本的建議就是儘早釋放無用物件的引用。大多數程式設計師在使用臨時變數的時候,都是讓引用變數在退出活動域(scope)後,自動設定為 null.我們在使用這種方式時候,必須特別注意一些複雜的物件圖,例如陣列,佇列,樹,圖等,這些物件之間有相互引用關係較為複雜。對於這類物件,GC 回收它們一般效率較低。如果程式允許,儘早將不用的引用物件賦為null.這樣可以加速GC的工作。 2.儘量少用finalize函式。finalize函式是Java提供給程式設計師一個釋放物件或資源的機會。但是,它會加大GC的工作量,因此儘量少採用finalize方式回收資源。 3.如果需要使用經常使用的圖片,可以使用soft應用型別。它可以儘可能將圖片儲存在記憶體中,供程式呼叫,而不引起OutOfMemory. 4.注意集合資料型別,包括陣列,樹,圖,連結串列等資料結構,這些資料結構對GC來說,回收更為複雜。另外,注意一些全域性的變數,以及一些靜態變數。這些變數往往容易引起懸掛物件(dangling reference),造成記憶體浪費。 5.當程式有一定的等待時間,程式設計師可以手動執行System.gc(),通知GC執行,但是Java語言規範並不保證GC一定會執行。使用增量式GC可以縮短Java程式的暫停時間。