NO IMAGE

Java 程式執行在 JVM 之上, JVM 的執行狀況對於 Java 程式而言會產生很大的影響, 因此掌握 JVM 中的關鍵機制對於編寫穩定、 高效能的 JAVA 程式至關重要。
JVM 制定了 Java 類的載入、 編譯、 執行、 物件記憶體的分配和回收、 執行緒以及鎖機制,這些機制對 Java 程式的執行效果起到了重要的影響, 當然, JVM 涉及的不僅僅是上面這些機制, 但在本章節中並不打算介紹所有 JVM 的機制, 而是僅僅深入介紹其中的一些關鍵機制。

JVM 物件記憶體回收

JVM 中自動的物件記憶體回收機制稱為: GC( Garbage Collection), GC 的基本原理為將記憶體中不再被使用的物件進行回收, GC 中用於回收記憶體中不被使用的物件的方法稱為收集器,由於 GC 需要消耗一些資源和時間的, Java 在對物件的生命週期特徵進行分析後, 在 V 1.2以上的版本採用了分代的方式來進行物件的收集, 即按照新生代、 舊生代的方式來對物件進行收集, 以儘可能的縮短 GC 對應用造成的暫停, 對新生代的物件的收集稱為 minor GC, 對舊生代的物件的收集稱為 Full GC, 程式中主動呼叫 System.gc()強制執行的 GC 為 Full GC, 在需要進行物件回收的語言( 例如還有 LISP) 中常用的有引用計數收集器和跟蹤收集器。

引用計數收集器

引用計數是標識 Heap 中物件狀態最明顯的一種方法, 引用計數的方法簡單來說就是對每一個物件都提供一個關聯的引用計數, 以此來標識該物件是否被使用, 當這個計數為零時,說明這個物件已經不再被使用了 。
引用計數的好處是可以不用暫停應用, 當計數變為零時, 即可將此物件的記憶體空間回收,但它需要給每個物件附加一個關聯引用計數, 這就要求 JVM 在分配物件時必須增加賦值操作, 並且引用計數無法解決迴圈引用的問題, 因此 JVM 並沒有採用引用計數。

跟蹤收集器

跟蹤收集器的方法為停止應用的工作, 然後開始跟蹤物件, 跟蹤時從物件根開始沿著引
用跟蹤, 直到檢查完所有的物件。
JVM 的根物件集合根據實現不同而不同, 但總會包含區域性變數中的物件引用和棧幀的運算元棧( 以及變數中的物件引用), 根物件的來源主要有三種。
根物件的來源之一是被載入的類的常量池中的物件引用, 例如字串 、 被載入的類的常量池可能指向儲存在堆中的字串 , 例如類名字, 超類名字, 超介面名字, 欄位名 ,欄位特徵簽名 , 方法名或者方法特徵簽名 。

來源之二是傳到本地方法中, 沒有被本地方法“釋放” 的物件引用。

來源之三是虛擬機器執行時資料區中從垃圾收集器的堆中分配的部分。

跟蹤收集器採用的均為掃描的方法, 但 JVM 將 Heap 分為了新生代和舊生代, 在進行minor GC 時需要掃描是否有舊生代引用了新生代中的物件, 但又不可能每次 minor GC 都掃描整個舊生代中的物件, 因此 JVM 採用了一種稱為卡片標記( Card Marking) 的演算法來避免這種現象。
卡片標記的演算法為將舊生代以某個大小(例如 512 位元組) 進行劃分, 劃分出來的每個區域稱為卡片, JVM 採用卡表維護卡的狀態, 每張卡片在卡表中佔用一個位元組的標識( 有些JVM 實現可能會不同), 當 Java 程式碼執行過程中發現舊生代的物件引用或釋放了對於新生代物件的引用時, 就相應的修改卡表中卡的狀態, 每次 Minor GC 只需掃描卡表中標識為髒狀態的卡中的物件即可, 圖示如下:


跟蹤收集器在掃描時最重要的是要根據這些物件是否被引用來標識其狀態, JVM 中將物件的引用分為了四種型別, 不同的物件引用型別會造成 GC 採用不同的方法進行回收:

強引用

預設情況下, 物件採用的均為強引用, 例如:

A a=null;
public void execute(){
a=new A();,
// 其他程式碼
}

只有當 execute 所在的這個物件的例項沒有其他物件引用, GC 時才會被回收。

軟引用

軟引用是 Java 中提供的一種比較適合於快取場景的應用, 採用軟引用修改之上的程式碼
如下:

SoftReference aRef=null;
A a=null;
public void execute(){
if((aRef==null)||(aRef.get()==null)){
a=new A();
aRef=new SoftReference(a);
}
else{
a=aRef.get();
}
// 執行程式碼
a=null;
}

程式碼中不同於強引用中的為在 execute 方法的最後將 a 設定為了 null, 當 execute 方法執行完畢後, a 物件只有在記憶體不夠用的情況下才會被 GC, 這對於合理的使用快取而言無疑非常有作用, 既可以保證不至於大量使用快取出現 OutOfMemory, 又可以在記憶體夠用的情況下提升效能。

弱引用

採用弱引用修改之上的程式碼如下:

WeakReference aRef=null;
A a=null;
public void execute(){
if((aRef==null)||(aRef.get()==null)){
a=new A();
aRef=new WeakReference(a);
}
else{
a=aRef.get();
}
// 執行程式碼
a=null;
}

對於 a 這個引用, 在 GC 時 a 一定會被 GC 回收, 這種引用有助於 GC 更快的回收物件,尤其是位於集合中的物件, 同時也有助於在 GC 未回收之前仍然呼叫此物件來執行一些動作。

虛引用

採用虛引用修改之上的程式碼如下:

ReferenceQueue aRefQueue=new ReferenceQueue();
PhantomReference aRef=null;
A a=null;
public void execute(){
a=new A();
aRef=new PhantomReference(a,aRefQueue);
// 執行程式碼
a=null;
}

在SoftReference 和 WeakReference 中也可以放入 ReferenceQueue, 這個 Queue 是用於
物件在被 GC 後用於儲存 Reference 物件例項的, 由於虛引用只是用來得知物件是否被 GC,通過 PhantomReference.get 返回的永遠是 null, 因此它要求必須有 ReferenceQueue, 當上面
程式碼中的 a 物件被 GC 後, 通過 aRefQueue.poll 可以獲取到 aRef 物件例項, 從而可以做一些需要的動作。
在掌握了 java 中的對於根物件、 分代掃描的方式以及物件的引用型別後, 來具體的看看跟蹤收集器, 常用的有如下三種:
標記—清除( Mark-Sweep)
從根物件開始訪問每一個活躍的節點, 並標記訪問到的每一個節點, 當遍歷完成後, 就對堆空間進行清除, 即清除那些沒打上標記的物件。

這種方法的好處是便於實現, 但由於要掃描整個堆, 因此要求應用暫停的時間會較長,並且會產生較多的記憶體碎片。
JVM 並沒有實現這種需要長時間停止應用的標記—清除收集器, 而是在此基礎上提供了併發的標記—清除( Concurrent Mark Sweep, 縮寫為 CMS) 收集器, 使得在整個收集的過程中只是很短的暫停應用的執行, 可通過在 JVM 引數中設定-XX:UseConcMarkSweepGC 來使用此收集器, 不過此收集器僅用於舊生代和持久代的物件收集, 併發的標記 — 清除較之Stop-The-World 的標記—清除複雜了很多, 來看看:併發標記—清除做到的是在標記訪問每一個節點時以及清除不活躍的物件時採用和應用併發的方式, 僅需在初始化標記節點狀態以及最終標記節點狀態時需要暫停整個應用, 因此其造成的應用的暫停的時間會比較的短。
併發標記—清除為了保證儘量短的造成應用的暫停, 首先從分配記憶體上做了改動, CMS提供了兩個 free lists, 一個用於存放小物件, 另外一個則用於存放大物件, 當 JVM 需要給物件分配記憶體時, 則通過 free list 來找到可用的堆地址, 並進行記憶體的分配以及將此地址從 freelist 刪除, 當 CMS 回收物件記憶體後, 則將其相應的地址重新放入此 free list 中, 這樣的好處是在回收物件的時候不需要做物件的移動等, 因此可以讓回收過程併發的進行。
接著來看看併發標記—清除的執行步驟:
1. Initial Marking
此步需要暫停整個應用, JVM 掃描整個 old generation 中根物件可直接訪問到的物件,
並對這些物件進行標記, 對於標記的物件 CMS 採用一個外部的 bit 陣列來進行記錄。
2. Concurrent Marking
在初始化標記完畢後, CMS 恢復所有應用的執行緒, 同時開始併發的對之前標記過的物件進行輪循, 以標記這些物件可訪問的物件。CMS 為了確保能夠掃描到所有的物件, 避免在 Initial Marking 中還有未標識到的物件,採用的方法為找到標記了的物件, 並將這些物件放入 Stack 中, 掃描時尋找此物件依賴的物件, 如果依賴的物件的地址在其之前, 則將此物件進行標記, 並同時放入 Stack 中, 如依賴的物件地址在其之後, 則僅標記該物件。
在進行 Concurrent Marking 時 minor GC 也可能會同時進行, 這個時候很容易造成舊生代物件引用關係改變, CMS 為了應對這樣的並發現象, 提供了一個 Mod Union Table 來進行記錄, 在這個 Mod Union Table 中記錄每次 minor GC 後修改了的 Card 的資訊。
在進行 Concurrent Marking 時還有可能會出現的一個並發現象是應用修改了舊生代中的物件的引用關係, CMS 中仍然採用 Card Table 的方式來進行記錄, 在 Card 中將某物件標識為 dirty 狀態, 但即使是這樣仍然可能會出現一種現象導致不再被引用的物件仍然是 marked
的狀態:例如當 Concurrent Marking 已經掃描到了 a 所引用的物件 b、 c、 e, 如果在此後應用將b 引用的物件由 c 改為了 d, 同時 g 不再引用 d, 此時會將 b、 g 物件的狀態在 card 中標識為dirty, 但 c 的狀態並不會因此而改變。


3. Final Marking
此步需要暫停整個應用, 由於在 Concurrent Marking 時應用可能會修改物件的引用關係或建立新的物件, 因此需要把這些改變或新建立的物件也進行掃描, CMS 遞迴掃描 Mod
Union Table 以及 Card Table 中 dirty 的物件, 並進行標記。
4. Concurrent Sweeping
在完成了 Final Marking 後, 恢復所有應用的執行緒, 就進入到這步了 , 這步需要負責的是將沒有標記的物件進行回收。
回收過程是併發進行的, 而 JVM 分配物件記憶體(儘管 CMS 僅用於 old generation, 但有些時候會由於應用建立的物件過大導致直接分配到 old generation 的現象, 另外一種現象就是 young generation 經過回收後需要轉入 old generation 的物件) 和 CMS 釋放記憶體又都是操作 free list, 會產生 free list 競爭的現象, 因此 CMS 在此增加了 Mutual exclusion locks, 以 JVM分配優先。
CMS 為了避免每次回收後回收到的大小都比之前分配出去的記憶體小, 在進行 sweeping的時候, 還會盡量的將相鄰的塊重新組裝為一個塊, sweeping 為了避免和 JVM 分配物件記憶體產生衝突, 採用的方法為首先從 free list 中刪除塊, 組裝完畢後再重新放入塊中, 為了能夠從 free list 中刪除指定的塊, CMS 將 free list 設計為了雙向連結串列。
CMS 中的耗時的過程都是和應用併發進行的, 這也是 CMS 最突出的優點, 使得其造成的應用的暫停時間比 Mark-Sweeping 的方式短了很多, 但同時也意味著 CMS 會和應用執行緒爭搶 CPU 資源, CMS 回收記憶體的方式也使得其很容易產生記憶體碎片, 降低了空間的利用率,另外就是 CMS 在回收時容易產生一些應該回收但需要等到下次 CMS 才能被回收掉的物件,例如上圖中的 C 物件, 稱為“ 浮動垃圾“, 這也就要求了採用 CMS 的情況下需要提供更多的可用的舊生代空間, 總體來說 CMS 很適用於對響應時間要求很高、 CPU 資源競爭不是很激烈以及記憶體空間相對更充足的系統。
CMS 為了降低和應用爭搶 CPU 資源的現象發生, 還提供了一種增量的模式, 稱為 i-CMS,在這種模式下, CMS 僅啟動一個處理器執行緒來併發的掃描標記和清除, 並且該執行緒在執行一小段時間後就會先將 CPU 使用權讓出來, 分多次多段的方式來完成整個掃描標記和清除的過程, 這樣降低了對於 CPU 資源的消耗, 但同時也降低了 CMS 的效能, 因此僅適用於 CPU少的應用。
CMS 為了減少產生的記憶體碎片, 提高 jvm 空間的利用率, 提供了一個整理碎片的功能,
可通過在 jvm 中指定-XX: UseCMSCompactAtFullCollection 來啟動此功能, 在啟動了此功能後預設為每次 Full GC 的時候都會進行整理, 也可以通過-XX:CMSFullGCsBeforeCompaction=來指定多少次 Full GC 後才執行整理, 不過要注意的是, 整理這個步驟是需要暫停整個應用的。
複製( Copying)
同樣從根開始訪問每一個活躍的節點, 但其不做標記, 而是將這些活動的物件複製到另
外的一個空間去, 在遍歷完畢後, 只需把原空間清空就可以了 , 過程圖示如下:

這種方法的好處是隻訪問活躍的物件, 不用掃描整個堆中的所有物件, 因此其掃描的速度僅取決於活躍的物件的數量, 並且不會產生記憶體碎片, 但其不足的地方是需要一個同樣大小的空間, 增加了記憶體的消耗, 並且複製物件也是需要消耗時間的。
JVM 中提供了此收集器的實現, 但僅用於新生代中物件的收集, 並提供了序列和並行的兩種執行方式, 序列即為單執行緒執行此收集器, 可通過 -XX: UseSerialGC 來指定使用序列方式的複製收集器; 並行則為多執行緒執行此收集器, 可通過-XX: UseParallelGC( 指定新生代、舊生代以及持久代都採用並行的方式進行收集, 舊生代並行執行收集器僅在 JDK 5 Update 6後才支援) 或-XX: UseParNewGC( 指定新生代採用並行的方式進行收集) 來指定使用並行方式的複製收集器, 其中並行的執行緒數預設為 CPU 個數, 可通過-XX:ParallelGCThreads 來指定並行執行收集器時的執行緒數。
複製時將 Eden Space 中的活躍物件和一塊 Survior Space 中尚不夠資格(又稱為 FromSpace, 小於-XX:MaxTenuringThreshold( 預設為 31 次) 次 Minor GC) 進入 Old Generation 的活躍物件複製到另外一塊 Survior Space( 又稱為 To Space) 中, 對於 From Space 中經歷過-XX:MaxTenuringThreshold 次仍然存活的物件則複製到 OldGeneration 中, 大物件也直接複製到 Old Generation, 如 To Space 中已滿的話, 則將物件直接複製到 Old Generation 中(這點非 常 值 得注 意 , 在 實 際的 產 品中 要 儘量 避 免 物件 直 接 到 Old Generation ), 可 通 過-XX:SurvivorRatio 來調整 Survior Space 所佔的大小, 然後清除 Eden Space 和 From Space, 過程圖示如下:

標記—整理( Mark-Compact)

標記—整理吸收了標記—清除和複製的優點, 第一階段從根節點遍歷標記所有活躍的物件, 第二階段遍歷整個堆, 清除未標記的物件, 並把存活的物件“ 壓縮“ 到堆中的一塊, 按順序排放, 這樣就避免了記憶體碎片的產生, 同時也不像複製演算法需要兩倍的記憶體空間, 過程圖示如下:


但由於標記—整理仍然是需要遍歷整個堆的, 因此其仍然要求應用暫停較長的時間。
JVM 中提供了此收集器的實現, 但僅用於舊生代中物件的收集, 同時也是舊生代預設採用的收集器, 從 JDK 5 Update 6 後支援並行執行, 以加快標記—整理的執行時間, JVM 中標記—整理收集器的執行過程圖示如下:

Java 為了降低 GC 對應用產生的影響, 一直都在不斷的發展著 GC, 並提供了多種不同的收集器, 以便 JAVA 開發人員能夠根據硬體環境以及應用的需求來選擇相應的收集器。

併發標記—清除收集器

併發標記—清除收集器的特徵是能夠讓 GC 過程暫停應用的時間縮短, 但需要消耗更多的 CPU 資源以及 JVM 記憶體空間, 並容易產生記憶體碎片, 對於響應時間要求非常靈敏的系統而言( 如 GUI 系統), 是無法忍受 GC 一次帶來的幾秒的暫停的, 在這種情況下可以優先採用這種收集器。
併發標記—清除收集器僅對舊生代和持久代的收集有效, 可通過在 JVM 引數中加入-XX:UseConcMarkSweepGC 來採用此收集器。

序列復制收集器

此收集器僅適用於新生代的收集, 其特徵為適用於快速的完成活躍物件不多的空間的收集, 不產生記憶體碎片, 但需要雙倍的記憶體空間, 執行過程中應用需要完全暫停, 可通過在JVM 引數中加入-XX: UseSerialGC 來採用此收集器。

並行複製收集器

此收集器和序列復制收集器唯一不同的地方在於採用了多執行緒進行收集, 在超過 2 個CPU 的環境上, 其速度比序列復制收集器快很多, 因此在超過 2 個 CPU 的環境上應採用此收 集 器 來完 成 新 生代 對 象的 收 集 , 可 通 過在 JVM 參 數中 加 入 -XX: UseParallelGC 或-XX: UseParNewGC 指定使用此收集器。

序列標記—整理收集器

此收集器僅適用於舊生代的物件收集, 是 JDK 5 Update 6 之前的版本中預設的舊生代收集器, 其特徵為適用於收集存活時間較長的物件, 不產生記憶體碎片, 但收集造成的應用暫停的時間會比較長。

並行標記—整理收集器

此收集器和序列方式不同之處僅在於多執行緒執行, 因此造成的應用的暫停時間能有一定的 縮 短 , 僅 在 JDK 5 Update 6 之 後 的 版 本 可 使 用 , 可 通 過 -XX: UseParallelGC 或-XX: UseParOldGC 來指定, 但不可與併發標記—整理收集器同時使用。
在 JDK 5 以前的版本中還有一個收集器是增量收集器, 此增量收集器可通過-Xincgc 來啟用, 但在 JDK 5 以及以上的版本中廢棄了此增量收集器, -Xincgc 會自動的轉為採用並行收集器去進行垃圾回收, 原因是其效能低於並行收集器, 因此在本書中就不介紹此收集器了 , 增量收集器中採用的火車演算法比較有意思, 如果有興趣的話可以去看看。
JVM 為了避免 JAVA 開發人員需要頭疼這麼多種收集器的選擇, 還提供了兩種簡單的方式來控制 GC 的策略:
1、 吞吐量優先
吞吐量是指 GC 所耗費的時間佔應用執行總時間的百分比, 例如應用總共執行了 100 分鐘, 其中 GC 執行佔用了 1 分鐘, 那麼吞吐量就是 99%了 , JVM 預設的指標是 99%。
吞吐量優先的策略即為以吞吐量為指標, 由 JVM 自行選擇相應的 GC 策略以及控制 NewGeneration、 Old Generation 記憶體的大小, 可通過在 JVM 引數中指定-XX:GCTimeRatio=n 來使用此策略。
2、 暫停時間優先
暫停時間是指每次 GC 造成的應用的停頓時間, 預設不啟用這個策略。暫停時間優先的策略即為以暫停時間為指標, 由 JVM 自行選擇相應的 GC策略以及控制New Generation、 Old Generation 記憶體的大小, 來儘量的保證每次 GC 造成的應用停頓時間都在指定的數值範圍內完成, 可通過在 JVM 引數中指定-XX:MaxGCPauseMillis=n 來使用此策略。
當以上兩引數都指定的情況下, 首先滿足暫停時間優先策略, 再滿足吞吐量優先策略。
大多數情況下使用預設的 JVM 配置或者使用以上兩個引數就可以讓 GC 符合應用的要求執行了 , 只有當預設的或使用了以上兩個引數還達不到需求時, 才值得自行來調整這些和記憶體分配和回收相關的 JVM 引數。
在 Java 中除了能夠通過呼叫 System.gc()來強制 JVM 進行 GC 操作外, 就只能由 JVM 來自行決定什麼時候執行 GC 了, 由於年輕代中多數為新建立的物件, 並且大多數都已不再活躍, 因此 Java 採用複製收集器來回收年輕代中的物件, 當 Eden Space 空間滿的時候, 會觸發 minor GC 的執行, Eden Space 空間滿的原因是新建立的物件的大小超過了 Eden Space 的大小, 例如如下的一段程式碼, 當新生代的大小設定為 10M( -Xmn10M), 整個 jvm 堆設定為
64M 時( -Xms64M –Xmx64M), 下面的程式碼在執行過程中會經歷一次 minor GC:

Map<String, byte[]> bytes=new HashMap<String, byte[]>();
for (int i = 0; i < 8*1024; i  ) {
bytes.put(String. valueOf(i), new byte[1024]) ;
}

由於新生代的大小為 10M, 那麼按照預設的 Survivor Ratio 為 8 的分配方法: Eden Space為 8M, 兩個 Survivor Space 均為 1M, 因此只要新建立的物件超過了 8M, 就會執行 minor GC,上面的程式碼中保證了 bytes 屬性中的 value 的大小在 8M, 因此可以保證在執行的過程中會經歷一次 minor GC, 按照複製收集器中的講解, 下面的程式執行狀況則會有所不同:
byte[] bytes=new byte[8*1024*1024];
這個物件會直接被分配到 old generation 中, 並不會觸發 minor GC, 這也是為什麼之前的一段程式中不直接分配大物件的原因。年老代中的物件則多數為長期活躍的物件, 因此 Java 採用標記—整理收集器或併發的標記—清除收集器來回收年老代中的物件。
觸發 JVM 執行 Full GC 的情況有如下兩種:
1、 Old Generation 空間滿或接近某個比例
Old Generation 空間滿的原因是從新生代提升到舊生代的物件大小 當前舊生代的物件
的大小已經接近 Old Generation 的空間大小, 標記—整理收集器的觸發條件為 Old Generation
空間滿, CMS 的觸發條件為 Old Generation 接近某個比例。
按照之前對於複製收集器的描述, 物件從新生代提升到舊生代的原因有如下三種:
1. 新分配的物件的大小超過了 Eden Space 的大小;
2. 物件在新生代中經過了 -XX:MaxTenuringThreshold 次仍然存活;
3.Minor GC 時放不進 To Space 中的物件;
CMS 可通過-XX:CMSInitiatingOccupancyFraction 來指定舊生代中空間使用比率佔到多少時, 開始執行 CMS, 預設值為 68%。
當 Full GC 後空間仍然不足以放入物件時, JVM 會丟擲 OutOfMemory 的錯誤資訊, 例如下面的程式碼:

byte[] toBytes=new byte[1024*1024];
byte[] maxBytes=new byte[8*1024*1024];

當 jvm 的啟動引數設定為-Xmn10M –Xms18M –Xmx18M 時, 上面的程式碼執行會直接報出
如下錯誤:
java.lang.OutOfMemoryError: Java heap space
當看到這個錯誤時, 說明 jvm 的空間不足或是系統有記憶體洩露, 例如該釋放的引用沒釋放等, 但出現這個錯誤時 jvm 不一定會 crash, 伴隨著的是 Full GC 的頻繁執行, 會嚴重影響應用的響應速度。
2、 Permanet Generation 空間滿
Permanet Generation 中存放的為一些 class 的資訊等, 當系統中需要載入的類、 反射的類和呼叫的方法較多的時候, Permanet Generation 可能會被佔滿, 佔滿時如果經過 Full GC仍然回收不了 , 那麼 JVM 會丟擲如下錯誤資訊:
java.lang.OutOfMemoryError: PermGen space
當看到這個錯誤時, 說明 Perm 空間分配的不足, 通常的解決方案為通過增大 Perm 空間來解決, 配置的引數為: -XX:PermSize 以及-XX:MaxPermSize。
GC 仍然在繼續的發展, 除了這些已有的 GC 外, JDK 7 中增加了一種新的 Garbage First的收集器, 同時 Java 為了能夠滿足實時系統的要求, 還提供了一個 RealTime 版的 JDK, 在這個 JDK 中允許開發人員更加靈活的控制物件的生命週期, 例如可以在某個方法中執行完畢後就自動回收物件的記憶體, 而不是等到 minor GC 或 Full GC, 這兩個變化對於編寫高效能的JAVA 應用而言都會產生不小的影響, 因此在本章節中也對其進行介紹。

Garbage First

Garbage First 簡稱 G1, 它的目標是要做到儘量減少 GC 所導致的應用暫停的時間, 讓應用達到準實時的效果, 同時保持 JVM 堆空間的利用率, 其最大的特色在於允許指定在某個時間段內 GC 所導致的應用暫停的時間最大為多少, 例如在 100 秒內最多允許 GC 導致的應用暫停時間為 1 秒, 這個特性對於準實時響應的系統而言非常的吸引人, 這樣就再也不用擔心繫統突然會暫停個兩三秒了。
G1 要做到這樣的效果, 也是有前提的, 一方面是硬體環境的要求, 必須是多核的 CPU以及較大的記憶體( 從規範來看, 512M 以上就滿足條件了 ), 另外一方面是需要接受吞吐量的稍微降低, 對於實時性要求高的系統而言, 這點應該是可以接受的。
為了能夠達到這樣的效果, G1 在原有的各種 GC 策略上進行了吸收和改進, 在 G1 中可以看到增量收集器和 CMS 的影子, 但它不僅僅是吸收原有 GC 策略的優點, 並在此基礎上做出了很多的改進, 簡單來說, G1 吸收了增量 GC 以及 CMS 的精髓, 將整個 jvm Heap 劃分為多個固定大小的 region, 掃描時採用 Snapshot-at-the-beginning 的併發 marking 演算法( 具體在後面內容詳細解釋) 對整個 heap 中的 region 進行 mark, 回收時根據 region 中活躍物件的bytes 進行排序, 首先回收活躍物件 bytes 小以及回收耗時短( 預估出來的時間) 的 region,回收的方法為將此 region 中的活躍物件複製到另外的 region 中, 根據指定的 GC 所能佔用的時間來估算能回收多少 region, 這點和以前版本的 Full GC 時得處理整個 heap 非常不同, 這樣就做到了能夠儘量短時間的暫停應用, 又能回收記憶體, 由於這種策略在回收時首先回收的是垃圾物件所佔空間最多的 region, 因此稱為 Garbage First。看完上面對於 G1 策略的簡短描述, 並不能清楚的掌握 G1, 在繼續詳細看 G1 的步驟之前, 必須先明白 G1 對於 JVM Heap 的改造, 這些對於習慣了劃分為 new generation、 old generation 的大家來說都有不少的新意。
G1 將 Heap 劃分為多個固定大小的 region, 這也是 G1 能夠實現控制 GC 導致的應用暫停時間的前提, region 之間的物件引用通過 remembered set 來維護, 每個 region 都有一個remembered set, remembered set 中包含了引用當前 region 中物件的 region 的物件的 pointer,由於同時應用也會造成這些 region 中物件的引用關係不斷的發生改變, G1 採用了 Card Table來用於應用通知 region 修改 remembered sets, Card Table 由多個 512 位元組的 Card 構成, 這些 Card 在 Card Table 中以 1 個位元組來標識, 每個應用的執行緒都有一個關聯的 remembered set log, 用於快取和順序化執行緒執行時造成的對於 card 的修改, 另外, 還有一個全域性的 filled RS buffers, 當應用執行緒執行時修改了 card 後, 如果造成的改變僅為同一 region 中的物件之間的關聯, 則不記錄 remembered set log, 如造成的改變為跨 region 中的物件的關聯, 則記錄到執行緒的 remembered set log, 如執行緒的 remembered set log 滿了 , 則放入全域性的 filled RS buffers 中, 執行緒自身則重新建立一個新的 remembered set log, remembered set 本身也是一個由一堆 cards 構成的雜湊表。
儘管 G1 將 Heap 劃分為了多個 region, 但其預設採用的仍然是分代的方式, 只是僅簡單的劃分為了年輕代( young) 和非年輕代, 這也是由於 G1 仍然堅信大多數新建立的物件都是不需要長的生命週期的, 對於應用新建立的物件, G1 將其放入標識為 young 的 region中, 對於這些 region, 並不記錄 remembered set logs, 掃描時只需掃描活躍的物件, G1 在分代的方式上還可更細的劃分為: fully young 或 partially young, fully young 方式暫停的時候僅處理 young regions, partially 同樣處理所有的 young regions, 但它還會根據允許的 GC 的暫停時間來決定是否要加入其他的非 young regions, G1 是執行到 fully-young 方式還是partially young 方式, 外部是不能決定的, 在啟動時, G1 採用的為 fully-young 方式, 當 G1完成一次 Concurrent Marking 後, 則切換為 partially young 方式, 隨後 G1 跟蹤每次回收的效率, 如果回收 fully-young 中的 regions 已經可以滿足記憶體需要的話, 那麼就切換回 fully young方式, 但當 heap size 的大小接近滿的情況下, G1 會切換到 partially young 方式, 以保證能提供足夠的記憶體空間給應用使用。
除了分代方式的劃分外, G1 還支援另外一種 pure G1 的方式, 也就是不進行代的劃分,pure 方式和分代方式的具體不同在下面的具體執行步驟中進行描述。
掌握了這些概念後, 繼續來看 G1 的具體執行步驟:
1. Initial Marking
G1 對於每個 region 都儲存了兩個標識用的 bitmap, 一個為 previous marking bitmap, 一個為 next marking bitmap, bitmap 中包含了一個 bit 的地址資訊來指向物件的起始點。
開始 Initial Marking 之前, 首先併發的清空 next marking bitmap, 然後停止所有應用執行緒,並掃描標識出每個 region 中 root 可直接訪問到的物件, 將 region 中 top 的值放入 next top at mark start( TAMS) 中, 之後恢復所有應用執行緒。
觸發這個步驟執行的條件為:
G1 定義了一個 JVM Heap 大小的百分比的閥值, 稱為 h, 另外還有一個 H, H 的值為(1-h)*Heap Size, 目前這個 h 的值是固定的, 後續 G1 也許會將其改為動態的, 根據 jvm 的執行情況來動態的調整, 在分代方式下, G1 還定義了一個 u 以及 soft limit,
soft limit 的值為 H-u*Heap Size, 當 Heap 中使用的記憶體超過了 soft limit 值時, 就會在一次 clean up 執行完畢後在應用允許的 GC 暫停時間範圍內儘快的執行此步驟;
在 pure 方式下, G1 將 marking 與 clean up 組成一個環, 以便 clean up 能充分的使用 marking 的資訊, 當 clean up 開始回收時, 首先回收能夠帶來最多記憶體空間的
regions, 當經過多次的 clean up, 回收到沒多少空間的 regions 時, G1 重新初始化一個新的 marking 與 clean up 構成的環。
2. Concurrent Marking
按照之前 Initial Marking 掃描到的物件進行遍歷, 以識別這些物件的下層物件的活躍狀
態, 對於在此期間應用執行緒併發修改的物件的以來關係則記錄到 remembered set logs 中,
新建立的物件則放入比 top 值更高的地址區間中, 這些新建立的物件預設狀態即為活躍的,同時修改 top 值。
3. Final Marking Pause
當應用執行緒的 remembered set logs 未滿時, 是不會放入 filled RS buffers 中的, 在這樣的情況下, 這些 remebered set logs 中記錄的 card 的修改就會被更新了 , 因此需要這一步, 這一步要做的就是把應用執行緒中存在的 remembered set logs 的內容進行處理, 並相應的修改remembered sets, 這一步需要暫停應用, 並行的執行。
4. Live Data Counting and Cleanup
值得注意的是, 在 G1 中, 並不是說 Final Marking Pause 執行完了, 就肯定執行 Cleanup這步的, 由於這步需要暫停應用, G1 為了能夠達到準實時的要求, 需要根據使用者指定的最大的 GC 造成的暫停時間來合理的規劃什麼時候執行 Cleanup, 另外還有幾種情況也是會觸發這個步驟的執行的:
G1 採用的是複製方法來進行收集, 必須保證每次的”to space”的空間都是夠的, 因此 G1 採取的策略是當已經使用的記憶體空間達到了 H 時, 就執行 Cleanup 這個步驟;
對於 full-young 和 partially-young 的分代模式的 G1 而言, 則還有情況會觸發 Cleanup的執行, full-young 模式下, G1 根據應用可接受的暫停時間、 回收 young regions需要消耗的時間來估算出一個 yound regions 的數量值, 當 JVM 中分配物件的 young regions 的數量達到此值時, Cleanup 就會執行; partially-young 模式下, 則會盡量頻繁的在應用可接受的暫停時間範圍內執行 Cleanup , 並最大限度的去執行non-young regions 的 Cleanup。這一步中 GC 執行緒並行的掃描所有 region, 計算每個 region 中低於 next TAMS 值中 marked data 的大小, 然後根據應用所期望的 GC 的短延時以及 G1 對於 region 回收所需的耗時的預估, 排序 region, 將其中活躍的物件複製到其他 region 中。G1 為了能夠儘量的做到準實時的響應, 例如估算暫停時間的演算法、 對於經常被引用的物件的特殊處理等, G1 為了能夠讓 GC 既能夠充分的回收記憶體, 又能夠儘量少的導致應用的暫停, 可謂費盡心思, 從 G1 的論文中的效能評測來看效果也是不錯的, 不過如果 G1 能允許開發人員在編寫程式碼時指定哪些物件是不用 mark 的就更完美了 , 這對於有巨大快取的應用而言, 會有很大的幫助, G1 隨 JDK 6 Update 14 已經 beta 釋出, 在 Java 7 中估計會正式的作為替代 CMS 的 GC 策略, 由於在本書的編寫階段中 G1 尚處於 beta 階段, 不過還是嚐嚐鮮,來看看 G1 的實際表現吧。

Real-Time 版的 JDK

為了滿足實時領域系統使用 Java 的需求, 也為了讓 Java 能夠進入更多的高階領域, Java推出了 Real-Time 版的規範( JSR-001, 更新的版本為 JSR-282), 並且各大廠商也都積極響應,相應的推出了 Real-Time 實現的 JDK, Real-Time 版的 JDK 對 java 做出了很多的改進, 例如強大的執行緒排程機制、 非同步的事件處理機制、 更為精準的時間刻度等, 在此最為關心的是其在java 記憶體管理方面的加強。GC 無疑是 java 進入實時領域的一個很大的障礙, 畢竟無論 GC 怎麼改進, 它肯定是會造成應用暫停的現象的, 而且是在執行時突然的就會造成暫停, 這對於實時系統來說是不可接受的, 因此 Real-Time 版的 JDK 在此方面做出了多方面的改進, 由於沒試用過, 在此也只能是按照規範紙上談兵了 。

新的記憶體管理機制

提供了兩種記憶體區域: Immortal 記憶體區域Scoped 記憶體區域
Immortal 記憶體區域用於保留永久的物件, 這些物件僅在應用結束執行時才會釋放記憶體,這個最典型的需求場景莫過於快取了 。
Scoped 記憶體區域用於保留臨時的物件, 位於 scope 中的物件在 scope 退出時, 這些物件所佔用的記憶體會被直接回收。
Immortal 記憶體區域和 Scoped 記憶體區域均不受 GC 管理, 因此基於這兩個記憶體區域來編寫的應用完全不用擔心 GC 會造成暫停的現象。
允許 Java 應用直接訪問實體記憶體
在保證安全的情況下, Real-Time JDK 允許 Java 應用直接訪問實體記憶體, 而非像以前的java 程式, 需要通過 native code 才能訪問, 能夠訪問實體記憶體, 也就意味著可以直接將物件放入實體記憶體, 而非 jvm heap 中。

JVM 記憶體狀況檢視和分析工具

Java 本身提供了多種豐富的工具來幫助開發人員檢視和分析 GC 以及 JVM 記憶體的狀況,同時開源界和商業界也有一些工具可用於檢視、 分析 GC 以及 JVM 記憶體的狀況, 通過這些分析可以來排查程式中記憶體洩露的問題以及調優程式的效能, 在下面介紹幾種常用的免費工具,商業工具就不在此處介紹了 , 其中知名的有 JProfiler 等。

輸出 GC 日誌

輸出 GC 日誌對於跟蹤分析 GC 的狀況, 無疑是最明顯和直接的分析記憶體回收狀況的方法, 只是 GC 日誌輸出後需要人肉的進行分析, 來判斷 GC 的狀況。
JVM 支援將日誌輸出到控制檯或指定的檔案中, 方法為:

輸出到控制檯

在 JVM 的啟動引數中加入 -XX: PrintGC -XX: PrintGCDetails -XX: PrintGCTimeStamps-XX: PrintGCApplicationStoppedTime, 按照引數的順序分別可以輸出 GC 的簡要資訊, GC 的詳細資訊、 GC 的時間資訊以及 GC 造成的應用暫停的時間。

輸出到指定的檔案

在 1 中的 jvm 啟動引數中再增加-Xloggc: gc.log 可指定將 gc 的資訊輸出到 gc.log 中。所輸出的 GC 日誌會由於採用的 JDK 版本以及 GC 策略有所不同, 在 JDK 1.6.0 的環境中增加了以上引數後會打出類似如下的 GC 日誌資訊:

117491.126: [GC
[PSYoungGen: 612300K->61235K(637952K)] 1578088K->1029309K(1698816K), 0.0475350
secs] [Times: user=0.32 sys=0.01, real=0.05 secs]
此行表示在系統執行 117491.126 秒時, JVM 執行了一次 minor GC, Young Generation 最
大的空間為 637952K, 在回收前使用了 612300K, 回收後降為 61235K, 表明此次 minor GC
回收了 Young Generation 中 551065K 的空間, jvm Heap 最大的空間為 1698816K, 在回收前使用了 1578088K, 回收後降為 1029309K, 表明此次 minor GC 中有佔用 2286K 記憶體的物件從Young Generation 轉入了 Old Generation 中, 此次 minor GC 耗費的時間為 0.05 秒。
117511.687: [Full GC (System) [PSYoungGen: 52690K->0K(637632K)] [PSOldGen:
973066K->450587K(1060864K)] 1025757K->450587K(1698496K) [PSPermGen:
45296K->45296K(98304K)], 1.4847770 secs] [Times: user=1.48 sys=0.00, real=1.49 secs]
此行表示在系統執行 117511.687 秒時, JVM 執行了一次 Full GC, Young Generation 的空間從回收前使用了 52690K, 到回收後的 0K, Old Generation 的空間從回收前的 973066K, 到回收後的 450587K, 整個 JVM 堆空間從回收前的 1025757K, 到回收後的 450587K, 持久代的空間保持 45296K 沒變, 此次 Full GC 耗費的時間為 1.49 秒。
Total time for which application threads were stopped: 1.5281560 seconds
此行表示 GC 造成應用暫停執行的時間為 1.5281560 秒。
可用於 GC 跟蹤分析的引數還有-verbose:gc、 -XX: PrintTenuringDistribution 等等, 另外GC 的日誌資訊的分析方法也是多種多樣, 並且隨採用的 GC 策略不同而不同, 這些內容會在效能調優章節中進一步的講解。

GC Portal

將 GC 日誌輸出固然有一定的作用, 但如果要靠人肉進行分析的話還是相當複雜的, 因此 Sun 提供了一個 GC Portal 來幫助分析這些 GC 日誌, 並生成相關的圖形化的報表, GC Portal部署起來會有些麻煩, 它需要執行在老版本的 Tomcat 上, 同時需要資料庫, 部署完畢後通過上傳日誌檔案的方式即可完成 GC 日誌的分析, 此 GC 日誌輸出的 JVM 引數使用的為:

-verbose:gc –XX: PrintGCDetails -XX: PrintGCTimeStamps [-Xloggc:檔名 ]

在上傳日誌時 GC Portal 的選項裡只有 jdk 1.2 或 jdk 1.2—1.4 的版本, 但經過測試, JDK 6 的日誌也是可以分析出來的, 但它的限制在於僅支援 5MB 的 gc 日誌的分析, GC Portal 可提供吞吐量的分析、 耗費的 CPU 的時間、 造成的應用暫停的時間、 每秒從新生代轉化到舊生代的數量、 minor GC的狀況以及 Full GC 的狀況等, 圖示如下:


GC Portal 中還有一個比較有意思的部分是提供調整 GC 引數的預測, 例如可以選擇給young size 增加 20%的空間, GC Portal 會根據當前的日誌資訊來評估在調整引數後的執行效果, 雖然不一定準, 但確實還是有些參考意義的。

JConsole

JConsole 可以圖形化的檢視 JVM 中記憶體的 GC 狀況, 可以很容易的從圖形中看出 GC 的變化狀況, JConsole 是 JDK 5 及以上的版本中自帶的工具, 位於 JDK 的 bin 目錄下, 執行時直接執行 JConsole.exe 或 JConsole.sh( 要求支援圖形介面), 在本地的 Tab 頁上看到執行了java 的 pid, 雙擊即可檢視相應程序的 JVM 的狀況, 同時, JConsole 也支援檢視遠端的 JVM的執行狀況, 具體可參見 JConsole 的 User Guide。
JConsole 中顯示了 JVM 中很多的資訊: 記憶體、 執行緒、 類和 MBean 等, 在開啟 JConsole的記憶體 Tab 頁後, 可看到 JVM 記憶體部分的執行狀況, 這對於分析記憶體是否有溢位以及 GC 的效果能夠更直接明瞭的看出來, 在效能調優章節中將再次使用此工具, 並進行更加詳細的解釋, JConsole 的執行效果圖示如下:

JVisualVM

JVisualVM 是 JDK 6 update 7 之後推出的一個工具, 此工具可以看做是一個類似 JProfiler的工具, 基於此工具可檢視記憶體的消耗情況、 執行緒的執行狀況以及程式中消耗 CPU、 記憶體的動作。
在記憶體方面的分析上, JVisualVM 帶來的最大的好處是其可通過安裝 VisualGC 外掛來分析 GC 趨勢、 記憶體消耗詳細狀況。
VisualGC 的執行圖示如下:


在上面的圖中可看到各區的記憶體消耗狀況以及 GC Time 的圖表, 而其提供的 Histogram檢視對於調優也有很大的幫助。
基於 JVisualVM 的 Profiler 中的 Memory 則可檢視物件佔用記憶體的狀況, 圖示如下:

JMap

JMap 是 JDK 中自帶的一個用於分析 jvm 記憶體狀況的工具, 位於 JDK 的 bin 目錄下, 使用 JMap 可檢視目前 JVM 中各個代的記憶體狀況, JVM 中物件的記憶體的佔用狀況、 匯出整個 JVM
中的記憶體資訊。

檢視 JVM 中各個代的記憶體狀況

在 linux 上直接通過 jmap [pid], 就可檢視整個 JVM 中記憶體的狀況, 看到的資訊類似如下( 和 JDK 版本、 GC 策略有關):

using thread-local object allocation.
Parallel GC with 8 thread(s)
Heap Configuration:
MinHeapFreeRatio = 40
MaxHeapFreeRatio = 70
MaxHeapSize = 1610612736 (1536.0MB)
NewSize = 524288000 (500.0MB)
MaxNewSize = 524288000 (500.0MB)
OldSize = 4194304 (4.0MB)
NewRatio = 8
SurvivorRatio = 8
PermSize = 100663296 (96.0MB)
MaxPermSize = 268435456 (256.0MB)
Heap Usage:
PS Young Generation
Eden Space:
capacity = 430702592 (410.75MB)
used = 324439936 (309.4100341796875MB)
free = 106262656 (101.3399658203125MB)
75.32806675098904% used
From Space:
capacity = 46333952 (44.1875MB)
used = 13016424 (12.413429260253906MB)
free = 33317528 (31.774070739746094MB)
28.092626331550566% used
To Space:
capacity = 46792704 (44.625MB)
used = 0 (0.0MB)
free = 46792704 (44.625MB)
0.0% used
PS Old Generation
capacity = 1086324736 (1036.0MB)
used = 945707880 (901.8973159790039MB)
free = 140616856 (134.1026840209961MB)
87.05572548059884% used
PS Perm Generation
capacity = 100663296 (96.0MB)
used = 46349592 (44.202415466308594MB)
free = 54313704 (51.797584533691406MB)
46.044182777404785% used

從上面的資訊中可看出 JVM 堆的配置資訊, 例如 NewSize、 NewRatio、 SurvivorRatio 等;
JVM 堆的使用情況, 例如新生代中的 Eden Space、 From Space、 To Space 的使用情況、 舊生代和持久代的使用情況。

JVM 中物件的記憶體的佔用情況

在檢視 JVM 記憶體狀況時, 除了需要知道每個代的佔用情況外, 很多時候更想知道的是其中各個物件佔用的記憶體大小, 這樣便於分析物件的記憶體佔用的情況, 在分析 OutOfMemory的場景中尤其適用。
輸入 jmap –histo [pid]即可檢視到 jvm 堆中物件的詳細佔用情況, 類似如下:


輸出的內容按照佔用的空間的大小排序, 例如上面的[C, 表示的是 char 型別的物件在jvm 中總共有 243707 個例項, 佔用了 501638784 bytes 的空間。

匯出整個 JVM 中的記憶體資訊

通過上面的方法能檢視到 jvm 中物件記憶體的佔用情況, 確實已經不錯了 , 但很多時候還需要知道這個物件到底是誰建立的, 例如上面顯示出來的[C, 只能知道它佔用了那麼多的空間, 但不知道是誰建立出的[C, jmap 也想到了這點, 於是提供了匯出整個 jvm 中的記憶體資訊的支援, 基於一些 jvm 記憶體的分析工具, 例如 sun JDK 6 中的 jhat、 Eclipse Memory Analyzer,可以分析 jvm 中記憶體的詳細資訊, 例如[C 是哪些物件建立的。
執行如下命令即可匯出整個 jvm 中記憶體資訊:
jmap -dump:format=b,file=檔名 [pid]

JHat

JHat 是 Sun JDK 6 及以上版本中自帶的一個用於分析 jvm 堆 dump 檔案的工具, 基於此工具可分析 jvm heap 中物件的記憶體佔用狀況、 引用關係等。
執行如下命令分析 jvm 堆的 dump 檔案:
jhat –J-Xmx1024M [file]
執行後等待 console 中輸出 Started HTTP server on port 7000, 看到這個後就可以通過瀏覽器訪問 http://ip:7000 了 , 此頁面預設為按 package 分類顯示系統中所有的物件例項, 在
頁面的最下端有 Other Queries 導航, 其中有顯示 jvm 中物件例項個數的連結、 有顯示 jvm中物件大小的連結等, 點選顯示 jvm 中物件大小的連結, 得到的結果圖示如下:


點選上圖中的 class [C, 可以看到有哪些物件例項引用了這個物件, 或者建立了這個物件, 總體來說 jhat 還是不錯的, 不過 jhat 在分析大的堆 dump 檔案時表現的不好, 速度很慢。

JStat

JStat 是 Sun JDK 自帶的一個統計分析 JVM 執行狀況的工具, 位於 JDK 的 bin 目錄下, 除了可用於分析 GC 的狀況外, 還可用於分析編譯的狀況、 class 載入的狀況等。
JStat 用於 GC 分析的引數有:
-gc、 -gccapacity、 -gccause、 -gcnew、 -gcnewcapacity、 -gcold、-gcoldcapacity、 -gcpermcapacity、 -gcutil , 常用的為-gcutil, 通過-gcutil 可按一定頻率檢視 jvm中各代的空間的佔用情況、 minor GC 的次數、 消耗的時間、 full GC 的次數以及消耗的時間的統計, 執行 jstat –gcutil [pid] [interval], 可看到類似如下的輸出資訊:

 S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 74.24 96.73 73.43 46.05 17808 382.335 208 315.197 697.533
45.37 0.00 28.12 74.97 46.05 17809 382.370 208 315.197 697.568

其中 S0、 S1 就是 Survivor 空間的使用率, E 表示 Eden 空間的使用率, O 表示舊生代空間的使用率, P 表示持久代的使用率, YGC 表示 minor GC 的執行次數, YGCT 表示 minor GC執行消耗的時間, FGC 表示 Full GC 的執行次數, FGCT 表示 Full GC 執行消耗的時間, GCT 表示 Minor GC Full GC 執行消耗的時間。

Eclipse Memory Analyzer

Eclipse Memory Analyzer 是 Eclipse 提供的一個用於分析 jvm 堆 dump 檔案的外掛, 藉助這個外掛可用於檢視物件的記憶體佔用狀況、 引用關係、 分析記憶體洩露等。
在 eclipse 中可以直接遠端安裝此外掛, 不過由於此外掛在分析堆 dump 檔案時比較耗記憶體, 因此在分析前最好先將 eclipse 的 jvm 的記憶體設定大一點, MAT 分析 dump 檔案後的物件佔用記憶體以及引用關係圖示如下:


MAT 還是非常不錯的, 相對而言功能比 jhat 強大很多, 分析的速度也快一些, 因此如果需要分析 jvm 堆 dumap 檔案, 首選推薦的還是 MAT。