JVM GC 之「AdaptiveSizePolicy」實戰
一、AdaptiveSizePolicy簡介
AdaptiveSizePolicy(自適應大小策略) 是 JVM GC Ergonomics(自適應調節策略) 的一部分。
如果開啟 AdaptiveSizePolicy,則每次 GC 後會重新計算 Eden、From 和 To 區的大小,計算依據是 GC 過程中統計的 GC 時間、吞吐量、記憶體佔用量 。
開啟 AdaptiveSizePolicy 的引數為:
-XX:+UseAdaptiveSizePolicy
JDK 1.8 預設使用 UseParallelGC 垃圾回收器,該垃圾回收器預設啟動了 AdaptiveSizePolicy。
AdaptiveSizePolicy 有三個目標:
- Pause goal:應用達到預期的 GC 暫停時間。
- Throughput goal:應用達到預期的吞吐量,即應用正常執行時間 / (正常執行時間 + GC 耗時)。
- Minimum footprint:儘可能小的記憶體佔用量。
AdaptiveSizePolicy 為了達到三個預期目標,涉及以下操作:
- 如果 GC 停頓時間超過了預期值,會減小記憶體大小。理論上,減小記憶體,可以減少垃圾標記等操作的耗時,以此達到預期停頓時間。
- 如果應用吞吐量小於預期,會增加記憶體大小。理論上,增大記憶體,可以降低 GC 的頻率,以此達到預期吞吐量。
- 如果應用達到了前兩個目標,則嘗試減小記憶體,以減少記憶體消耗。
注:AdaptiveSizePolicy 涉及的內容比較廣,本文主要關注 AdaptiveSizePolicy 對年輕代大小的影響,以及隨之產生的問題。
AdaptiveSizePolicy 看上去很智慧,但有時它也很調皮,會引發 GC 問題。
二、由 AdaptiveSizePolicy 引發的 GC 問題
某一天,有一位群友在群裡發來一張 jmap -heap 記憶體使用情況圖。
說 Survivor 區佔比總是在 98% 以上。

jmap -heap 記憶體情況
仔細觀察這張圖,其中包含幾個重要資訊:
- From 和 To 區都比較小,只有 10M。容量比較小,才顯得佔比高。
- Old 區的佔比和使用量(兩個多 G)都比較高。
此外,還可以看到 Eden、From、To 之間的比例不是預設的 8:1:1。
於是,立馬就想到 AdaptiveSizePolicy。
經群友的確認,使用的是 JDK 1.8 的預設回收演算法。
JVM 引數配置如下:

JVM 引數配置
引數中沒有對 GC 演算法進行配置,即使用預設的 UseParallelGC。
用預設引數啟動一個基於 JDK 1.8 的應用,然後使用 jinfo -flags pid 即可檢視預設配置的 GC 演算法。

預設使用 UseParallelGC
上文提到,該演算法預設開啟 AdaptiveSizePolicy。
即使 SurvivorRatio 的預設值是 8,但年輕代三個區域之間的比例仍會變動。
這個問題,可以參考來自R大的回答:
http://hllvm.group.iteye.com/group/topic/35468
HotSpot VM裡,ParallelScavenge系的GC(UseParallelGC / UseParallelOldGC)預設行為是SurvivorRatio如果不顯式設定就沒啥用。顯式設定到跟預設值一樣的值則會有效果。
因為ParallelScavenge系的GC最初設計就是預設開啟AdaptiveSizePolicy的,它會自動、自適應的調整各種引數。
在群友的截圖中,From 區只有 10M,Eden 區佔用了卻超過年輕代八成的空間。
其原因是 AdaptiveSizePolicy 為了達到期望的目標而進行了調整。
大概定位了 Survivor 區小的原因,還有一個問題:
為什麼老年代的佔比和使用量都比較高?
於是群友使用 jmap -histo 檢視堆中的例項。

jmap -histo 結果
可以看出,其中有兩個類的例項比較多,分別是:
- LinkedHashMap$Entry
- ExpiringCache$Entry
於是,搜尋關鍵類 ExpiringCache。
可以看出在 ExpiringCache 的建構函式中,初始化了一個 LinkedHashMap。
懷疑 LinkedHashMap$Entry 數量多的原因和 ExpiringCache$Entry 直接有關。
ExpiringCache(long millisUntilExpiration) { this.millisUntilExpiration = millisUntilExpiration; map = new LinkedHashMap<String,Entry>() { protected boolean removeEldestEntry(Map.Entry<String,Entry> eldest) { return size() > MAX_ENTRIES; } }; }
注:該 map 用於儲存快取資料,設定了淘汰機制。當 map 大小超過 MAX_ENTRIES = 200 時,會開始淘汰。
接著檢視 ExpiringCache$Entry 類。
這個類的主要屬性是「時間戳」和「值」,時間戳用於超時淘汰(快取常用手法)。
static class Entry { private longtimestamp; private String val; …… }
接著檢視哪裡使用到了這個快取。
於是找到 get 方法,定位到只有一個類的一個方法使用到了這個快取。

快取 get 方法

使用到快取的函式
接著往上層找,看到了一個熟悉的類:File,它的 getCanonicalPath() 方法使用到了這個快取。

File 類的 getCanonicalPath 方法
該方法用於獲取檔案路徑。
於是,詢問群友,是否在專案中使用了 getCanonicalPath() 方法。
得到的回答是肯定的。
當專案中使用 getCanonicalPath() 方法獲取檔案路徑時,會發生以下的事情:
- 首先從快取中讀取,取不到則需要生成快取。
- 生成快取需要新建 ExpiringCache$Entry 物件用於儲存快取值,這些新建的物件都會被 分配到 Eden 區 。
- 當 大量使用 getCanonicalPath() 方法 時,快取數量超過 MAX_ENTRIES = 200 開啟淘汰策略。原來 map 中的 ExpiringCache$Entry 物件變成垃圾物件,真正存活的 Entry 只有 200 個。
- 當發生 YGC 時, 理論上存活的 200 個 Entry 會去往 To 區 ,其他被淘汰的垃圾 Entry 物件會被回收。
- 但由於 AdaptiveSizePolicy 將 To 區調整到只有 10MB, 裝不下本該移動到 To 區的物件,只能直接移動到老年代 。
- 於是,在每次 YGC 時,會有接近 200 個存活的 ExpiringCache$Entry 物件進入到老年代。隨著快取淘汰機制的執行,這些 Entry 物件立馬又變成垃圾。
- 當物件進入老年代,即使變成了垃圾,也 需要等到老年代 GC 或者 FGC 才能將其回收 。由於老年代容量較大,可以承受多次 YGC 給予的 200 個 ExpiringCache$Entry 物件。
- 於是,老年代使用量逐漸變高。
老年代記憶體佔用量高的問題也定位到了。
因為每次 YGC 只有 200 個例項進入到老年代,問題顯得比較溫和。
只是隔一段時間觸發 FGC,應用執行看似正常。
接著使用 jstat -gcutil 檢視 GC 情況。
可以看到從應用啟動,一共發生了 15654 次 YGC。

jstat -gcutil 結果
推算每次 YGC 有 200 個 ExpiringCache$Entry 物件進入老年代。
那麼,老年代中大約存在 3130800 個 ExpiringCache$Entry 物件。
從之前的 jmap -histo 結果中看到,ExpiringCache$Entry 物件的數量是 6118824 個。
兩個數目都為百萬級。其餘約 300W 個例項應該都在 Eden 區。
每一次 YGC 後,都會有大量的 ExpiringCache$Entry 物件被回收。
從群友擷取的 GC log 中可以看出,YGC 的頻率大概為 23 秒一次。

GC log
假設執行的 jmap -histo 命令是在即將觸發 YGC 之前。
那麼,應用大概在 20s 的事件內產生了 300W 個 ExpiringCache$Entry 例項,1s 內產生約 15W 個。
假設單機 QPS = 300,一次請求產生的 ExpiringCache$Entry 例項數約為 500 個。
猜測是在迴圈體中使用了 getCanonicalPath() 方法。
至此可以得出 Survior 區變小,老年代佔比變高的原因:
- 在預設 SurvivorRatio = 8 的情況下,沒有達到吞吐量的期望,AdaptiveSizePolicy 加大了 Eden 區的大小。From 和To 區被壓縮到只有 10M。
- 在專案中大量使用 getCanonicalPath() 方法,產生大量ExpiringCache$Entry 例項。
- 當 YGC 發生時候,由於 To 區太小,存活的 Entry 物件直接進入到老年代。老年代佔用量逐漸變大。
從群友的 jstat -gcutil 截圖中還可以看出,應用從啟動到使用該命令,觸發了 19 次 FGC,一共耗時 9.933s,平均每次 FGC 耗時為 520ms。
這樣的停頓時間,對於一個高 QPS 的應用是無法忍受的。
定位到了問題的原因,解決方案比較簡單。
解決的思路有兩個:
- 不使用快取,就不會生成大量 ExpiringCache$Entry 例項。
- 阻止 AdaptiveSizePolicy 縮小 To 區。讓 YGC 時存活的 ExpiringCache$Entry 物件都能順利進入 To 區,保留在年輕代,而不是進入老年代。
解決方案一:
不使用快取。
使用 -Dsun.io.useCanonCaches = false
引數即可關閉快取。

sun.io.useCanonCaches 引數
這種方案解決比較方便,但這個引數並非常規引數,慎用。
解決方案二:
保持使用 UseParallelGC,顯式設定 -XX:SurvivorRatio=8。
配置引數進行測試:

預設配置
看到預設配置下,三者之間的比例不是 8:1:1。

加上引數 -Xmn100m -XX:SurvivorRatio=8
可以看到,加上引數 -Xmn100m -XX:SurvivorRatio=8 引數後,固定了 Eden 和 Survivor 之間的比例。
解決方案三:
使用 CMS 垃圾回收器。
CMS 預設關閉 AdaptiveSizePolicy。
配置引數 -XX:+UseConcMarkSweepGC,通過 jinfo 命令檢視,可以看到 CMS 預設減去/不使用 AdaptiveSizePolicy。

jinfo 結果
群友也是採用了這個方法:

使用 CMS 之後的 jmap -heap 結果
可以看出,Eden 和 Survivor 之間的比例被固定,To 區沒有被縮小。老年代的使用量和使用率也都很正常。
三、原始碼層面瞭解 AdaptiveSizePolicy
注:以下原始碼均主要基於 openjdk 8,不同 jdk 版本之間會有區別。
對原始碼的理解程度有限,對原始碼的理解也一直在路上。
有任何錯誤,還請各位指正,謝謝。
首先解釋,為什麼在 UseParallelGC 回收器的前提下,顯式配置 SurvivorRatio 即可固定年輕代三個區域之間的比例。
在 arguments.cpp 類中有一個 set_parallel_gc_flags() 方法。
從方法命名來看,是為了設定並行回收器的引數。
// If InitialSurvivorRatio or MinSurvivorRatio were not specified, but the // SurvivorRatio has been set, reset their default values to SurvivorRatio + // 2.By doing this we make SurvivorRatio also work for Parallel Scavenger. // See CR 6362902 for details. if (!FLAG_IS_DEFAULT(SurvivorRatio)) { if (FLAG_IS_DEFAULT(InitialSurvivorRatio)) { FLAG_SET_DEFAULT(InitialSurvivorRatio, SurvivorRatio + 2); } if (FLAG_IS_DEFAULT(MinSurvivorRatio)) { FLAG_SET_DEFAULT(MinSurvivorRatio, SurvivorRatio + 2); } }
當顯式設定 SurvivorRatio,即 !FLAG_IS_DEFAULT(SurvivorRatio),該方法會設定別的引數。
方法註釋上寫著:
make SurvivorRatio also work for Parallel Scavenger
通過顯式設定 SurvivorRatio 引數,SurvivorRatio 就會在 Parallel Scavenge 回收器中生效。
至於為何會生效,還有待進一步學習。
而預設是會被 AdaptiveSizePolicy 調整的。
接著檢視 AdaptiveSizePolicy 動態調整記憶體大小的程式碼。
JDK 1.8 預設的 UseParallelGC 回收器,其對應的年輕代回收演算法是 Parallel Scavenge。
觸發 GC 的原因有多種,最普通的一種是在年輕代分配記憶體失敗。
UseParallelGC 分配記憶體失敗引發 GC 的入口位於
vmPSOperations.cpp 類的 VM_ParallelGCFailedAllocation::doit() 方法。
之後依次呼叫了以下方法:
parallelScavengeHeap.cpp 類的 failed_mem_allocate(size_t size) 方法。
psScavenge.cpp 類的 invoke()、invoke_no_policy() 方法。
invoke_no_policy() 方法中有一段程式碼涉及 AdaptiveSizePolicy。
if (UseAdaptiveSizePolicy) { …… size_policy->compute_eden_space_size(young_live, eden_live, cur_eden, max_eden_size, false /* not full gc*/); …… }
在 GC 主過程完成後,如果開啟 UseAdaptiveSizePolicy 則會重新計算 Eden 區的大小。
在 compute_eden_space_size 方法中,有幾個判斷。
對應 AdaptiveSizePolicy 的三個目標:
- 與預期 GC 停頓時間對比。
- 與預期吞吐量對比。
- 如果達到預期,則調整記憶體容量。
if ((_avg_minor_pause->padded_average() > gc_pause_goal_sec()) || (_avg_major_pause->padded_average() > gc_pause_goal_sec())) { adjust_eden_for_pause_time(is_full_gc, &desired_promo_size, &desired_eden_size); } else if (_avg_minor_pause->padded_average() > gc_minor_pause_goal_sec()) { adjust_eden_for_minor_pause_time(is_full_gc, &desired_eden_size); } else if(adjusted_mutator_cost() < _throughput_goal) { assert(major_cost >= 0.0, "major cost is < 0.0"); assert(minor_cost >= 0.0, "minor cost is < 0.0"); adjust_eden_for_throughput(is_full_gc, &desired_eden_size); } else { if (UseAdaptiveSizePolicyFootprintGoal && young_gen_policy_is_ready() && avg_major_gc_cost()->average() >= 0.0 && avg_minor_gc_cost()->average() >= 0.0) { size_t desired_sum = desired_eden_size + desired_promo_size; desired_eden_size = adjust_eden_for_footprint(desired_eden_size, desired_sum); } }
詳細看其中一個判斷。
if ((_avg_minor_pause->padded_average() > gc_pause_goal_sec()) || (_avg_major_pause->padded_average() > gc_pause_goal_sec()))
如果統計的 YGC 或者 Old GC 時間超過了目標停頓時間,則會呼叫 adjust_eden_for_pause_time 調整 Eden 區大小。
gc_pause_goal_sec() 方法獲取預期停頓時間,在 ParallelScavengeHeap::initialize() 方法中,通過讀取 JVM 引數 MaxGCPauseMillis 獲取。

gc_pause_goal_sec() 來自 JVM 引數
接下來,再看 CMS 回收器。
CMS 初始化分代位於 cmsCollectorPolicy.cpp 類的 initialize_generations() 方法。
if (UseParNewGC) { if (UseAdaptiveSizePolicy) { _generations[0] = new GenerationSpec(Generation::ASParNew, _initial_gen0_size, _max_gen0_size); } else { _generations[0] = new GenerationSpec(Generation::ParNew, _initial_gen0_size, _max_gen0_size); } } else { _generations[0] = new GenerationSpec(Generation::DefNew, _initial_gen0_size, _max_gen0_size); } if (UseAdaptiveSizePolicy) { _generations[1] = new GenerationSpec(Generation::ASConcurrentMarkSweep, _initial_gen1_size, _max_gen1_size); } else { _generations[1] = new GenerationSpec(Generation::ConcurrentMarkSweep, _initial_gen1_size, _max_gen1_size); }
其中 _generations[0] 代表年輕代特徵,_generations[1] 代表老年代特徵。
如果設定不同的 UseParNewGC 、UseAdaptiveSizePolicy 引數,會對年輕代和老年代使用不同的策略。
CMS 垃圾回收入口位於 genCollectedHeap.cpp 類的 do_collection 方法。
在 do_collection 方法中,GC 主過程完成後,會對每個分代進行大小調整。
for (int j = max_level_collected; j >= 0; j -= 1) { // Adjust generation sizes. _gens[j]->compute_new_size(); }

使用 compute_new_size() 方法
本文主要討論 AdaptiveSizePolicy 對年輕代的影響,主要看 ASParNewGeneration 類,其中的 AS 字首就是 AdaptiveSizePolicy 的意思。
如果設定 -XX:+UseAdaptiveSizePolicy 則年輕代對應 ASParNewGeneration 類,否則對應 ParNewGeneration 類。
在 ASParNewGeneration 類中 compute_new_size() 方法中,呼叫了另一個方法調整 Eden 區大小。
size_policy->compute_eden_space_size(eden()->capacity(), max_gen_size());
該方法與 Parallel Scavenge 的 compute_eden_space_size 方法類似,也從三個方面對記憶體大小進行調整,分別是:
- adjust_eden_for_pause_time
- adjust_eden_for_throughput
- adjust_eden_for_footprint
接著進行測試,設定引數 -XX:+UseAdaptiveSizePolicy、
-XX:+UseConcMarkSweepGC。
期望 CMS 會啟用 AdaptiveSizePolicy,但根據 jmap -heap 結果檢視,並沒有啟動,年輕代三個區域之間的比例為 8:1:1。
從 jinfo 命令結果也可以看出,即使設定了 -XX:+UseAdaptiveSizePolicy,仍然關閉了 AdaptiveSizePolicy。

jinfo 結果
因為在 JDK 1.8 中,如果使用 CMS,無論 UseAdaptiveSizePolicy 如何設定,都會將 UseAdaptiveSizePolicy 設定為 false。
檢視 arguments.cpp 類中的 set_cms_and_parnew_gc_flags 方法,其呼叫了 disable_adaptive_size_policy 方法將 UseAdaptiveSizePolicy 設定成 false。
static void disable_adaptive_size_policy(const char* collector_name) { if (UseAdaptiveSizePolicy) { if (FLAG_IS_CMDLINE(UseAdaptiveSizePolicy)) { warning("disabling UseAdaptiveSizePolicy; it is incompatible with %s.", collector_name); } FLAG_SET_DEFAULT(UseAdaptiveSizePolicy, false); } }
如果是在啟動引數中設定了,則會打出提醒。

提醒 UseAdaptiveSizePolicy 引數和 CMS 不搭
但在 JDK 1.6 和 1.7 中,set_cms_and_parnew_gc_flags 方法的邏輯和 1.8 中的不同。
如果 UseAdaptiveSizePolicy 引數是預設的,則強制設定成 false。
如果顯式設定(complete),則不做改變。
// Turn off AdaptiveSizePolicy by default for cms until it is // complete. if (FLAG_IS_DEFAULT(UseAdaptiveSizePolicy)) { FLAG_SET_DEFAULT(UseAdaptiveSizePolicy, false); }
於是嘗試使用 JDK 1.6 搭建 web 應用,加上 -XX:+UseAdaptiveSizePolicy、-XX:+UseConcMarkSweepGC 兩個引數。
再用 jinfo -flag 檢視,看到兩個引數都被置為 true。

jinfo -flag 結果
接著,使用 jmap -heap 檢視堆記憶體使用情況,發現展示不了資訊。

jmap -heap 結果
這其實是 JDK 低版本的一個 Bug。
1.6.30以上到1.7的全部版本已經確認有該問題,jdk8修復。
參考: UseAdaptiveSizePolicy與CMS垃圾回收同時使用導致的JVM報錯 https://www.cnblogs.com/moonandstar08/p/5751175.html
四、問題小結
- 現階段大多數應用使用 JDK 1.8,其預設回收器是 Parallel Scavenge,並且預設開啟了 AdaptiveSizePolicy。
- AdaptiveSizePolicy 動態調整 Eden、Survivor 區的大小,存在將 Survivor 區調小的可能。當 Survivor 區被調小後,部分 YGC 後存活的物件直接進入老年代。老年代佔用量逐漸上升從而觸發 FGC,導致較長時間的 STW。
- 建議使用 CMS 垃圾回收器,預設關閉 AdaptiveSizePolicy。
- 建議在 JVM 引數中加上 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution,讓 GC log 更加詳細,方便定位問題。
五、參考資料
- Garbage Collector Ergonomics
https://docs.oracle.com/javase/8/docs/technotes/guides/vm/gc-ergonomics.html - File,file.getPath(), getAbsolutePath(), getCanonicalPath()區別
https://blog.csdn.net/u010900754/article/details/51451771 - UseAdaptiveSizePolicy與CMS垃圾回收同時使用導致的JVM報錯
https://www.cnblogs.com/moonandstar08/p/5751175.html - JVM分析工具概述
https://juejin.im/post/5b59975fe51d4519700f70aa