開發與維運

我與GC不得不說的故事——《我的Java打怪日記》

GC作為Java知識體系裡的一個面試熱點,經常是眾多程序猿常常需要複習的內容,藉助這次活動,特將我總結的GC知識點分享給大家,大家共同進步!

GC(垃圾回收)

虛擬機棧是線程獨佔的,也就是說隨著線程初始而初始,消亡而消亡,當線程被銷燬後,虛擬機棧上的內存自然會被回收,即虛擬機棧上的內存空間不在GC範圍。

GC的主要作用是回收程序中(主要是堆中)不再使用的內存。對對象而言,如果沒有任何變量去引用它,那麼該對象就不可能被程序訪問,因此可以認為它是垃圾信息,可以被回收。垃圾回收器使用有向圖來記錄和管理堆內存中的所有對象,通過該有向圖可以識別哪些變量是可達的,哪些是不可達的(沒有引用變量引用即為不可達的),所有不可達的均要被回收。

檢查對象是否存活

  1. 引用計數法:引用計數作為一種簡單但是效率較低的方法,其實現原理如下:在堆中每個對象都有一個引用計數器,當對象被引用過時,引用計數器加1,當引用失效時減1,由於這種方法無法解決相互引用的問題,因此JVM沒有采用這個算法。分析下面這段代碼使用引用計數法可能出現的問題。

image.png

當採用引用計數算法時:

    • 第一步:GcObject實例1被obj1引用,所以它的引用數加1,為1;
    • 第二步:GcObject實例2被obj2引用,所以它的引用數加1,為1;
    • 第三步:obj1的instance屬性指向obj2,而obj2指向GcObject實例2,故GcObject實例2引用加1,為2;
    • 第四步:obj2的instance屬性指向obj1,而obj1指向GcOjbect實例1,故GcObject實例1引用加1,為2;
    • 到此前4步,GcOjbect實例1和GcOjbect實例2的引用數量均為2,此時結果圖如下:

image.png

    • 第五步:obj1不再指向GcOjbect實例1,其引用計數減1,結果為1;
    • 第六步:obj2不再指向GcOjbect實例2,其引用計數減1,結果為1。

到此,可以發現GcObject實例1和實例2的計數引用都不為0,如果採用引用計數算法的話,這兩個實例所佔的內存將得不到釋放,這便產生了內存洩露。

  1. 可達性分析法:通過一系列的稱為“GC roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈,當一個對象到GC root沒有任何引用鏈相連(用圖論的話來說,就是從GC roots到這個對象不可達),則證明此對象是不可用的。

image.png

GC主要對堆中的對象進行回收,方法區、棧不被GC所管理,因而選擇這些區域內的對象作為GCroots。GC會收集那些不是GC roots且沒有被GC roots引用的對象。

常用的垃圾回收算法

  1. 標記清除:利用JVM維護的對象引用圖,從根節點開始遍歷對象的引用圖,同時標記遍歷到的對象,當遍歷結束時,未被標記的對象就是目前已不被使用的對象,可以被回收。如下例子:

image.png

image.png

假設運行到step3後運行step4之前,進行了一次垃圾回收。首先:找出所有的根對象,標誌其是否可回收,這個是通過將同步塊索引的一位設為0來完成的,這個階段為標記階段。在運行到step3後,obj1和obj2和obj3就是這裡的3個根,其中根obj2在棧中已經被pop出去了,因此棧中只有兩個根對象obj1和obj3,如圖所示:

image.png

因為根obj1均引用了object對象1,所以object對象1的同步塊索引的一位被置為1,以標記其不是垃圾,然後檢查根obj3的對象引用情況,發現它也引用了object對象1,當它剛要標誌其同步索引塊的一位時,發現object對象1已經被標記了,則不重新標記它。需要注意的是,在標記object對象1時,發現它引用了其他的對象(假設為a),那麼對象a也對被標記。標記過程會持續,依次檢查完所有的根。若對象在進行可達性分析後發現沒有與GC roots相連接的引用鏈,那麼它將會被第一次標記並進行一次篩選,篩選的條件是該對象是否有必要執行finalize()方法,當對象沒有重寫finalize()方法或者finalize()方法已經被虛擬機調用過,虛擬機將這兩種情況都視為沒必要執行。若該對象被判定為有必要執行finalize方法,則這個對象會被放在一個F-Queue隊列,finalize方法是對象逃脫死亡命運的最後一次機會,稍後GC將對F-queue中的對象進行第二次小規模的標記,若對象要在finalize中成功拯救自己(只要重新與引用鏈上的任何一個對象建立關聯即可),那麼在第二次標記時他們將會被移出即將回收集合。

如果在被標記後直接對對象進行清除,會帶來另一個新的問題——內存碎片化。如果下次有比較大的對象實例需要在堆上分配較大的內存空間時,可能會出現無法找到足夠的連續內存而不得不再次觸發垃圾回收。

  1. 標記整理:標記過程同上,整理:把堆中活動的對象移到堆中一端,這樣就會在堆中另外一端留出很大的一塊空閒區域,相當於對堆中的碎片進行了處理,雖然這種方式會大大簡化消除堆碎片的工作,但是每次處理都會帶來性能的損失。在完成這一步後,有兩個問題亟待解決,第一個是內存壓縮後,其在堆中的實際位置變化了,而引用的位置還是原來的,將會訪問舊的內存地址而造成內存損壞。所以,還要將根中引用的地址減去在壓縮中偏移的字節數,這樣就能保證每個根引用的還是原來的c,只不過對象在內存中變換了位置。第二個問題是指向下一個對象在堆中的分配位置指針也要進行偏移字節數的計算,這樣才能保證新對象分配的內存與原有的堆內存是連續的。

  1. 複製回收算法:把堆分成兩個大小相同的區域,在任何時刻。只有其中的一個區域被使用,當這個區域被消耗完,垃圾回收器會中斷程序的執行,通過遍歷的方式把所有活動的對象複製到另外一個區域中,在複製的過程中,它們是緊挨著佈置的,從而可以消除內存碎片。當複製過程結束後程序接著運行,直到這塊區域被使用完,然後再採用上面的方法進行垃圾回收。

  1. 按代回收算法:複製回收算法有一個缺點,每次算法執行時,所有處於活動狀態的對象都要被複制,這樣效率很低。由於程序有“大部分對象的生命週期都很短,只有一部分對象有較長的生命週期的”特點。因此可以根據這個缺點對算法進行優化。按代回收算法的主要思路如下:把堆分成兩個或者多個子堆,每一個子堆被視為一代。算法在運行的過程中優先收集那些年幼的對象,如果一個對象經過多次收集仍然存活,那麼就可以把這個對象轉移到高一級的堆裡,減少對其的掃描次數。開發人員可以通過System.gc()方法來通知垃圾回收器運行,當然,JVM也不會保證垃圾回收器馬上就會運行,由於該方法的執行會停止所有的響應去檢查內存中是否有可回收的對象,這會對程序的運行及性能造成很大的威脅。

分代收集:

  • 新生代:複製回收算法;
  • 老年代:標記整理或標記清除。

垃圾收集器

這裡討論的虛擬機是基於Jdk1.7之後的HotSpot虛擬機。下圖展示了jdk7中作用於不同分代的收集器,如果兩個收集器之間存在連線就代表它們可以搭配使用。

image.png

新生代垃圾收集器

  1. serial收集器:單線程收集器,單線程的意義不僅僅說明它只會使用一個cpu或者一條垃圾收集線程去完成垃圾收集工作,更重要的是在它進行垃圾收集時,必須暫停其他所有工作線程(Stop The World),直到它收集結束。Stop The World這項工作實際上是由虛擬機在後臺自動發起和自動完成的,在用戶不可見的情況下把用戶正常工作的線程全部停掉。下圖是Serial收集器運行示意圖。直到現在Serial仍然是虛擬機運行在client模式下默認新生代收集器,它簡單而高效,單個CPU環境下沒有線程交互的開銷,採用複製回收算法。

image.png

  1. parNew蒐集器:serial收集器的多線程版本,除了使用多條線程進行垃圾收集之外,其餘行為包括Serial收集器可用的所有控制參數,但在單CPU的環境中,它不會有比Serial收集器更好的效果,是許多運行在server模式下的虛擬機首選的新生代收集器,其中有一個與性能無關但很重要的原因是除了Serial收集器外,目前只有它能與CMS收集器配合工作。工作過程如下圖,複製回收算法。

image.png

  1. parallel scaverge:並行的多線程收集器,目標是達到一個可控制的吞吐量(運行代碼時間/(運行代碼時間+垃圾收集時間)),而其他收集器的目標是儘可能的縮短垃圾收集時用戶線程的停頓時間,停頓時間越短就越適合需要與用戶交互的程序,良好的響應速度能提升用戶體驗,而高吞吐量可以高效的利用CPU時間,儘快完成程序的運行。適合在後臺運算,沒有太多的交互。複製回收算法。

老年代垃圾收集器

  1. serial old:serial收集器的老年代版本,單線程,標記整理算法,這個收集器的主要意義也是在於給Client模式下的虛擬機使用,如果在Server模式下,它主要還有兩大用途:一種用途是在jdk1.5以及之前的版本中與Parallel Scavenge收集器搭配使用,另一種用途是作為CMS的後備預案。工作過程如下圖:

image.png

  1. parallel old:parallel scaverge的老年代版本,多線程,標記整理,工作過程如下:

image.png

  1. cms(Concurrent Mark Sweep)收集器:該收集器是Hotspot虛擬機中第一款具有真正意義上的併發收集器,它第一次實現了讓垃圾收集線程與用戶線程同時工作,但該收集器無法與Paraller Scavenge配合使用,它是一種以獲取最短回收停頓時間為目標的收集器,是基於標記清除算法實現的運作過程較為複雜,整個過程可分為4個過程:初始標記(僅僅只是標記一下GC roots能直接關聯到的對象,速度很快)、併發標記(進行GC Roots tracing的過程)、重新標記(為了修正併發標記期間因並用戶線程繼續運作而導致標記產生變動的那一部分對象的標記記錄,這個階段的停頓時間一般比初始標記階段稍長,但遠比並發標記的時間短) 併發清除。其中初始標記和重新標記階段要stop the world(停止工作線程),整個過程中耗時最長的併發標記和併發清除過程收集器都可以與用戶線程一起工作,總體來說,CMS收集器的內存回收過程是與用戶線程一起併發執行的。優點:併發收集、低停頓;缺點:不能處理浮動垃圾、對cpu資源敏感、產生大量內存碎片(標記清除算法)。運行過程如下:

image.png

浮動垃圾:併發清理階段用戶線程還在運行,這段時間就可能產生新的垃圾,新的垃圾在此次GC無法清除,只能等到下次清理。

G1收集器

優點:

  1. 並行與併發:G1能充分利用多CPU、多核環境下的硬件優勢,使用多個CPU來縮短Stop-The-World停頓的時間,部分其他收集器原本需要停頓Java線程執行的GC動作,G1收集器仍然可以通過併發的方式讓Java程序繼續執行。
  2. 分代收集:與其他收集器一樣,分代概念在G1中依然得以保留。雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但它能夠採用不同的方式去處理新創建的對象和已經存活了一段時間、 熬過多次GC的舊對象以獲取更好的收集效果。
  3. 空間整合:與CMS的標記清理算法不同,G1從整體來看是基於標記整理算法實現的收集器,從局部(兩個Region之間)上來看是基於複製算法實現的,但無論如何,這兩種算法都意味著G1運作期間不會產生內存空間碎片,收集後能提供規整的可用內存。 這種特性有利於程序長時間運行,分配大對象時不會因為無法找到連續內存空間而提前觸發下一次GC。
  4. 可預測的停頓:這是G1相對於CMS的另一大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內,消耗在垃圾收集上的時間不得超過N毫秒。

在G1之前的其他收集器進行收集的範圍都是整個新生代或者老年代,而G1不再是這樣。使用G1收集器時,Java堆的內存佈局就與其他收集器有很大差別,它將整個Java堆劃分為多個大小相等的獨立區域(Region),雖然還保留有新生代和老年代的概念,但新生代和老年代不再是物理隔離的了,它們都是一部分Region(不需要連續)的集合。

G1收集器的運作大致可劃分為以下幾個步驟:初始標記、併發標記、最終標記、篩選回收(首先對各個Region的回收價值和成本進行排序,根據用戶所期望的GC停頓時間來制定回收計劃)。

image.png

image.png

image.png

每個region的大小都是2的倍數,通過設置堆的大小和region的個數(默認2048)計算得出,每個region可能屬於eden,也可能屬於old,且每類區域空間是不連續的,這種將O區劃分成多塊的理念源於:當併發後臺線程尋找可回收的對象時、有些region區包含可回收的對象要比其他區塊多很多。雖然在清理這些區塊時G1仍然需要暫停應用線程、但可以用相對較少的時間優先回收包含垃圾最多區塊。這也是為什麼G1命名為Garbage First的原因:第一時間處理垃圾最多的區塊。

垃圾收集模式

G1中提供了三種模式垃圾回收模式,young gc、mixed gc 和 full gc,在不同的條件下被觸發。

  1. young gc:發生在年輕代的GC算法,一般對象(除了巨型對象)都是在eden region中分配內存,當所有eden region被耗盡無法申請內存時,就會觸發一次young gc,這種觸發機制和之前的young gc差不多,執行完一次young gc,活躍對象會被拷貝到survivor region或者晉升到old region中,空閒的region會被放入空閒列表中,等待下次被使用。

參數 含義
-XX:MaxGCPauseMillis 設置G1收集過程目標時間,默認值200ms
-XX:G1NewSizePercent 新生代最小值,默認值5%
-XX:G1MaxNewSizePercent 新生代最大值,默認值60%

  1. mixed gc:當越來越多的對象晉升到老年代old region時,為了避免堆內存被耗盡,虛擬機會觸發一個混合的垃圾收集器,即mixed gc,該算法並不是一個old gc,除了回收整個young region,還會回收一部分的old region,而不是全部老年代,可以選擇哪些old region進行收集,從而可以對垃圾回收的耗時時間進行控制,mixed gc的觸發時機:

在cms中,如果添加了以下參數:

-XX:CMSInitiatingOccupancyFraction=80 
-XX:+UseCMSInitiatingOccupancyOnly

當老年代的使用率達到80%時,就會觸發一次cms gc。相對的,mixed gc中也有一個閾值參數 -XX:InitiatingHeapOccupancyPercent,當老年代大小佔整個堆大小百分比達到該閾值時,會觸發一次mixed gc。mixed gc的執行過程有點類似cms,主要分為以下幾個步驟:

    • initial mark:初始標記過程,標記了從GC Root直接可達的對象;
    • concurrent marking:併發標記過程,整個過程gc collector線程與應用線程可以並行執行,標記出GC Root可達對象衍生出去的存活對象,並收集各個Region的存活對象信息;
    • remark:最終標記過程,標記出那些在併發標記過程中遺漏的,或者內部引用發生變化的對象;
    • clean up:垃圾清除過程,如果發現一個Region中沒有存活對象,則把該Region加入到空閒列表中。

  1. full gc:如果對象內存分配速度過快,mixed gc來不及回收,導致老年代被填滿,就會觸發一次full gc,G1的full gc算法就是單線程執行的serial old gc,會導致異常長時間的暫停時間,需要進行不斷的調優,儘可能的避免full gc。

內存分配

對象主要分配在新生代的Eden區上,如果啟動了本地線程分配緩衝,將按線程優先在TLAB上分配,少數情況下也可能直接分配在老年代中,分配的規則並不是百分之百固定的,其細節取決於當前使用的是哪一種垃圾收集器組合還有虛擬機中與內存相關參數的設置。下面是最普遍的內存分配規則:

  1. 大多數情況下,對象在新生代eden區中分配,當Eden區中沒有足夠的內存空間進行分配時,虛擬機將發起一次minor GC。

-XX:+PrintGCDetails​ :告訴虛擬機在發生垃圾收集行為時打印內存回收日誌,並且在進程退出的時候輸出當前的內存各區域分配情況。

image.png

  1. 大對象直接進入老年代:大對象是指需要大量連續內存空間的java對象,最典型的大對象就是那種很長的字符串以及數組,大對象對虛擬機的內存分配來說就是一個壞消息,尤其是朝生夕滅的短命大對象,寫程序的時候應當避免,經常出現大對象容易導致內存還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來存放它們。

-XX:PretenureSizeThreshold​,令大於這個設置值的對象直接在老年代分配,這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的內存複製

  1. 長期存活的對象進入老年代:每個對象有一個對象年齡Age計數器,如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被Survivor容納的話,將被移動到Survivor空間中,並且對象年齡設為1。 對象在Survivor區中每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(默認為15歲),就會被晉升到老年代中。對象晉升老年代的年齡閾值,可以通過參數​-XX:MaxTenuringThreshold​設置。

  1. 動態對象年齡判斷:若在survivor空間中相同年齡所有對象大小的總和>survivor 空間的一半,則年齡大於等於該年齡的對象直接進入老年代,無須等到MaxTeuringThreshold(默認為15)中的要求。

分配擔保

在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大於新生代所有對象總空間,如果這個條件成立,那麼Minor GC可以確保是安全的。 如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。 如果允許,那麼會繼續檢查老年代最大可用的連續空間是否大於歷次晉升到老年代對象的平均大小,如果大於,將嘗試著進行一次Minor GC,儘管這次Minor GC是有風險的;如果小於,或者HandlePromotionFailure設置不允許冒險,那這時也要改為進行一次Full GC。

內存洩露

內存洩漏大家都不陌生了,簡單粗俗的講,就是該被釋放的對象沒有釋放,一直被某個或某些實例所持有卻不再被使用導致 GC 不能回收。內存洩露是指一個不再被程序使用的對象或者變量還在內存中佔有存儲空間。一般來說,內存洩露主要有兩種情況,一是堆中申請的空間沒有被釋放,二是對象已不再被使用,但仍然在內存中保留著。垃圾回收機制的引入可以有效的解決第一種情況,而對於第二種情況,垃圾回收機制無法保證不再使用的對象會被釋放。Java中引起內存洩露的原因主要有以下幾個方面的內容:

  1. 靜態集合類:例如HashMap和Vector,如果這些容器是靜態的,由於它們的生命週期與程序一致,那麼容器中的對象在結束之前將不會被釋放,從而造成內存洩露。如下例:

image.png

  1. 各種連接,比如數據庫連接、網絡連接以及IO連接,若不關閉相應的連接,會造成內存洩露。
  2. 監聽器:通常一個應用中會用到多個監聽器,但在釋放對象的同時往往沒有相應的刪除監聽器,從而造成內存洩露。
  3. 變量不合理的作用域:變量定義的作用範圍大於其使用範圍,很有可能造成內存洩露。
  4. 單例模式:若單例模式中包含對對象的引用。

由於單例對象以靜態變量的方式存儲,因此它在JVM的整個生命週期中都存在,若它含有對其他對象的引用,則會造成其他對象的類不能被回收。

minor GC、major GC、full GC區別

新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java對象大多都具備朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。

老年代 GC(Major GC):指發生在老年代的GC,。MajorGC的速度一般會比Minor GC慢10倍以上。

Full GC是針對整個堆來說的,出現full gc的時候經常伴隨著至少一次的minor gc,但並非絕對的,

image.png

堆內存劃分為Eden、Survivor和Tenured/Old 空間。虛擬機給每個對象定義了一個對象年齡計數器。如果對象在Eden出生並經過第一次Minor GC後仍然存活,並且能被 Survivor容納的話,將被移動到Survivor空間中,並將對象年齡設為 1。對象在Survivor區中每熬過一次Minor GC,年齡就增加 1 歲,當它的年齡增加到一定程度(默認為15歲)時,就會被晉升到老年代中。

商業虛擬機:將內存分為一塊較大的eden空間和兩塊較小的survivor空間,默認比例是8:1:1,即每次新生代中可用內存空間為整個新生代容量的90%,每次使用eden和其中一個survivour。當回收時,將eden和survivor中還存活的對象一次性複製到另外一塊survivor上,最後清理掉eden和剛才用過的 survivor,若另外一塊survivor空間沒有足夠內存空間存放上次新生代收集下來的存活對象時,這些對象將直接通過分配擔保機制進入老年代。

觸發full gc的情況:

  • System.gc()方法的調用(此方法會建議jvm進行full gc);
  • 老年代空間不足:老年代空間只有在新生代對象轉入或創建大對象、大數組時才會出現不足的現象,當執行Full GC後空間仍然不足,則拋出如下錯誤: ​java.lang.OutOfMemoryError: Java heap space​,為避免以上兩種狀況引起的Full GC,調優時應儘量做到讓對象在Minor GC階段被回收、讓對象在新生代多存活一段時間及不要創建過大的對象及數組;
  • 永久代空間不足:當系統中要加載的類、反射的類和調用的方法較多時,Permanet Generation可能會被佔滿,在未配置為採用CMS GC的情況下也會執行Full GC。如果經過Full GC仍然回收不了,那麼JVM會拋出如下錯誤信息: ​java.lang.OutOfMemoryError: PermGen space​。為避免Perm Gen佔滿造成Full GC現象,可採用的方法為增大Perm Gen空間或轉為使用CMS GC;
  • 統計得到的Minor GC晉升到老年代的平均大小大於老年代的剩餘空間;
  • Full gc還會回收方法區和堆外內存。

查看gc情況

jstat通常用來分析系統的垃圾回收情況。

jstat -gcutil pid 2000(每隔2秒輸出一次結果)

image.png

其中S0、S1 代表兩個Survivor區;E 代表 Eden 區;O(Old)代表老年代;P(Permanent)代表永久代;YGC(Young GC)代表Minor GC;YGCT代表Minor GC耗時;FGC(Full GC)代表Full GC耗時;GCT代表Minor & Full GC共計耗時。

Leave a Reply

Your email address will not be published. Required fields are marked *