JVM 初探:記憶體分配、GC 原理與垃圾收集器

攻城師 -翡青發表於2017-01-29

JVM記憶體的分配與回收大致可分為如下4個步驟: 何時分配 -> 怎樣分配 -> 何時回收 -> 怎樣回收.
除了在概念上可簡單認為new時分配外, 我們著重介紹後面的3個步驟:

I. 怎樣分配- JVM記憶體分配策略

物件記憶體主要分配在新生代Eden區, 如果啟用了本地執行緒分配緩衝, 則優先在TLAB上分配, 少數情況能會直接分配在老年代, 或被拆分成標量型別在棧上分配(JIT優化). 分配的規則並不是百分百固定, 細節主要取決於垃圾收集器組合, 以及VM記憶體相關的引數.

物件分配

優先在Eden區分配

JVM記憶體模型一文中, 我們大致瞭解了VM年輕代堆記憶體可以劃分為一塊Eden區和兩塊Survivor區. 在大多數情況下, 物件在新生代Eden區中分配, 當Eden區沒有足夠空間分配時, VM發起一次Minor GC, 將Eden區和其中一塊Survivor區內尚存活的物件放入另一塊Survivor區域, 如果在Minor GC期間發現新生代存活物件無法放入空閒的Survivor區, 則會通過空間分配擔保機制使物件提前進入老年代(空間分配擔保見下).

大物件直接進入老年代

Serial和ParNew兩款收集器提供了-XX:PretenureSizeThreshold的引數, 令大於該值的大物件直接在老年代分配, 這樣做的目的是避免在Eden區和Survivor區之間產生大量的記憶體複製(大物件一般指 需要大量連續記憶體的Java物件, 如很長的字串和陣列), 因此大物件容易導致還有不少空閒記憶體就提前觸發GC以獲取足夠的連續空間.

物件晉升

年齡閾值

VM為每個物件定義了一個物件年齡(Age)計數器, 物件在Eden出生如果經第一次Minor GC後仍然存活, 且能被Survivor容納的話, 將被移動到Survivor空間中, 並將年齡設為1. 以後物件在Survivor區中每熬過一次Minor GC年齡就+1. 當增加到一定程度(-XX:MaxTenuringThreshold, 預設15), 將會晉升到老年代.

提前晉升: 動態年齡判定

然而VM並不總是要求物件的年齡必須達到MaxTenuringThreshold才能晉升老年代: 如果在Survivor空間中相同年齡所有物件大小的總和大於Survivor空間的一半, 年齡大於或等於該年齡的物件就可以直接進入老年代, 而無須等到晉升年齡.

II. 何時回收-物件生死判定

(哪些記憶體需要回收/何時回收)

在堆裡面存放著Java世界中幾乎所有的物件例項, 垃圾收集器在對堆進行回收前, 第一件事就是判斷哪些物件已死(可回收).

可達性分析演算法

在主流商用語言(如Java、C#)的主流實現中, 都是通過可達性分析演算法來判定物件是否存活的: 通過一系列的稱為 GC Roots 的物件作為起點, 然後向下搜尋; 搜尋所走過的路徑稱為引用鏈/Reference Chain, 當一個物件到 GC Roots 沒有任何引用鏈相連時, 即該物件不可達, 也就說明此物件是不可用的, 如下圖: Object5、6、7 雖然互有關聯, 但它們到GC Roots是不可達的, 因此也會被判定為可回收的物件:

在Java, 可作為GC Roots的物件包括:

  1. 方法區: 類靜態屬性引用的物件;
  2. 方法區: 常量引用的物件;
  3. 虛擬機器棧(本地變數表)中引用的物件.
  4. 本地方法棧JNI(Native方法)中引用的物件。

注: 即使在可達性分析演算法中不可達的物件, VM也並不是馬上對其回收, 因為要真正宣告一個物件死亡, 至少要經歷兩次標記過程: 第一次是在可達性分析後發現沒有與GC Roots相連線的引用鏈, 第二次是GC對在F-Queue執行佇列中的物件進行的小規模標記(物件需要覆蓋finalize()方法且沒被呼叫過).

III. GC原理- 垃圾收集演算法

分代收集演算法 VS 分割槽收集演算法

分代收集

當前主流VM垃圾收集都採用”分代收集”(Generational Collection)演算法, 這種演算法會根據物件存活週期的不同將記憶體劃分為幾塊, 如JVM中的 新生代老年代永久代. 這樣就可以根據各年代特點分別採用最適當的GC演算法:

  • 在新生代: 每次垃圾收集都能發現大批物件已死, 只有少量存活. 因此選用複製演算法, 只需要付出少量存活物件的複製成本就可以完成收集.
  • 在老年代: 因為物件存活率高、沒有額外空間對它進行分配擔保, 就必須採用“標記—清理”“標記—整理”演算法來進行回收, 不必進行記憶體複製, 且直接騰出空閒記憶體.
  • 分割槽收集
    上面介紹的分代收集演算法是將物件的生命週期按長短劃分為兩個部分, 而分割槽演算法則將整個堆空間劃分為連續的不同小區間, 每個小區間獨立使用, 獨立回收. 這樣做的好處是可以控制一次回收多少個小區間.
    在相同條件下, 堆空間越大, 一次GC耗時就越長, 從而產生的停頓也越長. 為了更好地控制GC產生的停頓時間, 將一塊大的記憶體區域分割為多個小塊, 根據目標停頓時間, 每次合理地回收若干個小區間(而不是整個堆), 從而減少一次GC所產生的停頓.

分代收集

新生代-複製演算法

該演算法的核心是將可用記憶體按容量劃分為大小相等的兩塊, 每次只用其中一塊, 當這一塊的記憶體用完, 就將還存活的物件複製到另外一塊上面, 然後把已使用過的記憶體空間一次清理掉.

這使得每次只對其中一塊記憶體進行回收, 分配也就不用考慮記憶體碎片等複雜情況, 實現簡單且執行高效.

現代商用VM的新生代均採用複製演算法, 但由於新生代中的98%的物件都是生存週期極短的, 因此並不需完全按照1∶1的比例劃分新生代空間, 而是將新生代劃分為一塊較大的Eden區和兩塊較小的Survivor區(HotSpot預設Eden和Survivor的大小比例為8∶1), 每次只用Eden和其中一塊Survivor. 當發生MinorGC時, 將Eden和Survivor中還存活著的物件一次性地拷貝到另外一塊Survivor上, 最後清理掉Eden和剛才用過的Survivor的空間. 當Survivor空間不夠用(不足以儲存尚存活的物件)時, 需要依賴老年代進行空間分配擔保機制, 這部分記憶體直接進入老年代.

老年代-標記清除演算法

該演算法分為“標記”和“清除”兩個階段: 首先標記出所有需要回收的物件(可達性分析), 在標記完成後統一清理掉所有被標記的物件.

該演算法會有以下兩個問題:

1. 效率問題: 標記和清除過程的效率都不高;
2. 空間問題: 標記清除後會產生大量不連續的記憶體碎片, 空間碎片太多可能會導致在執行過程中需要分配較大物件時無法找到足夠的連續記憶體而不得不提前觸發另一次垃圾收集.

老年代-標記整理演算法

標記清除演算法會產生記憶體碎片問題, 而複製演算法需要有額外的記憶體擔保空間, 於是針對老年代的特點, 又有了標記整理演算法. 標記整理演算法的標記過程與標記清除演算法相同, 但後續步驟不再對可回收物件直接清理, 而是讓所有存活的物件都向一端移動,然後清理掉端邊界以外的記憶體.

永久代-方法區回收

在方法區進行垃圾回收一般”價效比”較低, 因為在方法區主要回收兩部分內容: 廢棄常量無用的類. 回收廢棄常量與回收其他年代中的物件類似, 但要判斷一個類是否無用則條件相當苛刻:

  1. 該類所有的例項都已經被回收, Java堆中不存在該類的任何例項;
  2. 該類對應的Class物件沒有在任何地方被引用(也就是在任何地方都無法通過反射訪問該類的方法);
  3. 載入該類的ClassLoader已經被回收.

但即使滿足以上條件也未必一定會回收, Hotspot VM還提供了-Xnoclassgc引數控制(關閉CLASS的垃圾回收功能). 因此在大量使用動態代理、CGLib等位元組碼框架的應用中一定要關閉該選項, 開啟VM的類解除安裝功能, 以保證方法區不會溢位.

補充: 空間分配擔保

在執行Minor GC前, VM會首先檢查老年代是否有足夠的空間存放新生代尚存活物件, 由於新生代使用複製收集演算法, 為了提升記憶體利用率, 只使用了其中一個Survivor作為輪換備份, 因此當出現大量物件在Minor GC後仍然存活的情況時, 就需要老年代進行分配擔保, 讓Survivor無法容納的物件直接進入老年代, 但前提是老年代需要有足夠的空間容納這些存活物件. 但存活物件的大小在實際完成GC前是無法明確知道的, 因此Minor GC前, VM會先首先檢查老年代連續空間是否大於新生代物件總大小或歷次晉升的平均大小, 如果條件成立, 則進行Minor GC, 否則進行Full GC(讓老年代騰出更多空間).

然而取歷次晉升的物件的平均大小也是有一定風險的, 如果某次Minor GC存活後的物件突增,遠遠高於平均值的話,依然可能導致擔保失敗(Handle Promotion Failure, 老年代也無法存放這些物件了), 此時就只好在失敗後重新發起一次Full GC(讓老年代騰出更多空間).

IX. GC實現- 垃圾收集器

GC實現目標: 準確、高效、低停頓、空閒記憶體規整.

新生代

1. Serial收集器

Serial收集器是Hotspot執行在Client模式下的預設新生代收集器, 它的特點是 只用一個CPU/一條收集執行緒去完成GC工作, 且在進行垃圾收集時必須暫停其他所有的工作執行緒(“Stop The World” -後面簡稱STW).

雖然是單執行緒收集, 但它卻簡單而高效, 在VM管理記憶體不大的情況下(收集幾十M~一兩百M的新生代), 停頓時間完全可以控制在幾十毫秒~一百多毫秒內.

2. ParNew收集器

ParNew收集器其實是前面Serial的多執行緒版本, 除使用多條執行緒進行GC外, 包括Serial可用的所有控制引數、收集演算法、STW、物件分配規則、回收策略等都與Serial完全一樣(也是VM啟用CMS收集器-XX: +UseConcMarkSweepGC的預設新生代收集器).

由於存線上程切換的開銷, ParNew在單CPU的環境中比不上Serial, 且在通過超執行緒技術實現的兩個CPU的環境中也不能100%保證能超越Serial. 但隨著可用的CPU數量的增加, 收集效率肯定也會大大增加(ParNew收集執行緒數與CPU的數量相同, 因此在CPU數量過大的環境中, 可用-XX:ParallelGCThreads引數控制GC執行緒數).

3. Parallel Scavenge收集器

與ParNew類似, Parallel Scavenge也是使用複製演算法, 也是並行多執行緒收集器. 但與其他收集器關注儘可能縮短垃圾收集時間不同, Parallel Scavenge更關注系統吞吐量:

系統吞吐量=執行使用者程式碼時間(執行使用者程式碼時間+垃圾收集時間)

停頓時間越短就越適用於使用者互動的程式-良好的響應速度能提升使用者的體驗;而高吞吐量則適用於後臺運算而不需要太多互動的任務-可以最高效率地利用CPU時間,儘快地完成程式的運算任務. Parallel Scavenge提供瞭如下引數設定系統吞吐量:

Parallel Scavenge引數 描述
MaxGCPauseMillis (毫秒數) 收集器將盡力保證記憶體回收花費的時間不超過設定值, 但如果太小將會導致GC的頻率增加.
GCTimeRatio (整數:0 < GCTimeRatio < 100) 是垃圾收集時間佔總時間的比率
-XX:+UseAdaptiveSizePolicy 啟用GC自適應的調節策略: 不再需要手工指定-Xmn-XX:SurvivorRatio-XX:PretenureSizeThreshold等細節引數, VM會根據當前系統的執行情況收集效能監控資訊, 動態調整這些引數以提供最合適的停頓時間或最大的吞吐量

老年代

Serial Old收集器

Serial Old是Serial收集器的老年代版本, 同樣是單執行緒收集器,使用“標記-整理”演算法:

Serial Old應用場景如下:

  • JDK 1.5之前與Parallel Scavenge收集器搭配使用;
  • 作為CMS收集器的後備預案, 在併發收集發生Concurrent Mode Failure時啟用(見下:CMS收集器).

Parallel Old收集器

Parallel Old是Parallel Scavenge收老年代版本, 使用多執行緒和“標記-整理”演算法, 吞吐量優先, 主要與Parallel Scavenge配合在 注重吞吐量 及 CPU資源敏感 系統內使用:

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一款具有劃時代意義的收集器, 一款真正意義上的併發收集器, 雖然現在已經有了理論意義上表現更好的G1收集器, 但現在主流網際網路企業線上選用的仍是CMS(如Taobao、微店).
CMS是一種以獲取最短回收停頓時間為目標的收集器(CMS又稱多併發低暫停的收集器), 基於”標記-清除”演算法實現, 整個GC過程分為以下4個步驟:

1. 初始標記(CMS initial mark)
2. 併發標記(CMS concurrent mark: GC Roots Tracing過程)
3. 重新標記(CMS remark)
4. 併發清除(CMS concurrent sweep: 已死象將會就地釋放, 注意: 此處沒有壓縮)

其中兩個加粗的步驟(初始標記重新標記)仍需STW. 但初始標記僅只標記一下GC Roots能直接關聯到的物件, 速度很快; 而重新標記則是為了修正併發標記期間因使用者程式繼續執行而導致標記產生變動的那一部分物件的標記記錄, 雖然一般比初始標記階段稍長, 但要遠小於併發標記時間.

(由於整個GC過程耗時最長的併發標記和併發清除階段的GC執行緒可與使用者執行緒一起工作, 所以總體上CMS的GC過程是與使用者執行緒一起併發地執行的.

由於CMS收集器將整個GC過程進行了更細粒度的劃分, 因此可以實現併發收集、低停頓的優勢, 但它也並非十分完美, 其存在缺點及解決策略如下:

  1. CMS預設啟動的回收執行緒數=(CPU數目+3)4
    當CPU數>4時, GC執行緒最多佔用不超過25%的CPU資源, 但是當CPU數<=4時, GC執行緒可能就會過多的佔用使用者CPU資源, 從而導致應用程式變慢, 總吞吐量降低.
  2. 無法處理浮動垃圾, 可能出現Promotion FailureConcurrent Mode Failure而導致另一次Full GC的產生: 浮動垃圾是指在CMS併發清理階段使用者執行緒執行而產生的新垃圾. 由於在GC階段使用者執行緒還需執行, 因此還需要預留足夠的記憶體空間給使用者執行緒使用, 導致CMS不能像其他收集器那樣等到老年代幾乎填滿了再進行收集. 因此CMS提供了-XX:CMSInitiatingOccupancyFraction引數來設定GC的觸發百分比(以及-XX:+UseCMSInitiatingOccupancyOnly來啟用該觸發百分比), 當老年代的使用空間超過該比例後CMS就會被觸發(JDK 1.6之後預設92%). 但當CMS執行期間預留的記憶體無法滿足程式需要, 就會出現上述Promotion Failure等失敗, 這時VM將啟動後備預案: 臨時啟用Serial Old收集器來重新執行Full GC(CMS通常配合大記憶體使用, 一旦大記憶體轉入序列的Serial GC, 那停頓的時間就是大家都不願看到的了).
  3. 最後, 由於CMS採用”標記-清除”演算法實現, 可能會產生大量記憶體碎片. 記憶體碎片過多可能會導致無法分配大物件而提前觸發Full GC. 因此CMS提供了-XX:+UseCMSCompactAtFullCollection開關引數, 用於在Full GC後再執行一個碎片整理過程. 但記憶體整理是無法併發的, 記憶體碎片問題雖然沒有了, 但停頓時間也因此變長了, 因此CMS還提供了另外一個引數-XX:CMSFullGCsBeforeCompaction用於設定在執行N次不進行記憶體整理的Full GC後, 跟著來一次帶整理的(預設為0: 每次進入Full GC時都進行碎片整理).

分割槽收集- G1收集器

G1(Garbage-First)是一款面向服務端應用的收集器, 主要目標用於配備多顆CPU的伺服器治理大記憶體.
- G1 is planned as the long term replacement for the Concurrent Mark-Sweep Collector (CMS).
-XX:+UseG1GC 啟用G1收集器.

與其他基於分代的收集器不同, G1將整個Java堆劃分為多個大小相等的獨立區域(Region), 雖然還保留有新生代和老年代的概念, 但新生代和老年代不再是物理隔離的了, 它們都是一部分Region(不需要連續)的集合.

每塊區域既有可能屬於O區、也有可能是Y區, 因此不需要一次就對整個老年代/新生代回收. 而是當執行緒併發尋找可回收的物件時, 有些區塊包含可回收的物件要比其他區塊多很多. 雖然在清理這些區塊時G1仍然需要暫停應用執行緒, 但可以用相對較少的時間優先回收垃圾較多的Region(這也是G1命名的來源). 這種方式保證了G1可以在有限的時間內獲取儘可能高的收集效率.

新生代收集

G1的新生代收集跟ParNew類似: 存活的物件被轉移到一個/多個Survivor Regions. 如果存活時間達到閥值, 這部分物件就會被提升到老年代.

G1的新生代收集特點如下:

  • 一整塊堆記憶體被分為多個Regions.
  • 存活物件被拷貝到新的Survivor區或老年代.
  • 年輕代記憶體由一組不連續的heap區組成, 這種方法使得可以動態調整各代區域尺寸.
  • Young GCs會有STW事件, 進行時所有應用程式執行緒都會被暫停.
  • 多執行緒併發GC.

老年代收集

G1老年代GC會執行以下階段:

注: 一下有些階段也是年輕代垃圾收集的一部分.

index Phase Description
(1) 初始標記 (Initial Mark: Stop the World Event) 在G1中, 該操作附著一次年輕代GC, 以標記Survivor中有可能引用到老年代物件的Regions.
(2) 掃描根區域 (Root Region Scanning: 與應用程式併發執行) 掃描Survivor中能夠引用到老年代的references. 但必須在Minor GC觸發前執行完.
(3) 併發標記 (Concurrent Marking : 與應用程式併發執行) 在整個堆中查詢存活物件, 但該階段可能會被Minor GC中斷.
(4) 重新標記 (Remark : Stop the World Event) 完成堆記憶體中存活物件的標記. 使用snapshot-at-the-beginning(SATB, 起始快照)演算法, 比CMS所用演算法要快得多(空Region直接被移除並回收, 並計算所有區域的活躍度).
(5) 清理 (Cleanup : Stop the World Event and Concurrent) 見下 5-1、2、3
5-1 (Stop the world) 在含有存活物件和完全空閒的區域上進行統計
5-2 (Stop the world) 擦除Remembered Sets.
5-3 (Concurrent) 重置空regions並將他們返還給空閒列表(free list)
(*) Copying/Cleanup (Stop the World Event) 選擇”活躍度”最低的區域(這些區域可以最快的完成回收). 拷貝/轉移存活的物件到新的尚未使用的regions. 該階段會被記錄在gc-log內(只發生年輕代[GC pause (young)], 與老年代一起執行則被記錄為[GC Pause (mixed)].

詳細步驟可參考 Oracle官方文件-The G1 Garbage Collector Step by Step.

G1老年代GC特點如下:

  • 併發標記階段(index 3)
    1. 在與應用程式併發執行的過程中會計算活躍度資訊.
    2. 這些活躍度資訊標識出那些regions最適合在STW期間回收(which regions will be best to reclaim during an evacuation pause).
    3. 不像CMS有清理階段.
  • 再次標記階段(index 4)
    1. 使用Snapshot-at-the-Beginning(SATB)演算法比CMS快得多.
    2. 空region直接被回收.
  • 拷貝/清理階段(Copying/Cleanup Phase)
    • 年輕代與老年代同時回收.
    • 老年代記憶體回收會基於他的活躍度資訊.

補充: 關於Remembered Set

G1收集器中, Region之間的物件引用以及其他收集器中的新生代和老年代之間的物件引用都是使用Remembered Set來避免掃描全堆. G1中每個Region都有一個與之對應的Remembered Set, VM發現程式對Reference型別資料進行寫操作時, 會產生一個Write Barrier暫時中斷寫操作, 檢查Reference引用的物件是否處於不同的Region中(在分代例子中就是檢查是否老年代中的物件引用了新生代的物件), 如果是, 便通過CardTable把相關引用資訊記錄到被引用物件所屬的Region的Remembered Set中. 當記憶體回收時, 在GC根節點的列舉範圍加入Remembered Set即可保證不對全域性堆掃描也不會有遺漏.

V. JVM小工具

在${JAVA_HOME}/bin/目錄下Sun/Oracle給我們提供了一些處理應用程式效能問題、定位故障的工具, 包含

bin 描述 功能
jps 列印Hotspot VM程式 VMID、JVM引數、main()函式引數、主類名/Jar路徑
jstat 檢視Hotspot VM 執行時資訊 類載入、記憶體、GC[可分代檢視]、JIT編譯
jinfo 檢視和修改虛擬機器各項配置 -flag name=value
jmap heapdump: 生成VM堆轉儲快照、查詢finalize執行佇列、Java堆和永久代詳細資訊 jmap -dump:live,format=b,file=heap.bin [VMID]
jstack 檢視VM當前時刻的執行緒快照: 當前VM內每一條執行緒正在執行的方法堆疊集合 Thread.getAllStackTraces()提供了類似的功能
javap 檢視經javac之後產生的JVM位元組碼程式碼 自動解析.class檔案, 避免了去理解class檔案格式以及手動解析class檔案內容
jcmd 一個多功能工具, 可以用來匯出堆, 檢視Java程式、匯出執行緒資訊、 執行GC、檢視效能相關資料等 幾乎集合了jps、jstat、jinfo、jmap、jstack所有功能
jconsole 基於JMX的視覺化監視、管理工具 可以檢視記憶體、執行緒、類、CPU資訊, 以及對JMX MBean進行管理
jvisualvm JDK中最強大執行監視和故障處理工具 可以監控記憶體洩露、跟蹤垃圾回收、執行時記憶體分析、CPU分析、執行緒分析…

VI. VM常用引數整理

引數 描述
-Xms 最小堆大小
-Xmx 最大堆大小
-Xmn 新生代大小
-XX:PermSize 永久代大小
-XX:MaxPermSize 永久代最大大小
-XX:+PrintGC 輸出GC日誌
-verbose:gc -
-XX:+PrintGCDetails 輸出GC的詳細日誌
-XX:+PrintGCTimeStamps 輸出GC時間戳(以基準時間的形式)
-XX:+PrintHeapAtGC 在進行GC的前後列印出堆的資訊
-Xloggc:/path/gc.log 日誌檔案的輸出路徑
-XX:+PrintGCApplicationStoppedTime 列印由GC產生的停頓時間

在此處無法列舉所有的引數以及他們的應用場景, 詳細移步Oracle官方文件-Java HotSpot VM Options.

相關文章