Java 垃圾回收機制學習

##什麼是GC##
GC(Garbage Collection),也就是垃圾收集,它可以實現內存的自動回收。一般認爲GC是專屬於java語言的一個東西,但事實上GC早於java出現,在1960年,Lisp是第一次使用了GC技術。別的不多說了,能看這篇文章的肯定也是知道什麼是GC了。

##哪些內存需要回收##

首先我們需要知道jvm在執行程序的過程中,會把它所管理的內存劃分爲若干個不同的數據區域如下圖:
這裏寫圖片描述
各個區域的簡單介紹如下:

  • **程序計數器(Program Counter Register)**是一塊較小的內存區域,它可以看作是當前線程所執行的字節碼的行號指示器。在虛擬機概念模型裏,字節碼解釋器工作時就是通過改變這個計數器的值來選取下一條需要執行的字節碼命令。由於java虛擬機的多線程是通過線程輪流分配處理器執行時間的方式來實現的,所以一個處理器在一個確定的時候只能處理一條線程的指令,爲了能在線程切換後恢復到正確的指令位置,每個線程都需要一個獨立的程序計數器來獨立存儲當前線程的執行位置。此區域是java虛擬機規範中唯一一個沒有任何OutOfMemoryError的區域

  • java虛擬機棧 和程序計數器一個,java虛擬機棧也是線程私有的,它的生命週期與線程一致。每個線程的方法在執行時都會創建一個棧幀,用於存儲局部變量表,操作數棧,動態鏈接,方法出口等信息。每個方法從調用到結束,就對應着一個棧幀在虛擬機棧中入棧到出棧的過程,示意圖如下
    這裏寫圖片描述

  • 本地方法棧和虛擬機棧類似,只不過虛擬機棧是爲虛擬機執行java服務,而本地方法棧則爲虛擬機使用到的native方法服務。

  • java堆 對於大多數應用來說,java堆(java heap)是java虛擬機所管理的內存中最大的一塊。java堆是被所有線程共享的一塊內存區域,在虛擬啓動時創建。此區域的位移目的就是存放對象實例,幾乎所有的對象實例都在這裏分配內存。java堆是GC的主要區域。但是隨着JIT編譯器的發展與逃逸分析技術逐漸成熟,所有對象都在堆上分配這一點已經不在那麼絕對。從內存回收的角度來看,java堆又可以分爲新生代老年代

  • 方法區和java堆一樣,是所有線程公用的一塊內存區域,主要存儲虛擬機加載的類信息,常量,靜態變量,及時編譯器編譯後的代碼等數據

如上所述,其中程序計數器,虛擬機棧,本地方法棧三個區域是線程私有的,隨線程而滅。棧中的棧幀隨着方法的進入和退出而進行入棧出棧的操作,每一個棧分配的內存基本上在類結構確定下來時就已經確定了,因此這幾個區域的內存分配和回收都具有確定性,這幾個區域內就不需要過多考慮回收問題,因爲方法結束時,內存就自然被回收了。而java堆和方法區則不一樣,一個接口中的多個實現類需要的內存不一樣,一個方法中多個分支需要的內存也不一樣,我們只有在程序運行時才知道會創建哪些對象,這部分的內存分配和回收都是動態的,GC關注的也是這部分的內存
##怎樣判斷對象已死##
也就是怎樣判斷對象可以被回收。目前主要有兩種方法

  • 引用計數法
  • 可達性分析算法

###引用計數法###
此方法會給對象添加一個計數器,每當增加一個引用,計數器就加一,刪除一個引用,計數就減一,當數值爲0時對象就沒有再被引用了。此算法的優點是實現簡單,判斷效率高,但是它有一個主要的缺點就是很難解決相互引用問題。舉個例子,對象A中只有一個成員變量instance,對象B中也只有一個變量instance,然後將對象B賦值給A的instance,把對象A賦值給B的instance,初此之外再無其他地方對這兩個對象有引用,這樣的話實際上對象A和對象B根本不可能再被訪問,但是由於引用計數不爲0,所以導致這兩個對象無法被回收。
###可達性分析算法###
這個算法的基本思想是通過一系列成爲"GC roots"的對象作爲起點,從這些節點開始向下搜索,搜索走過的路徑稱爲引用鏈,當一個對象沒有被任何gc roots相連,則認爲這個對象不可用。如圖所示,雖然object 6,object 7,object 8有相互引用,但是他們都沒有和gc roots相連,所以這三個對象將被回收。在主流的商用程序語言(java,C#)中使用的都是可達性分析算法
這裏寫圖片描述
那GC roots又是什麼呢?在java語言中可以作爲GC roots的對象包括以下幾種:

  • 虛擬機棧(棧幀中的本地變量表)中引用的對象
  • 方法區中的類靜態屬性引用對象
  • 方法區中常量引用對象
  • 本地方法棧中JNI已用對象

###對象引用方式###
在java中存在四種引用方式,分別是強引用(strong reference),軟引用(soft reference),弱引用(weak reference),虛引用(phantom reference),介紹如下

  • 強引用是使用最廣的引用方式,"Object o = new Object"這種用法就是強引用,這類的引用只要強引用關係存在,則對象就不會被回收。
  • 軟引用用來引用一些目前還有用,但不是必須的對象。被軟引用的對象,在系統將要發生OOM之前,將會把這些對象列進回收範圍之中進行第二次回收。如果這次回收後還沒有足夠內存空間,纔會拋出OOM的異常,軟引用一般用SoftReference實現。
  • 弱引用用來引用那些非必須的對象,比軟引用更弱,被弱引用關聯的對象只能生存到下次GC發生之前。也就是說放GC發生的時候,不管當前內存是否足夠,都會回收弱引用的對象,用WeakReference實現。
  • 虛引用是更弱的引用,也成爲幽靈引用,一個對象是否有虛引用存在根本就不會印象它是否可以被回收,也無法通過虛引用來獲取對象(通過get方法會返回null),爲一個對象設置虛引用的唯一目的就是能在這個對象被GC回收時收到一個系統通知

瞭解了不同的引用方式後,我們在平時的開發過程中可以更加靈活的使用對象。

###Finalize()方法###
當一個對象在可達性算法中被判定爲不可達的時候,並不是肯定會被回收。因爲GC在回收的時候,會有兩次標記的過程,第一次標記的時候把不可達的對象找不出來,然後進行篩選,篩選的條件是看當前對象是否有有必要執行finalize()方法,而沒有必要執行的判斷條件是:1.當前對象沒有覆蓋finalize()方法,2.finalize已經被調用過。如果對象被判定爲有必要執行finalize()方法,則該對象會被放在一個叫做F-Queue的隊列中,並在一個由虛擬機建立,低優先級的Finalize線程中去執行。但這裏的執行並不是一定要等finalize方法執行結束,只是觸發他而已,因爲如果某個對象的finalize執行緩慢,或者死循環,這樣會導致其他對象永遠在等待。然後GC會對F-Queue中的對象進行第二次標記,這次標記完後,這些對象將被回收。如果我們在finalize方法中將當前對象的賦值給其他引用,則可以逃過被回收的命運。
需要注意的是,一個對象的finalize只會被調用一次

另外一般情況下不建議使用finalize方法,因爲它的運行代價高,不確定性大,無法保證各個對象的運行順序。

##垃圾蒐集算法##
前面知道了哪些對象會被回收,那麼GC在回收對象的時候具體是怎樣的實現呢?這裏不放代碼,只講算法,代碼我也不會。

###標記-清除算法###
標記清除算法分爲兩個階段,標記和清除:首先標記出所有需要回收的對象,然後在標記完成後統一回收所有被標記的對象。這個算法是最基礎的算法,後面其他算法都是基於此進行改進的。此算法主要有兩個缺點:1.效率問題,標記和清除兩個過程效率都不高;2.空間問題,清除完成後會產生大量不連續的內存碎片,這樣會導致以後要申請一塊大內存的時候,找不到符合要求的區域而又出發一次GC。標記-清除算法的示意圖如下:
這裏寫圖片描述

###複製算法###
複製算法將可用內存分爲大小相等的兩塊,每次只使用其中的一塊,當一塊的內存用完的時候,將存活的對象全部複製到另一塊空的內存,只剩下需要回收的內存,然後把這些內存一次性清理掉。這種算法不要考慮內存碎片問題,因爲每次都是正片清除,實現簡單,運行高效,但是這種算法的內存使用縮小爲原來的一半,導致代價過高。示意圖如下:
這裏寫圖片描述
不過現在的商業虛擬機都是用這種算法來回收新生代。因爲研究發現,新生代的對象大部分的生命週期都很短,所以並不需要按照1:1來劃分內存空間,而是將內存分爲一塊較大的Eden空間兩塊較小的Survivor空間,這裏稱之爲Survivor1和Survivor2,每次使用Eden和其中一塊Survivor(這裏假設使用Survivor1)。當回收時,將Eden和Survivor1上還存活的對象複製到Survivor2上,然後清理掉Eden和Survivor1。目前HotSpot虛擬機上默認Eden和Survivor大小的比例爲8:1,如下圖:
這裏寫圖片描述
但是有個問題就是不是每次存活的對象都只有不多於10%,當存活對象大於10%的時候Survivor2就放不下了,那怎麼辦呢?這時候就需要把這些對象方法哦老年代去。

###標記-整理算法###
複製算法在對象存活率低的新生代使用比較有效,但是遇到對象存活率很高的老年代就不適用了,因爲老年代大部分對象都是不需要回收的,而複製算法有需要開闢一開空間去存放複製的對象,這樣會很容易導致內存空間不足,所以在老年代不能採用複製算法,有人提出了一種“標記-整理”算法,這個算法的標記過程和新生代的“標記-清除”算法一樣,但是在回收階段不是直接對對象進行清理,而是將所有存活的對象移到一端,然後清理掉邊界意外的內存,如圖所示:
這裏寫圖片描述

##垃圾收集器##
前面講的算法如果不用代碼實現,都沒什麼卵用,當然目前肯定已經有實現了這些算法的程序了,我們叫它爲垃圾收集器。不同的廠商,不同的虛擬機提供的垃圾收集器是不一樣的,而且一般都會提供參數供用戶根據自己的需求來組合出各個年代所使用的垃圾收集器組合。本文討論的收集器是基於hotspot在jdk1.7之後使用的收集器,包含的虛擬機如下圖:
這裏寫圖片描述
圖中收集器處於哪個取就說明該收集器是專門作用於哪個區,兩個收集器之間有連線,說明這兩款可以搭配使用。要說明一點是,並沒有最好的垃圾收集器,我們在選擇垃圾收集器的時候要根據具體的應用場景來決定。接下來就是對各個垃圾收集器做一下介紹。

  • Serial 收集器 是歷史最悠久的收集器,從名字可以看出,它是一款串行收集器,不但是垃圾收集使用單線程,更重要的是Serial在回收垃圾的時候會暫停其他所有線程(stop the world),直到收集結束。serial收集器示意圖如下
    這裏寫圖片描述
    雖然聽上去感覺Serial收集器很差,但是實際上它仍然是jvm在client模式下默認的垃圾收集器,因爲它簡單而高效。在單個cpu環境下,由於沒有線程切換帶來的開銷,而且在桌面應用一般分配給虛擬機的內存不會很大,蒐集一次花費的時間也不會太長,基本只有幾十毫秒,這對用戶來說是可以是接受的。

  • ParNew收集器
    ParNew收集器可以說是Serial的多線程版本,它在做垃圾回收的時候是用多線程去做,在多處理器環境下會比Serial效率更好。事實上ParNew和Serial共用了相當多的代碼。可以說除了多線程進行垃圾回收,其他行爲和Serial完全一樣。示意圖如下
    這裏寫圖片描述
    ParNew雖然與Serial相比沒有太多創新,但是它卻是許多運行在server模式下虛擬機首選的新生代收集器,其中一個與性能無關的原因是,除了Serial收集器外,目前只有ParNew可以和CMS收集器搭配使用。而CMS收集器又是很牛逼的收集器,它作用於老年代,可以實現垃圾收集的同時用戶程序繼續運行,也就是不用全部掛起。但是它不能和Parallel Scavenge收集器搭配使用,所以在老年代使用CMS的虛擬機上新生代只能選擇ParNew。

  • Parallel Scavenge收集器是一個新生代收集器,使用複製算法,而且是多線程收集器,那麼它和ParNew有什麼區別呢。一般的收集器關注的點都是怎樣縮短GC時用戶線程的暫停時間,而Parallel Scavenge的目的則是達到一個可控制的吞吐量。吞吐量是指CPU用於運行用戶代碼的時間與CPU總運行時間的比值,比如虛擬機總共運行了100秒,其中垃圾收集花了1秒,那麼吞吐量就是99%。吞吐量越高說明用戶代碼使用cpu的效率越高。Parallel Scavenge有兩個參數可以精確控制吞吐量,分別是-XX:MaxGCPauseMillis和-XX:GCTimeRatio,前者用於指定最大垃圾收集停頓時間,後者用於直接設置吞吐量。Parallel Scavenge還有一個參數-XX:+UseAdaptiveSizePolicy,這是一個開關參數,打開之後就不需要手工指定新生代的大小(-Xmn),Eden與Survivor區的比例(-XX:SurvivorRatio),晉升老年代對象大小(-XX:PretenureSizeThreshold)等細節參數了,虛擬機就會根據系統當前的運行情況動態調整參數以提供最合適的停頓時間或者最大吞吐量,這種調節方式稱作GC自適應調節策略(GC Ergonomics)。

  • Serial Old收集器是Serial收集器的老年代版本,同樣是單線程的,使用標記-整理算法。這個收集器主要是給在client模式下的虛擬機使用,如果是在server模式下使用,那麼主要有兩個用途:1.在JDK1.5及之前的版本中與Parallel Scavenge配合使用;2.作爲CMS的後背方案,當CMS發生Concurrent Mode Failure時,就會使用Serial Old收集器。

  • Parallel Old收集器Parallel Scavenge的老年代版本,使用多線程和"標記-處理"算法。

  • CMS收集器是一種以獲取最短停頓事件爲目標的收集器。目前大多數的java應用程序都使用在服務器上,所以對響應速度要求很高,CMS很好的滿足了這個要求
    CMS是基於標記-清除算法,它的工作過程分爲四個步驟
    1.初始標記
    2.併發標記
    3.重新標記
    4.併發清除

其中,初始標記和重新標記仍然需要“stop the world"
初始標記只是標記了GC roots的直接關聯對象,速度很快
併發標記階段就是進行GC Roots tracing的過程,而重新標記階段是爲了修正在併發標記期間,由於用戶代碼
繼續運行而導致標記產生變動的那一部分對象的標記記錄。這部分時間比初始標記花費的事件稍長,但遠比並發標記快。
而由於耗時最長的併發標記和併發清除過程是和用戶代碼同時進行,所以從總體來說CMS的收集過程是和用戶代碼同時進行的
CMS的示意圖如下:這裏寫圖片描述
CMS雖然很優秀,但是也有一些缺點,主要如下:

  • CMS對CPU資源特別敏感。CMS默認開啓的收集線程數是(cpu+3)/4,當cpu數量大於4的時候,併發回收垃圾線程不少於25%的cpu資源,隨着cpu數量的增加這個比例越來越低,但是如果cpu數量小於4,比如2,那麼回收垃圾的線程相當於佔了一半的cpu資源,這樣會嚴重影響用戶 代碼的執行效率,因爲多線程之間切換是需要耗費資源的。

  • CMS無法處理浮動垃圾,可能出現"Concurrent Mode Fail"失敗而導致full gc的產生。由於在CMS收集過程中,用戶的代碼還在繼續運行,這樣 會產生新的內存對象,而這些內存對象只能在下次GC的時候進行回收,這些對象就是”浮動對象“。由於CMS垃圾收集器和用戶代碼是同時運行,所以必須在 老年代留出一部分內存空間給用戶代碼使用,在jdk1.5下,CMS默認會在老年代被使用到68%的時候出發CMS收集.這個值是可以通過-XX:CMSinitiatingOccupanyFraction來指定的。如果程序運行期間,預留的內存無法滿足程序的需求,就會出現一次"Concurrent Mode Fail",這時虛擬機就會使用後備方案:Serial Old垃圾收集器。這樣停頓時間就更長了。

  • 由於CMS是基於標記-清除算法的,這個算法會產生內存碎片,如果程序找不到足夠大的連續內存,則會出發一次full gc,爲了解決這個問題CMS,提供了一個參數XX:+UseCMSCompactAtFullCollection,這個參數的作用是,放虛擬機發現內存不足要進行full gc的時候,CMS對內存碎片進行整理由於整理的過程是不能併發的,所以用戶的程序執行會被影響。

  • **G1收集器(Garbage-First)**是當前最前沿的收集器,主要用於服務端應用,與其他收集器相比,它有如下優點:

  • 並行與併發:G1能充分利用cpu資源,既可以使用多線程並行收集垃圾,還可以和用戶程序併發進行

  • 分代收集:G1可以處理新生代和老年代,並對不同存活時間的對象分別對待處理,以獲取更好的收集效果

  • 空間整合:整體上使用標記整理算法,局部又是複製清理算法,這就導致它不會產生內存碎片

  • 可預測的停頓:G1可以指定在M長的時間內,花費在GC上的所用的時間。

G1可以回收老年代和年輕代,它將內存劃分成大小相等的若干個獨立區域(Region),雖然還有年輕代和老年代的概念,但是已經不是物理隔離的了,而是 一些Region的集合。
G1可預測停頓的實現是這樣的,它不再對整個內存區域進行垃圾收集,而是跟蹤各個Region裏面的垃圾堆積的價值大小(回收所獲得的空間大小和回收所需的時間的經驗值) 然後維護一個優先級列表,根據用戶指定的時間,優先回收價值高的Region,這種方式可以在有限的時間內實現內存回收的最大效率。

G1收集器的回收大致可以分爲如下幾步:

  1. 初始標記
  2. 併發標記
  3. 最終標記
  4. 篩選回收

前三個步驟與CMS差不多,在第四個篩選回收階段,會先對被標記Region進行價值排序,然後再根據用戶指定是的時間按價值從高到低的順序回收Region中的內存。

##理解GC日誌##
以下內容來自博客Java GC 介紹並有所補充
瞭解GC日誌可以幫助我們更好地排查一些線上問題,如OOM、應用停頓時間過長等等。GC日誌對我們進行JVM調優也是很有幫助的。採用不同的GC收集器所產生的GC日誌的格式會稍微不同,但虛擬機設計者爲了方便用戶閱讀,將各個收集器的日誌都維持一定的共性。

具有一定共性的的GC日誌格式大致如下所示:

<datestamp>:[GC[<collector>:<start occupancy1>-><end occupancy1>(total size1),<pause time1> secs]<start occupancy2>-><end occupancy2>(total size2),<pause time2> secs] [Times:<user time> <system time>, <real time>]
  • datestamp : 表示GC日誌產生的時間點,如果指定的jvm參數是-XX:+PrintGCTimeStamps,那麼輸出的是相對於虛擬機啓動時間的時間戳,如果指定的是-XX:+PrintGCDateStamps,那麼輸出的是具體的時間格式,可讀性更高
  • GC : 表示發生GC的類型,有GC(代表MinorGC)和FullGC兩種情況
  • collector : 表示GC收集器類型,取值可能是DefNew、ParNew、PSYoungGen、Tenured、ParOldGen、PSPermGen等等
  • start occupancy1 : 表示發生回收之前佔用的內存空間
  • end occupancy1 : 表示發生回收以後還佔用的內存空間
  • total size1 : 該堆區域所擁有的總內存空間
  • pause time1 : 發生垃圾收集的時間
  • start occupancy2 : 表示回收前Java堆內存總佔用空間
  • end occupancy2 : 表示回收後Java堆內存還佔用的總空間
  • total size2 : 表示Java堆內存總空間
  • pause time2 : 表示整個堆回收消耗時間
  • Times:具體時間數據,user:用戶態消耗的時間,sys:內核態消耗的時間,real:操作從開始到結束所經過的牆鍾時間。

具體logsample可以參考博客Java GC 介紹

###垃圾收集相關參數###

  • -XX:+UseSerialGC
    虛擬機運行在client模式下的默認值,使用這個參數表示虛擬機將使用Serial + Serial Old收集器組合進行垃圾回收。
    -XX:+UseSerialGC表示使用這個設置,而-XX:-UseSerialGC表示禁用這個設置。
  • -XX:+UseParNewGC
    使用這個設置以後,虛擬機將使用ParNew + Serial Old收集器組合進行垃圾回收。
  • -XX:+UseConcMarkSweepGC
    使用這個設置以後,虛擬機將使用ParNew + CMS + Serial Old的收集器組合進行垃圾回收。注意Serial Old收集器將作爲CMS收集器出現Concurrent Mode Failure失敗後的後備收集器來進行回收(將會整理內存碎片)。
  • -XX:+UseParallelGC
    虛擬機運行在server模式下的默認值。使用這個設置,虛擬機將使用Parallel Scavenge + Serial Old(PS MarkSweep)的收集器組合進行垃圾回收。
  • -XX:+UseParallelOldGC
    使用這個設置以後,虛擬機將使用Parallel Scavengen + Parallel Old的收集器組合進行垃圾回收。
  • -XX:PretenureSizeThreshold
    設置直接晉升到老年代的對象大小,大於這個參數的對象將直接在老年代分配,而不是在新生代分配。注意這個值只能設置爲字節,如-XX:PretenureSizeThreshold=3145728表示超過3M的對象將直接在老年代分配。
  • -XX:MaxTenuringThreshold
    設置晉升到老年代的對象年齡。每個對象在堅持過一次Minor GC之後,年齡就會加1,當超過這個值時就進入老年代。默認設置爲-XX:MaxTenuringThreshold=15。
  • -XX:ParellelGCThreads
    設置並行GC時進行內存回收的線程數。只有當採用的垃圾回收器是採用多線程模式,包括ParNew,Parallel Scavenge、Parallel Old、CMS,這個參數的設置纔會有效。
  • -XX:CMSInitiatingOccupancyFraction
    設置CMS收集器在老年代空間被使用多少(百分比)後觸發垃圾收集。默認設置-XX:CMSInitiatingOccupancyFraction=68表示老年代空間使用比例達到68%時觸發CMS垃圾收集。僅當老年代收集器設置爲CMS時候這個參數纔有效。
  • -XX:+UseCMSCompactAtFullCollection
    設置CMS收集器在完成垃圾收集後是否要進行一次內存碎片整理。僅當老年代收集器設置爲CMS時候這個參數纔有效。
  • -XX:CMSFullGCsBeforeCompaction
    設置CMS收集器在進行多少次垃圾收集後再進行一次內存碎片整理。如設置-XX:CMSFullGCsBeforeCompaction=2表示CMS收集器進行了2次垃圾收集之後,進行一次內存碎片整理。僅當老年代收集器設置爲CMS時候這個參數纔有效。
  • -XX:SurvivorRatio
    設置新生代中Eden區與survivor區的容量比值,默認爲8,表示eden:survivor=8:1
  • -XX:UseAdaptiveSizePolicy
    動態調整各個區域大小和進入老年代的年齡
  • GCTimeRatio
    GC時間佔總時間比率,默認值爲99,即允許%1的GC時間,僅在使用Parallel Scavenge時生效
  • MaxGCPauseMillis設置GC最大停頓時間,僅在Parallel Scavenge時生效

###GC日誌相關###

  • -XX:+PrintGCDetails
    表示輸出GC的詳細情況。
  • -XX:+PrintGCDateStamps
    指定輸出GC時的時間格式,比指定-XX:+PrintGCTimeStamps可讀性更高。
  • -Xloggc
    指定gc日誌的存放位置。如-Xloggc:/var/log/myapp-gc.log表示將gc日誌保存在磁盤/var/log/目錄,文件名爲myapp-gc.log。

參考資料如下:
《深入理解java虛擬機》周志明
java GC介紹

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