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程序的暫停時間。

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章