1. 程式人生 > >【深入理解JVM虛擬機器】第3章 垃圾收集器與記憶體分配策略

【深入理解JVM虛擬機器】第3章 垃圾收集器與記憶體分配策略

3.1 概述

垃圾收集(Garbage Collection , GC)的歷史遠遠比Java久遠。它需要完成三件事:

  • 哪些記憶體需要回收
  • 什麼時候回收
  • 如何回收

程式計數器、虛擬機器棧、本地方法棧三個區域隨執行緒而生,隨執行緒而滅;棧中的棧幀隨著方法的進入和退出而有條不紊地執行著出棧和入棧操作,每一個棧幀需要分配多少記憶體基本上是在類結構確定下來時就已知的(儘管在執行期會由JIT編譯器進行一些優化,但是大體上可以認為是編譯器可知的),因此在這幾個區域的記憶體分配和回收都具有確定性,這幾個區域不太需要過多地考慮回收的問題。而Java堆和方法區則不一樣,一個介面中的多個實現類需要的記憶體可能不一樣,一個方法中的多個分支需要的記憶體也可能不一樣,我們只有在程式處於執行期間才能知道會建立哪些物件,這部分的記憶體分配和回收都是動態的,垃圾收集器需要關注的是這部分記憶體,我們所討論的“記憶體”分配與回收也僅僅指著一部分。

3.2 物件已死?

堆中存放著Java世界中幾乎所有的物件,垃圾收集器在對堆進行回收前,第一件事就是要確定哪些物件還“存活著”,哪些已經“死去”(即不可能再被任何途徑使用的物件)。

3.2.1 引用計數演算法

引用計數演算法(Reference Counting):給物件新增一個引用計數器,每當有一個地方引用它時,計數器的數值就加1;當引用失效時,計數器數值就減1;任何時刻計數器都為0的物件就是不可能再被使用的。 實際上,Java並沒有採用引用計數演算法,因為它很難解決物件之間的相互迴圈引用的問題。

3.2.2 根搜尋演算法

在主流的商業程式語言中(Java和C#),都是使用__根搜尋演算法(GC Roots Tracing)來判定物件是否存活的。 這個演算法的基本思路是:通過一系列的名字為“GC Roots”的物件作為起點,從這些節點開始向下搜尋,搜尋所走過的路徑稱為__引用鏈(Reference Chain)

,當一個物件到GC Roots沒有任何引用鏈物件相連(用圖論的話說,就是從GC Roots到這個物件不可到達)時,則證明此物件不可用。 如下圖示:

GC Root Tracing.jpg | left | 483x358

  • 物件object5、object6和object7雖然互相關聯,但是他們到GC Roots是不可到達的,所以它們會被判定為師可回收物件。 在Java中,可以作為GC Roots的物件有以下幾種:
  • 虛擬機器棧(棧中的本地變量表)中的引用的物件
  • 方法區中的類靜態屬性引用的物件
  • 方法區中的常量引用的物件
  • 本地方法棧中JNI(即一般說的Native方法)的引用的物件

3.2.3 再談引用

在JDK1.2之前,Java中的引用(Reference)非常狹隘:

如果Reference型別的資料中儲存的數值代表著另一塊記憶體的起始地址,就稱這塊記憶體代表著一個引用。

在JDK1.2之後,Java對引用的概念進行了擴充,將引用分為強引用(Strong Reference)、軟引用(Soft Reference)、弱引用(Weak Reference)和虛引用(Phantom Reference)。這四種引用強度依次減弱。

  • 強引用,就是在程式程式碼中普遍存在的,類似Object obj = new Object()這類的引用,只要強引用還存在,垃圾回收器永遠不會回收掉被引用的物件。
  • 軟引用,用來描述一些還有用,但是並非必需的物件。對於軟引用關聯的物件,在系統將要發生記憶體溢位異常之前,將會把這些物件放進回收範圍之中並進行第二次的回收。如果這次回收還是沒有足夠的記憶體,才會丟擲記憶體溢位異常。在JDK1.2之後,提供了SoftReference類來實現軟引用。
  • 弱引用,也是用來描述非必需物件的,但是它的強度要比軟引用弱一些,被弱引用關聯的物件只能生存到下一次垃圾回收之前。當垃圾回收器工作時,不論當前記憶體是否足夠,都會回收掉只被弱引用關聯的物件。在JDK1.2之後,提供了WeakReference來實現弱引用。
  • 虛引用,也稱為幽靈引用或者幻影引用,它是最弱的一種引用關係。一個物件是否有虛引用的存在,完全不影響其生存時間,也無法通過虛引用來獲取一個物件的例項。為一個物件設定虛引用的唯一目的就是希望這個物件被收集器回收時收到一個系統通知。在JDK1.2之後,提供了PhantomReference類來實現虛引用。

3.2.4 生存還是死亡?

在根搜尋演算法不可達的物件,也並非是“非死不可”的,它們暫時處於“死緩”狀態,要真正宣告物件的死亡,至少要經歷兩次標記:

如果物件在進行根搜尋後發現沒有與GC Roots相連線的引用鏈,那它就會被第一次標記並且進行一次篩選,篩選的條件是此物件是否有必要執行finalize()方法。當物件沒有覆蓋finalize()方法,或者finalize()方法已經被虛擬機器呼叫過,虛擬機器將這兩種情況都視為“沒有必要執行”。

如果這個物件被判定為有必要執行__finalize()方法__,那麼這個物件就會被放在名為F-Queue的佇列中,並在稍後有一條由虛擬機器自動建立的、低優先順序的Finalizer執行緒去執行。這裡所說的“執行”是指虛擬機器會觸發這個方法,但是並不承諾會保證等待它執行結束。(這樣做的目的是,如果一個物件在finalize()方法中執行緩慢或者是發生了死迴圈,將可能會導致F-Queue裡的其他物件永久處於等待狀態,甚至導致整個記憶體回收系統崩潰)。

finalize()方法是物件逃脫死亡命運的最後一次機會,稍後GC將對F-Queue中的物件進行第二次小規模標記,如果物件要在finalize()方法中成功拯救自己,只要重新與引用鏈上的任何物件建立關聯即可,譬如把自己(this關鍵字)複製給某個類變數或者物件的成員變數。

3.2.5 回收方法區

Java虛擬機器規範不要求虛擬機器在方法區實現垃圾收集,而且在方法區進行垃圾收集的“價效比”一般都比較低:在堆中,尤其是在新生代中,常規應用進行一次垃圾收集一般可回收70%~95%的空間,而永久代的垃圾收集效率遠低於此。

永久代的垃圾收集主要分為兩部分內容:廢棄常量和無用的類。

__回收廢棄常量__與回收Java堆中的物件非常類似,假如一個字串“abc”已經進入常量池,但是當前系統沒有任何一個String物件是叫做“abc”的,換句話說就是沒有任何物件引用常量池的“abc”常量,也沒有其他地方引用了這個字面量,如果這時候發生記憶體回收,而且有必要的話,這個“abc”就會被系統“請”出常量池。常量池中的其他類(介面)、方法、欄位的符號引用也與此類似。

類需要同時滿足以下三個條件,才能算是__“無用的”類__:

  • 該類所有的例項都被回收,也就是說Java堆中已經不存在該類的所有例項。
  • 載入該類的ClassLoader已經被回收
  • 該類對應的 java.lang.class 物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法

虛擬機器在一個類同時滿足以上三個條件時,__可以__對這個無用類進行回收。(⚠️,這裡說的僅僅是“可以”,而不是和物件一樣,不使用了就必然會回收)。HotSpot虛擬機器提供了 -Xnoclassgc 引數來進行控制,還可以使用 -verbose:class , -XX:+TraceClassLoading , -XX:+TraceClassUnLoading 檢視類的載入和解除安裝資訊。

在大量使用反射、動態代理、CGLib等bytecode框架的場景,以及動態生成JSP和OSGi這類頻繁自定義ClassLoader的場景都需要虛擬機器具備類解除安裝的功能,以保證永久代不會溢位。

3.3 垃圾收集演算法

垃圾收集演算法涉及到大量的程式細節,而且各個平臺的虛擬機器操作記憶體的方法又各不相同,因此本節著重介紹幾種演算法的思想和發展過程。

3.3.1 標記-清除演算法

標記-清除(Mark-Sweep)演算法,分為兩個部分__標記__和__清除__,首先標記出所有需要回收的物件,在標記完成之後統一回收掉所有被標記的物件。

它是最基礎的演算法,是因為後續的演算法都是基於這種思路並對其缺點進行改進而得到的。

它的主要缺點有兩個: 1, 效率問題,標記或清除過程的效率都不高; 2, 空間問題,標記清除之後會產生大量的不連續的空間碎片,在以後需要分配大物件時無法找到足夠的連續記憶體空間而不得不進行一次另一次垃圾清理。

3.3.2 複製演算法

__複製(Copying)演算法__的出現是為了解決“標記-清除演算法”的效率問題,它將可用記憶體按照容量劃分為大小相等的兩塊,每次只使用其中一塊。當這一塊記憶體使用完了,就將還存活著物件複製到另外一塊上面,然後再將已使用過的記憶體空間一次清理掉。這樣就使得每一次都是對其中一塊進行記憶體回收,記憶體分配時也就不用考慮記憶體碎片等複雜情況,只需要移動堆頂指標,按順序分配記憶體即可,實現簡單,執行高效。

現在的商業虛擬機器都採用這種收集演算法來回收新生代,新生代中的物件絕大部分都是朝生夕死的,所以並不需要按照1:1的比例來劃分記憶體空間,而是將記憶體分為一塊較大的Eden空間和兩塊較小的Survivor,每次使用Eden和其中一塊Survivor,當回收時,將Eden和Survivor中存活的物件一次性地拷貝到另一塊Survivor空間,最後清理掉Eden和Survivor空間。

HotSpot虛擬機器預設的Eden和Survivor大小比例為8:1,也就是說每次新生代中可用空間為整個新生代空間的90%(80%+10%),只有10%的記憶體空間是被“浪費”的。當Survivor的空間不夠用的時候,需要依賴其他記憶體(老年代)進行__分配擔保(Handle Promotion)__。

3.3.3 標記-整理演算法

標記-整理(Mark-Compact)演算法:標記過程與“標記-清除”演算法一樣,但是後續步驟不是直接對可回收物件進行清理,而是讓所有的存活物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

3.3.4 分代收集演算法

當前商業虛擬機器的垃圾收集都是採用__“分代收集(Generational Collection)演算法”__,根據物件的存活週期的不同將記憶體劃分為幾塊。

一般是把Java堆分為__新生代__和__老年代__,這樣就可以根據各個年代的特定採用最適當的收集演算法。在新生代,每次垃圾收集都發現有大批物件死去,只有少量存活,那就選用複製演算法,只需要付出少量存活物件的複製成本就可以完成省收集。而老年代中因為物件存活率高,沒有額外的空間進行分配擔保,就必須使用“標記-清理”或者“標記-整理”演算法來進行回收。

3.4 垃圾收集器

垃圾收集器是記憶體回收的具體實現,Java虛擬機器規範對垃圾收集器的實現並沒有具體規定,因此不同廠商、不同版本的虛擬機器所提供的垃圾收集器可能會有很大的區別。 以下是 HotSpot JVM 1.6 的垃圾收集器:

HotSpot JVM 1.6 GC .jpg | left | 810x556

其中,如果兩個收集器之間有連線,說明可以搭配使用。

3.4.1 Serial 收集器

特點:

  • 單執行緒收集器
  • 在進行垃圾收集時,必需暫停其他所有的工作執行緒(打掃衛生時,必需要求房間裡停止工作產生垃圾)
  • 簡單而高效,專心做垃圾收集
  • 虛擬機器執行在Client模式下的預設新生代收集器

3.4.2 ParNew 收集器

ParNew收集器其實就是Serial收集器的多執行緒版本,是執行在Server模式下的虛擬機器中首選的新生代收集器。

並行(Parallel):指多條垃圾收集執行緒並行工作,但此時使用者執行緒處於等待狀態。

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

3.4.3 Parallel Scavenge

Parallel Scavenge收集器是一個使用複製演算法的並行的多執行緒收集器,它關注於提高吞吐量(Throughput,CPU用於執行使用者程式碼的時間和CPU消耗時間的比值)。另外,自適應調節策略也是Parallel Scavenge和ParNew收集器的區別

3.4.4 Serial Old 收集器

是Serial收集器的老年版本。

3.4.5 Parallel Old 收集器

是Parallel Scavenge 收集器的老年版本。

3.4.6 CMS 收集器

CMS(Concurrent Mask Sweep)收集器是一個以獲取最短回收停頓時間為目標的收集器。因此,此收集器特別適合現代的網際網路或 B/S 架構的服務端上。

CMS 收集器是基於“標記-清除”演算法實現的,整個過程分為4個步驟:

  • 初始標記
  • 併發標記
  • 重新標記
  • 併發清除

它是一種優秀的收集器。

  • 優點是:併發收集、低停頓
  • 缺點是:對CPU資源非常敏感、無法處理浮動垃圾、收集結束後會產生大量的空間碎片以致於在給大物件分配空間時帶來麻煩

3.4.7 G1 收集器

G1(Garbage First)收集器是當前收集器技術發展的最新成果,相對於上文的 CMS 收集器有兩個顯著改進:

  1. 基於“標記-整理”演算法,也就是說它不會產生空間碎片
  2. 非常精確地控制停頓

G1 收集器可以實現在基本不犧牲吞吐量的情況下完成低停頓的回收,它將整個Java堆劃分為多個大小固定的獨立區域(Region),並跟蹤這些區域裡面的垃圾堆積程度,在後臺維護一個優先列表,每次根據允許的收集時間,優先回收垃圾最多的區域(這也是Garbage First名稱的由來)。總而言之,區域劃分和有優先順序的區域回收,保證了G1收集器在有限的時間內可以獲得最高的收集效率。

3.4.8 垃圾收集器引數總結

-XX:+UseSerialGC:在新生代和老年代使用序列收集器
-XX:SurvivorRatio:設定eden區大小和survivior區大小的比例
-XX:NewRatio:新生代和老年代的比
-XX:+UseParNewGC:在新生代使用並行收集器
-XX:+UseParallelGC :新生代使用並行回收收集器
-XX:+UseParallelOldGC:老年代使用並行回收收集器
-XX:ParallelGCThreads:設定用於垃圾回收的執行緒數
-XX:+UseConcMarkSweepGC:新生代使用並行收集器,老年代使用CMS+序列收集器
-XX:ParallelCMSThreads:設定CMS的執行緒數量
-XX:CMSInitiatingOccupancyFraction:設定CMS收集器在老年代空間被使用多少後觸發
-XX:+UseCMSCompactAtFullCollection:設定CMS收集器在完成垃圾收集後是否要進行一次記憶體碎片的整理
-XX:CMSFullGCsBeforeCompaction:設定進行多少次CMS垃圾回收後,進行一次記憶體壓縮
-XX:+CMSClassUnloadingEnabled:允許對類元資料進行回收
-XX:CMSInitiatingPermOccupancyFraction:當永久區佔用率達到這一百分比時,啟動CMS回收
-XX:UseCMSInitiatingOccupancyOnly:表示只在到達閥值的時候,才進行CMS回收

3.5 記憶體分配與回收策略

Java 技術體系中的自動記憶體管理最終可以可以歸結為自動化地解決了以下兩個問題:給物件分配記憶體__和__回收分配給物件的記憶體

其中,關於__回收記憶體__是上文介紹的虛擬機器中垃圾收集體系及其工作原理所闡述的內容。

而,關於__分配記憶體__則是本節需要闡述的內容。

3.5.1 物件優先在Eden分配

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

  • 新生代GC(Minor GC):指發生在新生代的垃圾收集動作,因為Java物件大多數都具有朝生夕滅的特性,所以Minor GC非常頻繁,一般回收速度也比較快。
  • 老年代GC(Major GC/Full GC):指發生在老年代的GC,出現了Major GC,經常會伴隨至少發生一次的Minor GC(但是並非絕對,在ParallelScavenge收集器的收集策略裡,就有直接進行Major GC的策略選擇過程)。Major GC的速度一般會比Minor GC慢10倍以上。

3.5.2 大物件直接進入老年代

大物件,是指需要大量連續記憶體空間的Java物件,最典型的大物件就是那種很長的字串及陣列。大物件對於虛擬機器的記憶體分配來說,是一個壞訊息,因為它的經常出現容易導致記憶體還有不少空間時就提前觸發垃圾收集以獲取足夠的連續空間來“安置”它們。

虛擬機器提供了-XX:PretenureSizeThreshold引數,讓大於這個設定值的物件直接在老年代中分配,這樣做的目的是避免在Eden區及兩個Survivor區之間發生大量的記憶體拷貝(新生代採用複製演算法來收集記憶體)。

PretenureSizeThreshold引數只對Serial和ParNew兩款收集器有效,Parallel Scavenge收集器不認識這個引數,一般也沒必要設定。如果遇到必需使用此引數的場合,可以考慮ParNew+CMS的收集器組合

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

虛擬機器採用了分代收集的思想來管理記憶體,那麼回收時就必須能夠識別哪些物件應當放在新生代,哪些物件應該放在老年代。為了達到這個目的,虛擬機器給每個物件定義了一個物件年齡(Age)計數器。

如果物件在Eden出現並經過第一次MinorGC後仍然存活,並且能被Survivor容納,將被移動到Survivor空間,物件年齡加1。物件在Survivor區每熬過一次Minor GC,年齡就增加1歲,當它的年齡增加到一定程度(預設值是15)時,就會被晉升到老年代中。

物件晉升老年代的年齡閾值,可以通過引數-XX:MaxTenuringThreshold來設定

3.5.4 動態物件年齡判定

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

3.5.5 空間分配擔保

在發生Minor GC時,虛擬機器會檢測之前每次晉升到老年代的平均大小是否大於老年代的剩餘空間大小,如果大於,則改為直接進行一次Full GC。如果小於,則檢視HandlePromotionFailure設定是否允許擔保失敗;如果允許,那隻會進行Minor GC;如果不允許,則也要改為進行一次Full GC。