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