1. 程式人生 > >JAVA虛擬機器之一:垃圾回收(GC)機制

JAVA虛擬機器之一:垃圾回收(GC)機制

引言 java對於其它語言(c/c++)來說,建立一個物件使用後,不用顯式的delete/free,且能在一定程度上保證系統記憶體資源及時回收,這要功歸於java的自動垃圾回收機制(Garbage Collection,GC),但也是因為自動回收機制存在,一旦系統內洩漏或存溢位時,排查問題比較困難,因此java程式開發者深入理解java虛擬機器GC機制變得重要。 要掌握GC機制,需要搞清楚下面幾個問題: 1、執行時有哪些記憶體區域? 2、執行時怎麼給類、物件分配記憶體? 3、哪些區域的記憶體需要回收? 4、記憶體中的哪些物件可以回收? 5、如何回收? 一、執行時有哪些記憶體區域? 根據java虛擬機器規範規定,java虛擬機器所管理的執行時記憶體包括以下區域,如下圖:

  1、程式計數器:每一條java執行緒都有一個獨立的程式計數器,我們把執行緒相互獨立隔離的區域叫執行緒私有的,它的作用可以看作是當前執行緒所執行的位元組碼的行號指示器,它是一塊較小的空間區域,如果執行的是java方法,這個計數器記錄的是正在執行的虛擬機器位元組碼的指令地址,如果是native的方法,這個計數器的值為空(undefined)。 2、java虛擬機器棧:java虛擬機器棧與程式計數器一樣,也是一條執行緒私有的,java虛擬機器棧描述的是java方法執行的記憶體模型,每個方法被執行的時候都會同時建立一個棧幀(Stack Frame)用於儲存區域性變量表,運算元棧,動態鏈路,方法出口等資訊。每一個方法被呼叫直至執行完成的過程,就對應著一個棧幀在虛擬機器棧中從入棧到出棧的過程。 3、本地方法棧:
本地方法棧和虛擬機器棧作用非常相似,不同的是java虛擬機器棧是為執行的是java方法服務的,而本地方法棧是為native的方法執行服務的。 4、java堆:java堆(heap)是java虛擬機器所管理的記憶體中最大的一塊。java堆是被所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項以及陣列都要在堆上分配記憶體。在堆上的記憶體是分代管理的,分為新生代和老年代,新生代又細分為:Eden,From Survivor,To Survivor,它們空間大小比例為8:1:1。 5、方法區:方法區與java堆一樣,是各個執行緒共享的記憶體區域,它用用於儲存已被虛擬機器載入的類資訊,常量,靜態變數、即時編譯器編譯後的程式碼等資料。雖然java虛擬機器規範把方法區描述為堆得一個邏輯部分,但是它卻有一個別名叫Non-Heap(非堆),目的應該是與java堆區分開來,也稱“永久代”(Permanent Generation)。hotspot虛擬機器永久代已經完全在JDK 8移除,用Native Memory來的實現,命名為metaSpace,
https://blogs.oracle.com/poonam/entry/about_g1_garbage_collector_permanent
。在下圖左右是分別是jdk6,jdk8中jvisualvm的執行時資料記憶體的監控。
       6、執行時常量池:執行時常量池是方法區的一部分。用於存放編譯期生成的各種字面量和符號引用。 二、執行時怎麼給類、物件分配記憶體? 要了解java垃圾回收機制前必須知道java怎麼分配給物件記憶體的,根據上面執行時資料區域的劃分可以知道,幾乎所有的物件都在堆上分配,而類資訊、常量、靜態變數在方法區分配。堆記憶體是分代管理的,物件優先在Eden分配;大物件(所謂的大物件是指需要連續記憶體空間的java物件,如很長的字串或者陣列)直接進入老年代;長期存活的物件將進入老年代,在垃圾回收時在Survivor中每熬過一次youngGC,他的年齡就增加1,直到到達指定的年齡就會被放入老年代。 三、那些區域的記憶體需要回收? 根據執行時資料區域的各個部分,程式計數器、虛擬機器棧、本地方法棧三個區域隨著執行緒而生,隨執行緒滅而滅。棧中的棧幀隨著方法的進入和退出而進棧出棧。每個棧幀分配多少記憶體在類結構確定下來的時候就基本已經確定。所以這個三個區域記憶體回收時方法或者執行緒結束而回收的,不需要太多關注;而java堆和方法區則不一樣,一個介面不同實現類,一個方法中不同的分支,在具體執行的時候才能確定建立那些物件,所以這部分記憶體是動態的,也是需要垃圾回收機制來回收處理的。 四、記憶體中的哪些物件可以回收? 1、堆記憶體:判斷堆內的物件是否可以回收,要判斷這個物件例項是否確實沒用,判斷演算法有兩種:引用計數法和根搜尋演算法。 引用計數法:就是給每個物件加一個計數器,如果有一個地方引用就加1,當引用失效就減1;當計數器為0,則認為物件是無用的。這種演算法最大的問題在於不能解決相互引用的物件,如:A.b=B;B.a=A,在沒有其他引用的情況下,應該回收;但按照引用計數法來計算,他們的引用都不為0,顯然不能回收。 根搜尋演算法:這個演算法的思路是通過一系列名為“GC Roots”的物件作為起點,從這個節點向下搜尋,搜尋所經過的路徑稱為引用鏈(Reference Chain),當一個物件到GC Roots沒有任何引用鏈相連(圖論的不可達)時,則證明該物件不可用。 java等一大部分商用語言是用根搜尋演算法來管理記憶體的,java中可以做為GC Roots的物件有如下幾種:
  • 虛擬機器棧(棧幀中的本地變量表)中的引用的物件;
  • 方法區中的類靜態屬性引用的物件;
  • 方法區中常量引用的物件;
  • 本地方法棧JNI(Native)的引用物件;
2、方法區:方法區回收主要有兩部分:廢棄的常量和無用的類。廢棄的常量判斷方法和堆中的物件類似,只要判斷沒有地方引用就可以回收。相比之下,判斷一個類是否無用,條件就比較苛刻,需要同事滿足下面3個條件才能算是“無用的類”:
  • 該類的所有例項都已經被回收,也就是java堆中不存在該類的任何例項;
  • 載入該類的ClassLoader已經被回收;
  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。
虛擬機器可以對於滿足上面三個條件的無用類進行回收,僅僅是可以回收,具體能否回收,JVM提供了-Xnoclassgc引數進行控制。 五、如何回收? gc有多種演算法,根據不同的演算法實現了不同的垃圾回收器,每種收集器在可以在不同的應用場景使用。 1、回收演算法:
  • 標記-清除(Mark-Sweep)演算法:如它的名字一樣,演算法分“標記”和“清除”兩個階段:首先標記出所有需要回收的物件,在標記完成後統一回收掉被標記的物件。主要有兩個缺點:一個是效率問題,標記和清除效率都不高;另一個是空間問題:標記清除後會產生大量空間碎片。
  • 複製(Copying)演算法:它將記憶體按容量分成大小相等的兩塊,每次只用一塊,當這一塊記憶體用完後,就將可用的物件複製到另外一塊上面,然後一次性清除已用過那塊的記憶體空間。優點是實現簡單,執行效率高,缺點是記憶體縮小為原來的一半。
  • 標記整理(Mark-Compact)演算法:此演算法仍然與標記-清除演算法一樣,第一步標記,第二步不是對無用物件清理,而是,讓所有可用物件都向一端移動,然後直接清理掉端邊界以外的記憶體。標記整理演算法的優點是不會產生空間碎片。
  • 分代收集(Generation Collection)演算法:分代收集演算法根據物件存活週期的不同將記憶體劃為幾塊,一般把java堆分為新生代和老年代,這樣就可以根據各個年代的特點採用最合適的收集演算法。在新生代中,每次垃圾回收時都發現大批物件死去,只有少量存活,那就選用複製演算法,付出少量複製成本就可以完成收集。而老年代中物件存活率較高且沒有空間進行擔保(後面講新生代的擔保分配),就必須使用“標記-清除”或者“標記-整理”演算法。
2、垃圾回收器,垃圾回收器是垃圾回收演算法的具體實現,一般不同的廠商或者不同版本的虛擬機器都包含不同的垃圾收集器,並且一般會提供引數供使用者選擇在不用業務場景下組合出各個年代所使用的收集器。Hotspot虛擬機器包含垃圾收集器如下圖:         

                     
  • Serial(序列GC)收集器 :Serial收集器是一個新生代收集器,單執行緒執行,使用複製演算法。它在進行垃圾收集時,必須暫停其他所有的工作執行緒(使用者執行緒)。是Jvm client模式下預設的新生代收集器。對於限定單個CPU的環境來說,Serial收集器由於沒有執行緒互動的開銷,專心做垃圾收集自然可以獲得最高的單執行緒收集效率。 
  • ParNew(並行GC)收集器 :ParNew收集器其實就是serial收集器的多執行緒版本,除了使用多條執行緒進行垃圾收集之外,其餘行為與Serial收集器一樣。 
  • Parallel Scavenge(並行回收GC)收集器 :Parallel Scavenge收集器也是一個新生代收集器,它也是使用複製演算法的收集器,又是並行多執行緒收集器。parallel Scavenge收集器的特點是它的關注點與其他收集器不同,CMS等收集器的關注點是儘可能地縮短垃圾收集時使用者執行緒的停頓時間,而parallel Scavenge收集器的目標則是達到一個可控制的吞吐量。吞吐量= 程式執行時間/(程式執行時間 + 垃圾收集時間),虛擬機器總共運行了100分鐘。其中垃圾收集花掉1分鐘,那吞吐量就是99%。 
  • Serial Old(序列GC)收集器 :Serial Old是Serial收集器的老年代版本,它同樣使用一個單執行緒執行收集,使用“標記-整理”演算法。主要使用在Client模式下的虛擬機器。 
  • Parallel Old(並行GC)收集器 :Parallel Old是Parallel Scavenge收集器的老年代版本,使用多執行緒和“標記-整理”演算法。 
  • CMS(併發GC)收集器 :CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。CMS收集器是基於“標記-清除”演算法實現的,整個收集過程大致分為4個步驟:
      ①.初始標記(CMS initial mark)
                ②.併發標記(CMS concurrenr mark)             ③.重新標記(CMS remark)             ④.併發清除(CMS concurrent sweep)      其中初始標記、重新標記這兩個步驟任然需要停頓其他使用者執行緒。初始標記僅僅只是標記出GC ROOTS能直接關聯到的物件,速度很快,併發標記階段是進行GC ROOTS 根搜尋演算法階段,會判定物件是否存活。而重新標記階段則是為了修正併發標記期間,因使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間會被初始標記階段稍長,但比並發標記階段要短。      由於整個過程中耗時最長的併發標記和併發清除過程中,收集器執行緒都可以與使用者執行緒一起工作,所以整體來說,CMS收集器的記憶體回收過程是與使用者執行緒一起併發執行的。 CMS收集器的優點:併發收集、低停頓,但是CMS還遠遠達不到完美,器主要有三個顯著缺點: CMS收集器對CPU資源非常敏感。在併發階段,雖然不會導致使用者執行緒停頓,但是會佔用CPU資源而導致引用程式變慢,總吞吐量下降。CMS預設啟動的回收執行緒數是:(CPU數量+3) / 4。 CMS收集器無法處理浮動垃圾,可能出現“Concurrent Mode Failure“,失敗後而導致另一次Full  GC的產生。由於CMS併發清理階段使用者執行緒還在執行,伴隨程式的執行自熱會有新的垃圾不斷產生,這一部分垃圾出現在標記過程之後,CMS無法在本次收集中處理它們,只好留待下一次GC時將其清理掉。這一部分垃圾稱為“浮動垃圾”。也是由於在垃圾收集階段使用者執行緒還需要執行, 即需要預留足夠的記憶體空間給使用者執行緒使用,因此CMS收集器不能像其他收集器那樣等到老年代幾乎完全被填滿了再進行收集,需要預留一部分記憶體空間提供併發收集時的程式運作使用。在預設設定下,CMS收集器在老年代使用了68%的空間時就會被啟用,也可以通過引數-XX:CMSInitiatingOccupancyFraction的值來提供觸發百分比,以降低記憶體回收次數提高效能。要是CMS執行期間預留的記憶體無法滿足程式其他執行緒需要,就會出現“Concurrent Mode Failure”失敗,這時候虛擬機器將啟動後備預案:臨時啟用Serial Old收集器來重新進行老年代的垃圾收集,這樣停頓時間就很長了。所以說引數-XX:CMSInitiatingOccupancyFraction設定的過高將會很容易導致“Concurrent Mode Failure”失敗,效能反而降低。 最後一個缺點,CMS是基於“標記-清除”演算法實現的收集器,使用“標記-清除”演算法收集後,會產生大量碎片。空間碎片太多時,將會給物件分配帶來很多麻煩,比如說大物件,記憶體空間找不到連續的空間來分配不得不提前觸發一次Full  GC。為了解決這個問題,CMS收集器提供了一個-XX:UseCMSCompactAtFullCollection開關引數,用於在Full  GC之後增加一個碎片整理過程,還可通過-XX:CMSFullGCBeforeCompaction引數設定執行多少次不壓縮的Full  GC之後,跟著來一次碎片整理過程。
  • G1收集器:在G1中,堆被劃分成 許多個連續的區域(region)。每個區域大小相等,在1M~32M之間。JVM最多支援2000個區域,可推算G1能支援的最大記憶體為2000*32M=62.5G。區域(region)的大小在JVM初始化的時候決定,也可以用-XX:G1HeapReginSize設定。在G1中沒有物理上的Yong(Eden/Survivor)/Old Generation,它們是邏輯的,使用一些非連續的區域(Region)組成的。

3、垃圾收集(Garbage Collection),新生代的GC叫YongGC,也叫MinorGC,指發生在新生代的垃圾回收動作,因為java具備朝生夕滅特性,所以YongGC非常頻繁,一般回收集比較快;老年代GC叫FullGC,也叫Major GC,一般都伴有YongGC,GC的速度一般比YongGC慢10倍以上。目前虛擬機器實現都是分代收集(G1物理上是不連續的,是邏輯分代,這裡主要以jdk1.7之前為例),當要給物件分配空間時,在Eden上分配空間,如果空間不夠,則觸發一次YongGC,如果空間夠,則分配空間,如果還不夠則直接進入老年代;當一次YongGC後,從Eden,From Survivor的物件放入To Survivor,如果放不下,則進入老年代;每次Yong GC 後還留在Survivor中的物件,物件的年齡Age加1,達到一定年齡(預設為15,可用引數-XX:MaxTenuringThreshold設定)後自動進入老年代;在發生Yong GC時,虛擬機器會檢測之前每次晉升到老年代的平均大小是否大於老年代的剩餘空間大小,如果大於,則改為直接進行一次Full GC。如果小於則看HandlePromotionFailure設定是否允許擔保失敗,如果允許,那隻會進行Minor GC;如果不允許,則也要改為進行一次Full GC。 總結: java GC主要主要指java堆和方法區的物件回收,哪些物件可以回收是通過根搜尋演算法來判斷的,在堆中是分代收集的,怎麼回收是由具體的垃圾收集器來完成的,在不同的應用場景下,開發者可以選擇不同的收集器來滿足業務需求,達到最佳效能。 參考資料: 1、深入理解java虛擬機器-周志民 2、The Java® Virtual Machine Specification Java SE 8 Edition