1. 程式人生 > >《深入理解JAVA虛擬機器》詳細解讀(第三章 ):垃圾收集器與記憶體分配策略

《深入理解JAVA虛擬機器》詳細解讀(第三章 ):垃圾收集器與記憶體分配策略

 

目錄

一、垃圾收集器與記憶體分配策略

1 概述

2 物件已經死亡?

2.1引用計數法(未使用)

2.2可達性分析演算法

2.3 再談引用

2.4 生存還是死亡

2.5 回收方法區

3 垃圾收集演算法

3.1 複製演算法(Copy)

3.2 標記-清除演算法(Mark-Sweep)

3.3 標記-整理演算法(Mark-Compact)

3.4分代收集演算法

4 Hotspot的GC演算法實現

4.1列舉根節點

4.2安全點(SafePoint)

4.3安全區域(Safe Region)

5 垃圾收集器

5.1 Serial序列收集器(新生代)

5.2 ParNew收集器(新生代)

5.3 Parallel Scavenge收集器(新生代)

5.4 Serial Old收集器(老生代)

5.5 Parallel Old收集器(老生代)

5.6 CMS收集器(老生代)

5.7 G1收集器

6 理解GC日誌

7 垃圾收集器的引數總結

8 記憶體分配與回收策略

8.1物件優先在Eden區分配

8.2 大物件直接進入老年代

8.3 長期存活的物件將進入老年代

8.4 動態物件年齡判定

總結:

PS:博文僅作為個人學習筆記,如有錯誤歡迎指正,轉載請註明出處~


一、垃圾收集器與記憶體分配策略

Java與C++之間有一堵由記憶體動態分配和垃圾收集技術所圍成的“高牆”,牆外面的人想進去,牆裡面的人卻想出來。

本節常見面試題(推薦帶著問題閱讀,問題答案在文中都有提到):

  • 如何判斷物件是否死亡(兩種方法)。
  • 簡單的介紹一下強引用、軟引用、弱引用、虛引用(虛引用與軟引用和弱引用的區別、使用軟引用能帶來的好處)。
  • 垃圾收集有哪些演算法,各自的特點?
  • HotSpot為什麼要分為新生代和老年代?
  • 常見的垃圾回收器有那些?
  • 介紹一下CMS,G1收集器。
  • Minor Gc和Full GC 有什麼不同呢?

1 概述

說到垃圾收集(Garbage Collection,GC)

,大部分人都把這項技術當做Java語言的伴生產物。事實上,GC的歷史比Java更久遠。說到GC,我們首先必須考慮以下三個問題:

  • 哪些垃圾需要回收?

  • 什麼時候回收?

  • 如何回收?

目前記憶體的動態分配與記憶體回收技術已經相當成熟,一切看起來都進入了“自動化”時代,那麼為什麼我們還要去了解GC和記憶體分配呢?

答案很簡單:當需要排查各種 記憶體溢位問題、當垃圾收集稱為系統達到更高併發的瓶頸時,我們就需要對這些“自動化”的技術實施必要的監控和調節。

看過上一篇部落格《深入理解JAVA虛擬機器》詳細解讀(第二章 ):JAVA記憶體區域與記憶體溢位異常

的朋友相信已經瞭解,Java的記憶體分為程式計數器棧,元空間(JDK8)和堆。

在編譯程式程式碼的時候,棧幀需要多大的區域性變量表,多深的運算元棧都已經完全確定了,並且寫入到方法表的code屬性中,因此一個棧幀需要分配多少記憶體,不會受到執行期變數資料的影響,而僅僅取決於具體的虛擬機器實現,不需要GC關注。

而一個程式只要還在執行,他的程式計數器就一直處在被使用的狀態,也不需要GC關注。

Java堆是所有執行緒共享的一塊記憶體區域,在虛擬機器啟動時建立。此記憶體區域的唯一目的就是存放物件例項,幾乎所有的物件例項以及陣列都在這裡分配記憶體。既然物件是在執行時建立的,由於繼承,介面等原因,同一個型別的物件所需要的記憶體可能不同,這部分的記憶體分配也將是動態的,所以GC主要關注的就是這一部分的記憶體。

2 物件已經死亡?

堆中幾乎放著所有的物件例項,對堆垃圾回收前的第一步就是要判斷那些物件已經死亡(即不能再被任何途徑使用的物件)

2.1引用計數法(未使用)

給物件中新增一個引用計數器,每當有一個地方引用它,計數器就加1;當引用失效,計數器就減1;任何時候計數器為0的物件就是不可能再被使用的。

這個方法實現簡單,效率高,但是目前主流的虛擬機器中並沒有選擇這個演算法來管理記憶體,其最主要的原因是它很難解決物件之間相互迴圈引用的問題。A和B互相持有對方物件的引用,這個計數器永遠無法為0。

2.2可達性分析演算法

這個演算法的基本思想就是通過一系列的稱為 “GC Roots” 的物件作為起點,從這些節點開始向下搜尋,節點所走過的路徑稱為引用鏈,當一個物件到GC Roots沒有任何引用鏈相連的話,則證明此物件是不可用的(換句話說,就是從GC Roots到這個物件不可達)。如圖,Object 5,6,7雖然互相關聯,但他們到GC Roots是不可達的,被判定回收。

在Java語言中,可作為GC Roots的物件包括以下幾種:

  • 虛擬機器棧(棧楨中的本地變量表)中引用的物件
  • 本地方法棧中JNI(Native方法)引用的物件
  • 方法區中類靜態屬性引用的物件
  • 方法區中常量引用的物件

2.3 再談引用

如果判斷物件是否存活僅僅依靠物件是否被引用,而被分成已引用未引用兩部分,未免太過狹隘。如何描述一個“食之無味,棄之可惜”的物件在這種情況下就顯得無能為力。

我們希望能夠描述這樣一類物件:當記憶體空間足夠時,能正常儲存在記憶體中;如果GC回收後記憶體依然緊張,則可以拋棄這類物件,很多系統的快取功能都符合這樣的應用場景,這裡僅做部分引申不具體討論。

引申:快取的維護策略

LRU(Least Recently Used):最近最少使用 
LFU(Least Frequently Uesd):最不經常使用 
FIFO(First in First Out):先進先出 當然快取的更新策略會更加複雜,這裡不作更多討論。

JDK1.2以後,Java對引用的感念進行了擴充,將引用分為強引用軟引用弱引用虛引用四種(引用強度逐漸減弱)

1.強引用(StrongReference)

以前我們使用的大部分引用實際上都是強引用,這是使用最普遍的引用,類似“Object A = new Object()”。如果一個物件具有強引用,那就類似於必不可少的生活用品,垃圾回收器絕不會回收它。當記憶體空 間不足,Java虛擬機器寧願丟擲OutOfMemoryError錯誤,使程式異常終止,也不會靠隨意回收具有強引用的物件來解決記憶體不足問題。

2.軟引用(SoftReference)

如果一個物件只具有軟引用,那就類似於可有可無的生活用品。如果記憶體空間足夠,垃圾回收器就不會回收它,如果記憶體空間不足了,就會回收這些物件的記憶體。只要垃圾回收器沒有回收它,該物件就可以被程式使用。軟引用可用來實現記憶體敏感的快取記憶體。在JDK1.2之後,提供了SoftRefrence類來實現軟引用。

軟引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果軟引用所引用的物件被垃圾回收,JAVA虛擬機器就會把這個軟引用加入到與之關聯的引用佇列中。

3.弱引用(WeakReference)

如果一個物件只具有弱引用,那就類似於可有可無的生活用品弱引用與軟引用的區別在於:只具有弱引用的物件擁有更短暫的生命週期。在垃圾回收器執行緒掃描它 所管轄的記憶體區域的過程中,一旦發現了只具有弱引用的物件,不管當前記憶體空間足夠與否,都會回收它的記憶體。不過,由於垃圾回收器是一個優先順序很低的執行緒, 因此不一定會很快發現那些只具有弱引用的物件。在JDK1.2之後,提供了WeakReference類來實現軟引用。

弱引用可以和一個引用佇列(ReferenceQueue)聯合使用,如果弱引用所引用的物件被垃圾回收,Java虛擬機器就會把這個弱引用加入到與之關聯的引用佇列中。

4.虛引用(PhantomReference)

"虛引用"顧名思義,就是形同虛設,與其他幾種引用都不同,虛引用並不會決定物件的生命週期。如果一個物件僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。

虛引用主要用來跟蹤物件被垃圾回收的活動

虛引用與軟引用和弱引用的一個區別在於 虛引用必須和引用佇列(ReferenceQueue)聯合使用。當垃圾回收器準備回收一個物件時,如果發現它還有虛引用,就會在回收物件的記憶體之前,把這個虛引用加入到與之關聯的引用佇列中。程式可以通過判斷引用佇列中是 否已經加入了虛引用,來了解被引用的物件是否將要被垃圾回收。程式如果發現某個虛引用已經被加入到引用佇列,那麼就可以在所引用物件的記憶體被回收之前採取必要的行動。在JDK1.2之後提供了PhantomReference類來實現軟引用。

特別注意:

在程式設計中一般很少使用弱引用與虛引用,使用軟引用的情況較多,這是因為軟引用可以加速JVM對垃圾記憶體的回收速度,可以維護系統的執行安全,防止記憶體溢位(OutOfMemory)等問題的產生。

2.4 生存還是死亡

即使在可達性分析法中不可達的物件,也並非是“非死不可”的,這時候它們暫時處於“緩刑階段”。要真正宣告一個物件死亡,至少要經歷兩次標記過程

第一次篩選

可達性分析法中不可達的物件被第一次標記並且進行一次賽選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize方法,或finalize方法已經被虛擬機器呼叫過時,虛擬機器將這兩種情況視為沒有必要執行直接回收。

第二次篩選

被判定為需要執行的物件將會被放在一個叫做F-Queue(ReferenceQueue的佇列中,並稍後由一個虛擬機器自動建立的低優先順序的Finalizer執行緒去執行他,這就是第二次篩選。這裡所謂的“執行”,是指虛擬機器僅會觸發這個方法而不會等待,因為如果這個方法執行的時間很長或者發生死迴圈以及更加極端的情況,將很可能使得F-Queue中的所有物件永遠處於等待狀態,導致整個回收系統的崩潰。

finalize()方法是物件逃脫死亡命運的最後一次機會(finalize()方法僅會被虛擬機器呼叫一次),稍後GC將對F-Queue中的物件進行第二次小規模的標記,除非這個物件與引用鏈上的任何一個物件建立關聯從而移出F-Queue,否則就會被真的回收。

2.5 回收方法區

雖然虛擬機器規範不要求虛擬機器在方法區實現垃圾收集,而且在方法區的垃圾收集效率遠低於堆中。尤其是新生代中的垃圾收集,一次往往可以回收70%-95%的空間,然爾在特定情況下,方法區的垃圾回收也是必要的。

方法區(或者說永生代/元空間)的垃圾收集主要回收兩部分內容:廢棄常量無用的類

廢棄常量

廢棄常量的回收和堆中物件的回收有點類似,如果字串常量池中的字串沒有被任何地方引用,而且必要的話,就會被回收。

無用的類

判定一個常量是否是“廢棄常量”比較簡單,而要判定一個無用的類”需要同時滿足下面3個條件 :

  • 該類所有的例項都已經被回收,也就是Java堆中不存在該類的任何例項。

  • 載入該類的ClassLoader已經被回收。

  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法。

虛擬機器對以上無用的類僅僅是可以回收,而不是必須回收,具體還要看虛擬機器的設定。在大量使用反射、動態代理、CGli'b等ByteCode框架、動態生成JSP以及OSGi這類頻繁自定義ClassLoader的場景,都需要虛擬機器具備解除安裝類的能力,以保證永生代不會發生記憶體溢位的現象。

3 垃圾收集演算法

3.1 複製演算法(Copy)

為了解決效率問題,“複製”收集演算法出現了。它可以將記憶體分為大小相同的兩塊,每次使用其中的一塊。當這一塊的記憶體使用完後,就將還存活的物件複製到另一塊去,然後再把使用的空間一次清理掉。這樣就使每次的記憶體回收都是對記憶體區間的一半進行回收。

為了方便理解,我畫了示意圖:

IBM公司的專門研究表明,新生代中的物件98%是“朝生昔死”的,所以通常from-survivor,eden和to-survivor的比例為1:8:1

3.2 標記-清除演算法(Mark-Sweep)

在物件存活率較高時使用複製演算法會浪費大量記憶體,所以GC規定經歷過16次minorGC還沒銷燬的物件會被放入老生代來回收。

演算法分為“標記”和“清除”階段:首先標記出所有需要回收的物件,在標記完成後統一回收所有被標記的物件。它是最基礎的收集演算法,會帶來兩個明顯的問題;

1:效率問題

2:空間問題(標記清除後會產生大量不連續的碎片)

為了方便理解,我畫了示意圖:

 

3.3 標記-整理演算法(Mark-Compact)

根據老年代的特點特出的一種標記演算法,標記過程仍然與“標記-清除”演算法一樣,但後續步驟不是直接對可回收物件回收,而是讓所有存活的物件向一段移動,然後直接清理掉端邊界以外的記憶體。很顯然,整理這一下需要時間,所以與標記清除演算法相比,這一步花費了不少時間,但從長遠來看,這一步還是很有必要的。該演算法可謂“道德高尚,自己栽樹,後人乘涼”

 

3.4分代收集演算法

當前虛擬機器的垃圾收集都採用分代收集演算法,這種演算法沒有什麼新的思想,只是根據物件存活週期的不同將記憶體分為幾塊。一般將java堆分為新生代老年代,這樣我們就可以根據各個年代的特點選擇合適的垃圾收集演算法。

比如在新生代中,每次收集都會有大量物件死去,所以可以選擇複製演算法,只需要付出少量物件的複製成本就可以完成每次垃圾收集。而老年代的物件存活機率是比較高的所以我們可以選擇“標記-清理”或“標記-整理”演算法進行垃圾收集。

延伸面試問題: HotSpot為什麼要分為新生代和老年代?

根據上面的對分代收集演算法的介紹回答。

引申:FullGC

4 Hotspot的GC演算法實現

4.1列舉根節點

首先,由上文我們瞭解到GC通過可達性分析來判斷物件是否應該被回收,並且GC Roots的節點通常為物件的靜態成員棧楨的區域性變量表,而現在的很多應用僅僅方法區就有幾百兆,如果逐個檢查引用那麼必然會消耗很多時間

其次可達性分析對執行時間的敏感性還體現在GC停頓上,如果在分析期間,物件的引用關係還在變化的話,那麼分析的準確性就無法得到保證。即使是號稱(幾乎)不會發生停頓的CMS收集器中,列舉根節點時也是需要停頓的。

我們把這種GC的停頓,稱為STW(Stop the World)

目前主流的java虛擬機器使用的都是準確式GC(對應的還有保守式GC,感興趣的可以去看文末的參考資料5,當執行停頓時,虛擬機器並不需要逐一去掃描引用,而是應當知道哪些地方存放著這些引用。在Hotspot的實現中,使用了一組稱為OopMap的資料結構來達到這個目的,你可以把他理解成一個型別的對映表,或者是一個除錯資訊。不同的虛擬機器對這個資料結構有不同的明明,殊途同歸。在類載入完成的時候,虛擬機器就把物件內什麼偏移量(記憶體地址)對應什麼型別的資料計算出來,在JIT編譯(just-in-time compilation,程式碼第一次被編譯時,又稱即時編譯,是特殊情況下的動態編譯,詳見文末的參考資料3過程中也會在特定的位置記錄下棧和暫存器中哪些位置是引用,這樣GC掃描時就可以直接得知這些資訊了。

4.2安全點(SafePoint)

OopMap的協助下,Hotspot可以快速完成GC Roots列舉,但是如果引用關係發生變化,或者說OopMap的變化指令非常多,如果為每一條指令都生成對應的OopMap,那麼將會需要大量的額外空間,GC的空間成本會變得非常高。

實際上,Hotspot也並沒有為每條指令都生成對應的OopMap,而是在特定的位置記錄了這些資訊,這些位置我們稱之為安全點(SafePoint)。既程式並非在所有地方都能停下來開始GC,而是隻有到達安全點時才能暫停。安全點的選擇既不能太少以致讓GC等待太長時間,也不能太多從而過分增大執行時的負荷。所以,安全點的位置選點基本上是以“是否具有讓程式長時間執行的特徵”為標準進行選定的。什麼意思呢?就是說當程式需要長時間執行的時候,設定安全點,從而使得GC對程式的影響降低到最小。這些特定的位置主要在指令序列複用的時候,例如: 

  1. 迴圈的末尾 
  2. 方法臨返回前 / 呼叫方法的call指令後 
  3. 可能拋異常的位置

對於SafePoint來說,另一需要考慮的問題就是多執行緒問題,既如何讓所有的執行緒都“跑”到最近的安全點上再停頓下來(這裡不包括執行JNI呼叫的執行緒 : Java Native Interface。這裡有兩種中斷方式可供選擇,搶先式中斷主動式中斷

搶先式中斷:

強先式中斷不需要執行緒程式碼主動配合,而是強制將所有執行緒中斷。如果發現有執行緒中斷的地方不在安全點上,就恢復執行緒讓他跑到安全點上。現在幾乎沒有虛擬機器使用強先式中斷來暫停執行緒執行GC。

主動式中斷:

主動式中斷的思想是讓GC需要中斷執行緒的時候,不直接對執行緒進行操作,而是僅僅設定一個標誌,標誌的位置和安全點重合。各個執行緒執行時會去主動輪詢這個標誌,當發現標誌為真時,就自己將執行緒掛起。

4.3安全區域(Safe Region)

使用SafePoint似乎已經完美解決了如何進入GC的問題,但是實際情況卻不一定。SafePoint保證了程式執行時進入GC的問題,但是當程式不執行時,或者說執行緒處於Sleep或者Brocked狀態下,執行緒無法走到安全點,也將無法進入GC流程。JVM顯然不可能等待這個執行緒被重新分配CPU時間片,對於這種情況就需要安全區域(Safe Region)來解決。換句話說,在這種情況下,程式碼會進入安全區域。

安全區域是指在一段程式碼片段中,引用關係不會發生變化,在這個區域中的任何地方開始GC都是安全的。我們可以把Safe Region看成擴充套件版的Safe Point。執行緒在執行到Safe Region中的程式碼時,首先標識自己已經進入了Safe Region,那麼此時如果JVM發起GC回收,就不用管擁有Safe Region標識的執行緒了。線上程要離開Safe Region時,他要等待GC完成了GC Roots列舉才能繼續執行,否則就必須等待GC列舉。

總結:

到此為止,我們僅僅知道了Hotspot虛擬機器如何去發起GC回收的問題,但是JVM(Java Virtual Machine)具體如何如何進行記憶體回收動作仍未可知。由於虛擬機器回收記憶體的方式主要由具體的GC收集器來決定,而且往往虛擬機器中不止有一種垃圾收集器,所以下一步我們來了解下Hotspot中具體有哪些垃圾收集器。

5 垃圾收集器

如果說收集演算法是記憶體回收的方法論,那麼垃圾收集器就是記憶體回收的具體實現。Java虛擬機器規範中對垃圾收集器如何實現並沒有任何規定,所以不同版本的JVM中所提供的垃圾收集器可能會有很大的差別,而且一般都會提供引數供使用者根據自己應用的特點和要求組合出各個年代所使用的收集器。下面討論的是JDK1.7以後的Hotspot虛擬機器中垃圾收集器的實現。

上圖展示了7種垃圾收集器,如果兩個垃圾收集器之間存在連線,就說明他們可以搭配使用他們所處的區域代表了不同的分代,如新生代老生代。雖然我們對各個收集器進行比較,但並非為了挑選出一個最好的收集器。因為直到現在為止還沒有最好的垃圾收集器出現,更加沒有萬能的垃圾收集器,我們能做的就是根據具體應用場景選擇適合自己的垃圾收集器。試想一下:如果有一種四海之內、任何場景下都適用的完美收集器存在,那麼我們的HotSpot虛擬機器就不會實現那麼多不同的垃圾收集器了。

5.1 Serial序列收集器(新生代)

Serial(序列)收集器是最基本、歷史最悠久的垃圾收集器了。大家看名字就知道這個收集器是一個單執行緒收集器了。它的 “單執行緒” 的意義不僅僅意味著它只會使用一條垃圾收集執行緒去完成垃圾收集工作,更重要的是它在進行垃圾收集工作的時候必須暫停其他所有的工作執行緒"Stop The World" 瞭解一下),直到它收集結束。

虛擬機器的設計者們當然知道Stop The World(STW)帶來的不良使用者體驗,所以在後續的垃圾收集器設計中停頓時間在不斷縮短(仍然還有停頓,尋找最優秀的垃圾收集器的過程仍然在繼續)。

但是Serial收集器有沒有優於其他垃圾收集器的地方呢?當然有,它簡單而高效(與其他收集器的單執行緒相比)。Serial收集器由於沒有執行緒互動的開銷,自然可以獲得很高的單執行緒收集效率。Serial收集器對於執行在Client模式下的虛擬機器來說是個不錯的選擇,他是Client模式下預設的新生代垃圾收集器

引申:虛擬機器的server和client模式

hotspot包括server和client兩種模式的實現

Java HotSpot Client VM(-client),為在客戶端環境中減少啟動時間而優化;

Java HotSpot Server VM(-server),為在伺服器環境中最大化程式執行速度而設計。

比較:Server VM啟動比Client VM慢,執行比Client VM快。

server模式的執行中,垃圾回收處理做的比較好一些。

5.2 ParNew收集器(新生代)

ParNew收集器其實就是Serial收集器的多執行緒版本,除了使用多執行緒進行垃圾收集外,其餘行為(控制引數、收集演算法、回收策略等等)和Serial收集器完全一樣。

它是許多執行在Server模式下的虛擬機器的首要選擇,除了Serial收集器外,只有它能與CMS收集器(真正意義上的併發收集器,後面會介紹到)配合工作。

從ParNew收集器開始,後面還會接觸到幾款並行和併發的收集器,這裡有必要對並行和併發概念進行補充:

  • 並行(Parallel) :指多條垃圾收集執行緒並行工作,使用者執行緒處於等待狀態。(媽媽打掃垃圾的時候乖乖站著)

  • 併發(Concurrent):指使用者執行緒與垃圾收集執行緒同時執行(但不一定是並行,可能會交替執行),使用者程式在繼續執行,而垃圾收集器執行在另一個CPU上。(媽媽打掃垃圾的時候,你還能扔紙屑)

5.3 Parallel Scavenge收集器(新生代)

Parallel Scavenge收集器是一個新生代收集器,它也是使用複製演算法的收集器,又是並行的的多執行緒收集器。。。那麼它有什麼特別之處呢?

Parallel Scavenge收集器關注點是吞吐量(高效率的利用CPU)。CMS等垃圾收集器的關注點更多的是使用者執行緒的停頓時間(提高使用者體驗)。所謂吞吐量就是CPU中用於執行使用者程式碼的時間與CPU總消耗時間的比值,公式為吞吐量=程式碼執行時間/(程式碼執行時間+GC時間)。例如程式運行了100分鐘,GC收集花了1分鐘,那麼吞吐量就是99%。邏輯 Parallel Scavenge收集器提供了很多引數供使用者找到最合適的停頓時間或最大吞吐量,如果對於收集器運作不太瞭解的話,手工優化存在的話可以選擇把記憶體管理優化交給虛擬機器去完成(GC的自適應調節策略 Ergonomics也是一個不錯的選擇。

5.4 Serial Old收集器(老生代)

Serial收集器的老年代版本,它同樣是一個單執行緒收集器,主要供Client模式的虛擬機器使用。它主要有兩大用途:一種用途是在JDK1.5以及以前的版本中與新生代Parallel Scavenge收集器搭配使用,另一種用途是作為CMS收集器的後備方案,使用“標記-整理”演算法

5.5 Parallel Old收集器(老生代)

Parallel Scavenge收集器的老年代版本使用多執行緒“標記-整理演算法。解決了Serial Old在老生代垃圾收集中效能上的拖累,在注重吞吐量以及CPU資源的場合中,都可以優先考慮 Parallel Scavenge收集器和Parallel Old收集器。

5.6 CMS收集器(老生代)

CMS(Concurrent Mark Sweep)收集器是一種以獲取最短回收停頓時間為目標的收集器。它而非常符合在注重使用者體驗的應用上使用。

從名字中的Mark Sweep這兩個詞可以看出,CMS收集器是一種 “標記-清除”演算法實現的,它的運作過程相比於前面幾種垃圾收集器來說更加複雜一些。整個過程分為四個步驟:

  • 初始標記(ini-mark): 暫停所有的其他執行緒,並記錄下直接與root相連的物件,速度很快 ;

  • 併發標記(concurrent-mark): 同時開啟GC和使用者執行緒,用一個閉包結構去記錄可達物件。但在這個階段結束,這個閉包結構並不能保證包含當前所有的可達物件。因為使用者執行緒可能會不斷的更新引用域,所以GC執行緒無法保證可達性分析的實時性。所以這個演算法裡會跟蹤記錄這些發生引用更新的地方

  • 重新標記(remark): 重新標記階段就是為了修正併發標記期間因為使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄,這個階段的停頓時間一般會比初始標記階段的時間稍長,遠遠比並發標記階段時間短

  • 併發清除(concurrent-sweep): 開啟使用者執行緒,同時GC執行緒開始對為標記的區域做清掃

從它的名字就可以看出它是一款優秀的垃圾收集器,主要優點:併發收集、低停頓。但是它有下面三個明顯的缺點:

  • 對CPU資源敏感

面向併發的concurrent程式設計都會對CPU資源比較敏感。在併發階段,它雖然不會導致使用者執行緒停頓,但是由於他佔用了一部分CPU資源,如果CPU資源緊張的話會導致使用者程序響應變慢。CMS的預設回收執行緒數量是(CPU數量+3)/4,CPU數量越小GC執行緒所佔的CPU資源越多,如果只有2個CPU就會佔用50%的CPU,讓人無法接受。針對這種情況,提出了增量式併發收集器(Incremental Concurrent Mark Sweep),既i-CMS它採用的解決策略是讓使用者程序和GC程序交替進行,減少使用者響應時間,然而實際表現的效能並不理想,已經被申明不建議使用(deprecated)

  • 無法處理浮動垃圾(Float Garbage)

在上圖的併發清理階段,使用者執行緒其實還在不斷的製造垃圾,我們稱之為浮動垃圾。這一部分垃圾出現在標記過程之後,CMS無法對它們進行即時清理,而是留到下一次GC處理。也是由於在垃圾清理階段使用者程序還在執行,所以CMS不能等到老生代都滿了以後再來對記憶體進行清理,需要預留一部分記憶體供使用者使用,在JDK1.6以後,這個百分比被設定成8%,既記憶體使用率達到92%以後,會出現一次“Concurrent Mode Failure”,JVM啟動後備方案,使用Seral Old收集器來重新進行老年代的垃圾收集,這樣停頓的時間就很長了,所以這部分預留記憶體不能設定太少。

  • 它使用的回收演算法-“標記-清除”演算法會導致收集結束時會有大量空間碎片產生

由於使用了“標記-清除”演算法,在回收過程中容易產生大量的空間碎片,所以CMS設定了一個引數在必要的時候啟動FullGC進行記憶體整理,這回造成一次較長的停頓效果。CMS也提供了引數設定在固定次數的FullGC後來一次帶壓縮的GC。

5.7 G1收集器

上一代的垃圾收集器(序列serial, 並行parallel, 以及CMS)都把堆記憶體劃分為固定大小的三個部分:    年輕代(young generation), 老年代(old generation), JDK1.8以前的持久代(permanent generation)和1.8以後的元空間,這些space必須是地址連續的空間。

G1 (Garbage-First)是一款面向伺服器的垃圾收集器,主要針對配備多顆處理器及大容量記憶體的機器. 以極高概率滿足GC停頓時間要求的同時,還具備高吞吐量效能特徵。它被開發的目的,就被用來替代現有的CMS收集器。

被視為JDK1.7中HotSpot虛擬機器的一個重要進化特徵。它具備一下特點:

  • 並行與併發:G1能充分利用CPU、多核環境下的硬體優勢,使用多個CPU(CPU或者CPU核心)來縮短stop-The-World停頓時間。部分其他收集器原本需要停頓Java執行緒執行的GC動作,G1收集器仍然可以通過併發的方式讓java程式繼續執行

  • 分代收集:雖然G1可以不需要其他收集器配合就能獨立管理整個GC堆,但是還是保留了分代的概念並重新設計分代。他將記憶體劃分為不同的Region,新生代和老生代不再是物理隔離的,而是一系列Region的集合。

  • 空間整合:與CMS的“標記--清理”演算法不同,G1從整體來看是基於“標記整理”演算法實現的收集器;從區域性上來看是基於“複製”演算法實現的。這兩種演算法都不會產生記憶體碎片,有利於程式長時間的有效執行,減少GC和Full GC的觸發。

  • 可預測的停頓:這是G1相對於CMS的另一個大優勢,降低停頓時間是G1和CMS共同的關注點,但G1除了追求低停頓外,還能建立可預測的停頓時間模型,能讓使用者明確指定在一個長度為M毫秒的時間片段內垃圾收集用時不超過N秒

G1收集器跟蹤各個Region裡的垃圾堆價值大小,在後臺維護了一個優先列表,每次根據允許的收集時間,優先選擇回收價值最大的Region(這也就是它的名字Garbage-First的由來)。這種使用Region劃分記憶體空間以及有優先順序的區域回收方式,保證了GF收集器在有限時間內可以儘可能高的收集效率(把記憶體化整為零)。

每個Region被標記了E(eden)S(survivor)O(old)H(humongous),說明每個Region在執行時都充當了一種角色,其中H是以往演算法中沒有的,它代表Humongous,這表示這些Region儲存的是巨型物件(humongous object,H-obj),當新建物件大小超過Region大小一半時,直接在新的一個或多個連續Region中分配,並標記為H。

我們可以思考一下,將記憶體劃分為Region來進行優先順序管理真的能簡單地做到更加快速麼?

答案是否定的。由於新生代比老生代中記憶體更新更快,而且不同的Region內的物件有可能存在互相引用的關係,如果回收新生代的記憶體時不得不同時掃描老生代的話,G1的效率可能要下降不少。實際上,G1採用了Remenbered Set集來解決這個問題。G1規定虛擬機器在對Refrence物件進行寫操作的時候,產生一個Write Barrier柵欄暫時中斷寫操作,檢查Refrence的物件是否處於不同的Region中(主要檢查是否老年代的物件引用了新生代的物件),如果是便把相關的引用資訊記錄到寫物件所處Region的Remenbered Set中,這樣當G1在回收記憶體時,在GC根節點的列舉範圍中加入Remenbered Set即可避免全堆掃描檢索引用。

G1為這種新的記憶體結構提供了三種模式垃圾回收模式,young gcmixed gc full gc,在不同的條件下被觸發。

young gc

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

mixed gc

當越來越多的物件晉升到老年代old region時,為了避免堆記憶體被耗盡,虛擬機器會觸發一個混合的垃圾收集器,即mixed gc,該演算法並不是一個old gc,除了回收整個young region,還會回收一部分的old region,這裡需要注意:是一部分老年代,而不是全部老年代,可以選擇哪些old region進行收集,從而可以對垃圾回收的耗時時間進行控制。由於G1是並行垃圾收集器,所以為了保證使用者程序有記憶體可以使用,當老年代的使用率達到閥值時,啟動mixed gc回收記憶體,可以通過引數設定。

mixed gc的垃圾回收和CMS有點類似,大致可以分為以下四部分

  • 初始標記(initial-mark)

  • 併發標記(concurrent-mark)

  • 最終標記(final-mark)

  • 篩選回收(livadata count and evacuation)

上面幾個步驟的運作過程和CMS有很多相似之處。初始標記階段僅僅只是標記一下GC Roots能直接關聯到的物件,並且修改TAMS的值,讓下一個階段使用者程式併發執行時,能在正確可用的Region中建立新物件,這一階段需要停頓執行緒,但是耗時很短,併發標記階段是從GC Root開始對堆中物件進行可達性分析,找出存活的物件,這階段時耗時較長,但可與使用者程式併發執行。而最終標記階段則是為了修正在併發標記期間因使用者程式繼續運作而導致標記產生變動的那一部分標記記錄,虛擬機器將這段時間物件變化記錄在執行緒Remenbered Set Logs裡面,最終標記階段需要把Remembered Set Logs裡的資料合併到Remembered Set中,這一階段需要停頓執行緒,但是可以和記錄變化並行執行。最後在篩選回收階段首先對各個Region的回收價值和成本進行排序,根據使用者所期望的GC停頓時間來制定回收計劃。

full gc

如果物件記憶體分配速度過快,mixed gc來不及回收,導致老年代被填滿,就會觸發一次full gc,G1的full gc演算法就是單執行緒執行的Serial Old GC,這是個單執行緒的垃圾收集器,會導致異常且長時間的暫停時間,因此在使用G1時應儘可能地避免出現full gc。

6 理解GC日誌

每一種收集器的日誌實現格式都是由他們自身的實現所決定的,但是為了方便閱讀,各個收集器的日誌都維持著一定的共性,例如下面這一段日誌:

2018-06-15T10:44:26.630-0800: [GC (System.gc()) [PSYoungGen: 2673K->496K(38400K)] 2673K->504K(125952K), 0.0010649 secs] [Times: user=0.00 sys=0.00, real=0.00 secs

上方的[GC (System.gc()),表示

這次垃圾收集的停頓型別如果有FULL GC說明這次垃圾收集出現了STW(Stop-The-World)

上面方括號內部的[PSYoungGen: 2673K->496K(38400K)],表示

GC前該記憶體區域已使用容量->GC後該記憶體區域已使用容量,後面圓括號裡面的38400K為該記憶體區域的總容量。

方括號外面的 2673K->504K(125952K), 0.0010649 secs],表示

GC前Java堆已使用容量->GC後Java堆已使用容量,後面圓括號裡面的125952K為Java堆總容量。   PSYoungGen耗時

[Times: user=0.00 sys=0.00, real=0.00 secs]分別表示

戶消耗的CPU時間   核心態消耗的CPU時間     操作從開始到結束所經過的牆鍾時間(Wall Clock Time)

user是使用者態耗費的時間,sys是核心態耗費的時間,real是整個過程實際花費的時間。user+sys是CPU時間,每個CPU core單獨計算,所以這個時間可能會是real的好幾倍。

CPU時間和牆鍾時間的差別是,牆鍾時間包括各種非運算的等待耗時,例如等待磁碟I/O、等待執行緒阻塞,而CPU時間不包括這些耗時,但是多核CPU會疊加user或sys的時間,所以在多核環境下可能出現user+sys>real的情況
 

7 垃圾收集器的引數總結

-XX:+UseSerialGC : Jvm執行在Client模式下的預設值,開啟此開關後,使用Serial + Serial Old的收集器組合進行記憶體回收

-XX:+UseParNewGC : 開啟此開關後,使用ParNew + Serial Old的收集器進行垃圾回收

-XX:+UseConcMarkSweepGC : 使用ParNew + CMS +  Serial Old的收集器組合進行記憶體回收,Serial Old作為CMS出現“Concurrent Mode Failure”失敗後的後備收集器使用

-XX:+UseParallelGC : Jvm執行在Server模式下的預設值,開啟此開關後,使用Parallel Scavenge +  Serial Old的收集器組合進行回收

-XX:+UseParallelOldGC : 使用Parallel Scavenge +  Parallel Old的收集器組合進行回收

-XX:SurvivorRatio : 新生代中Eden區域與Survivor區域的容量比值,預設為8,代表Eden:Subrvivor = 8:1

-XX:PretenureSizeThreshold : 直接晉升到老年代物件的大小,設定這個引數後,大於這個引數的物件將直接在老年代分配

-XX:MaxTenuringThreshold : 晉升到老年代的物件年齡,每次Minor GC之後,年齡就加1,當超過這個引數的值時進入老年代

-XX:UseAdaptiveSizePolicy : 動態調整java堆中各個區域的大小以及進入老年代的年齡

-XX:+HandlePromotionFailure : 是否允許新生代收集擔保,進行一次minor gc後, 另一塊Survivor空間不足時,將直接會在老年代中保留

-XX:ParallelGCThreads : 設定並行GC進行記憶體回收的執行緒數

-XX:GCTimeRatio : GC時間佔總時間的比列,預設值為99,即允許1%的GC時間,僅在使用Parallel Scavenge 收集器時有效

-XX:MaxGCPauseMillis : 設定GC的最大停頓時間,在Parallel Scavenge 收集器下有效

-XX:CMSInitiatingOccupancyFraction : 設定CMS收集器在老年代空間被使用多少後出發垃圾收集,預設值為68%,僅在CMS收集器時有效,-XX:CMSInitiatingOccupancyFraction=70

-XX:+UseCMSCompactAtFullCollection : 由於CMS收集器會產生碎片,此引數設定在垃圾收集器後是否需要一次記憶體碎片整理過程,僅在CMS收集器時有效

-XX:+CMSFullGCBeforeCompaction : 設定CMS收集器在進行若干次垃圾收集後再進行一次記憶體碎片整理過程,通常與UseCMSCompactAtFullCollection引數一起使用

-XX:+UseFastAccessorMethods : 原始型別優化

-XX:+DisableExplicitGC : 是否關閉手動System.gc()

-XX:+CMSParallelRemarkEnabled : 降低標記停頓

-XX:LargePageSizeInBytes : 記憶體頁的大小不可設定過大,會影響Perm的大小,-XX:LargePageSizeInBytes=128m

–XX:+UseG1GC : 開啟此開關後,使用G1垃圾收集器

 

8 記憶體分配與回收策略

8.1物件優先在Eden區分配

大多數情況下,物件在新生代中Eden區分配。當Eden區沒有足夠空間進行分配時,虛擬機器將發起一次Minor GC.

Minor Gc和Full GC 有什麼不同呢?

新生代GC(Minor GC):指發生新生代的的垃圾收集動作,Minor GC非常頻繁,回收速度一般也比較快。

老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC經常會伴隨至少一次的Minor GC(並非絕對),Major GC的速度一般會比Minor GC的慢10倍以上。

8.2 大物件直接進入老年代

大物件就是需要大量連續記憶體空間的物件(比如:字串、陣列)。

8.3 長期存活的物件將進入老年代

既然虛擬機器採用了分代收集的思想來管理記憶體,那麼記憶體回收時就必須能識別那些物件應放在新生代,那些物件應放在老年代中。為了做到這一點,虛擬機器給每個物件一個物件年齡(Age)計數器,預設16代後轉入老年代。

8.4 動態物件年齡判定

為了更好的適應不同程式的記憶體情況,虛擬機器不是永遠要求物件年齡必須達到了某個值才能進入老年代,如果Survivor 空間中相同年齡所有物件大小的總和大於Survivor空間的一半,年齡大於或等於該年齡的物件就可以直接進入老年代,無需達到要求的年齡。

總結:

本節介紹了垃圾收集演算法,幾款JDK1.7中提供的垃圾收集器特點以及運作原理。 記憶體回收與垃圾收集器在很多時候都是影響系統性能、併發能力的主要因素之一,虛擬機器之所以提供多種不同的收集器以及大量調節引數,是因為只有根據實際應用的需求、實現方式選擇最優的收集方式才能獲取最高的效能。沒有固定收集器、引數組合、也沒有最優的調優方法,那麼必須瞭解每一個具體收集器的行為、優勢和劣勢、調節引數。

PS:博文僅作為個人學習筆記,如有錯誤歡迎指正,轉載請註明出處~

參考文件:

1.《深入理解Java虛擬機器》密碼:8jz3 

2. 面試中關於Java虛擬機器(jvm)的問題看這篇就夠了

3. JVM即時編譯(JIT)

4. 彙編中偏移地址的理解

5. 我愛學Java之JVM中的OopMap

6. hotspot 虛擬機器的server和client模式

7. G1垃圾收集器介紹