小白進階篇之爲什麼你的JVMSurvivor區僅僅20M

背景

某一天,有一位同學在羣裏發來一張 jmap -heap 內存使用情況圖。

說 Survivor 區佔比總是在 98% 以上。

 

image

 

 

仔細觀察這張圖,其中包含幾個重要信息:

  1. From 和 To 區都比較小,只有 10M。容量比較小,才顯得佔比高。
  2. Old 區的佔比和使用量(兩個多 G)都比較高。

此外,還可以看到 Eden、From、To 之間的比例不是默認的 8:1:1。

於是,立馬就想到 AdaptiveSizePolicy。

經羣友的確認,使用的是 JDK 1.8 的默認回收算法。

JVM 參數配置如下:

 

image

 

 

參數中沒有對 GC 算法進行配置,即使用默認的 UseParallelGC。

用默認參數啓動一個基於 JDK 1.8 的應用,然後使用 jinfo -flags pid 即可查看默認配置的 GC 算法。

 

image

 

 

默認開啓 AdaptiveSizePolicy

AdaptiveSizePolicy(自適應大小策略) 是 JVM GC Ergonomics(自適應調節策略) 的一部分。

如果開啓 AdaptiveSizePolicy,則每次 GC 後會重新計算 Eden、From 和 To 區的大小,計算依據是 GC 過程中統計的 GC 時間、吞吐量、內存佔用量

JDK 1.8 默認使用 UseParallelGC 垃圾回收器,該垃圾回收器默認啓動了 AdaptiveSizePolicy。

AdaptiveSizePolicy 有三個目標:

  1. Pause goal:應用達到預期的 GC 暫停時間。
  2. Throughput goal:應用達到預期的吞吐量,即應用正常運行時間 / (正常運行時間 + GC 耗時)。
  3. Minimum footprint:儘可能小的內存佔用量。

AdaptiveSizePolicy 爲了達到三個預期目標,涉及以下操作:

  1. 如果 GC 停頓時間超過了預期值,會減小內存大小。理論上,減小內存,可以減少垃圾標記等操作的耗時,以此達到預期停頓時間。
  2. 如果應用吞吐量小於預期,會增加內存大小。理論上,增大內存,可以降低 GC 的頻率,以此達到預期吞吐量。
  3. 如果應用達到了前兩個目標,則嘗試減小內存,以減少內存消耗。

注:AdaptiveSizePolicy 涉及的內容比較廣,本文主要關注 AdaptiveSizePolicy 對年輕代大小的影響,以及隨之產生的問題。

AdaptiveSizePolicy 看上去很智能,但有時它也很調皮,會引發 GC 問題。

即使 SurvivorRatio 的默認值是 8,但年輕代三個區域之間的比例仍會變動。

這個問題,可以參考來自R大的回答:

hllvm.group.iteye.com/group/topic…

HotSpot VM裏,ParallelScavenge系的GC(UseParallelGC / UseParallelOldGC)默認行爲是SurvivorRatio如果不顯式設置就沒啥用。顯式設置到跟默認值一樣的值則會有效果。

因爲ParallelScavenge系的GC最初設計就是默認打開AdaptiveSizePolicy的,它會自動、自適應的調整各種參數。

在羣友的截圖中,From 區只有 10M,Eden 區佔用了卻超過年輕代八成的空間。

其原因是 AdaptiveSizePolicy 爲了達到期望的目標而進行了調整。


大概定位了 Survivor 區小的原因,還有一個問題:

爲什麼老年代的佔比和使用量都比較高?

於是羣友使用 jmap -histo 查看堆中的實例。

 

image

 

 

可以看出,其中有兩個類的實例比較多,分別是:

  1. LinkedHashMap$Entry
  2. ExpiringCache$Entry

於是,搜索關鍵類 ExpiringCache。

可以看出在 ExpiringCache 的構造函數中,初始化了一個 LinkedHashMap。

懷疑 LinkedHashMapEntry 直接有關。

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 long   timestamp;     private String val;     …… }

接着查看哪裏使用到了這個緩存。

於是找到 get 方法,定位到只有一個類的一個方法使用到了這個緩存。

 

image

 

 

 

image

 

 

接着往上層找,看到了一個熟悉的類:File,它的 getCanonicalPath() 方法使用到了這個緩存。

 

image

 

 

該方法用於獲取文件路徑。

於是,詢問羣友,是否在項目中使用了 getCanonicalPath() 方法。

得到的回答是肯定的。

當項目中使用 getCanonicalPath() 方法獲取文件路徑時,會發生以下的事情:

  1. 首先從緩存中讀取,取不到則需要生成緩存。
  2. 生成緩存需要新建 ExpiringCache$Entry 對象用於保存緩存值,這些新建的對象都會被分配到 Eden 區
  3. 大量使用 getCanonicalPath() 方法時,緩存數量超過 MAX_ENTRIES = 200 開啓淘汰策略。原來 map 中的 ExpiringCache$Entry 對象變成垃圾對象,真正存活的 Entry 只有 200 個。
  4. 當發生 YGC 時,理論上存活的 200 個 Entry 會去往 To 區,其他被淘汰的垃圾 Entry 對象會被回收。
  5. 但由於 AdaptiveSizePolicy 將 To 區調整到只有 10MB,裝不下本該移動到 To 區的對象,只能直接移動到老年代
  6. 於是,在每次 YGC 時,會有接近 200 個存活的 ExpiringCache$Entry 對象進入到老年代。隨着緩存淘汰機制的運行,這些 Entry 對象立馬又變成垃圾。
  7. 當對象進入老年代,即使變成了垃圾,也需要等到老年代 GC 或者 FGC 才能將其回收。由於老年代容量較大,可以承受多次 YGC 給予的 200 個 ExpiringCache$Entry 對象。
  8. 於是,老年代使用量逐漸變高。

老年代內存佔用量高的問題也定位到了。

因爲每次 YGC 只有 200 個實例進入到老年代,問題顯得比較溫和。

只是隔一段時間觸發 FGC,應用運行看似正常。


接着使用 jstat -gcutil 查看 GC 情況。

可以看到從應用啓動,一共發生了 15654 次 YGC。

 

image

 

 

推算每次 YGC 有 200 個 ExpiringCache$Entry 對象進入老年代。

那麼,老年代中大約存在 3130800 個 ExpiringCache$Entry 對象。

從之前的 jmap -histo 結果中看到,ExpiringCache$Entry 對象的數量是 6118824 個。

兩個數目都爲百萬級。其餘約 300W 個實例應該都在 Eden 區。

每一次 YGC 後,都會有大量的 ExpiringCache$Entry 對象被回收。

從羣友截取的 GC log 中可以看出,YGC 的頻率大概爲 23 秒一次。

 

image

 

 

假設運行的 jmap -histo 命令是在即將觸發 YGC 之前。

那麼,應用大概在 20s 的事件內產生了 300W 個 ExpiringCache$Entry 實例,1s 內產生約 15W 個。

假設單機 QPS = 300,一次請求產生的 ExpiringCache$Entry 實例數約爲 500 個。

猜測是在循環體中使用了 getCanonicalPath() 方法。

至此可以得出 Survior 區變小,老年代佔比變高的原因:

  1. 在默認 SurvivorRatio = 8 的情況下,沒有達到吞吐量的期望,AdaptiveSizePolicy 加大了 Eden 區的大小。From 和To 區被壓縮到只有 10M。
  2. 在項目中大量使用 getCanonicalPath() 方法,產生大量ExpiringCache$Entry 實例。
  3. 當 YGC 發生時候,由於 To 區太小,存活的 Entry 對象直接進入到老年代。老年代佔用量逐漸變大。

從羣友的 jstat -gcutil 截圖中還可以看出,應用從啓動到使用該命令,觸發了 19 次 FGC,一共耗時 9.933s,平均每次 FGC 耗時爲 520ms。

這樣的停頓時間,對於一個高 QPS 的應用是無法忍受的。


定位到了問題的原因,解決方案比較簡單。

解決的思路有兩個:

  1. 不使用緩存,就不會生成大量 ExpiringCache$Entry 實例。
  2. 阻止 AdaptiveSizePolicy 縮小 To 區。讓 YGC 時存活的 ExpiringCache$Entry 對象都能順利進入 To 區,保留在年輕代,而不是進入老年代。

解決方案一:

不使用緩存。

使用 -Dsun.io.useCanonCaches = false 參數即可關閉緩存。

 

image

 

 

這種方案解決比較方便,但這個參數並非常規參數,慎用。

解決方案二:

保持使用 UseParallelGC,顯式設置 -XX:SurvivorRatio=8。

配置參數進行測試:

 

image

 

 

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

 

image

可以看到,加上參數 -Xmn100m -XX:SurvivorRatio=8 參數後,固定了 Eden 和 Survivor 之間的比例。

解決方案三:

使用 CMS 垃圾回收器。

CMS 默認關閉 AdaptiveSizePolicy。

配置參數 -XX:+UseConcMarkSweepGC,通過 jinfo 命令查看,可以看到 CMS 默認減去/不使用 AdaptiveSizePolicy。

 

image

 

 

羣友也是採用了這個方法:

 

image

 

 

可以看出,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 的三個目標:

  1. 與預期 GC 停頓時間對比。
  2. 與預期吞吐量對比。
  3. 如果達到預期,則調整內存容量。

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 獲取。

 

image

接下來,再看 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(); }

 

image

 

 

本文主要討論 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。

 

image

 

 

因爲在 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);   } }

如果是在啓動參數中設置了,則會打出提醒。

 

image

 

 

但在 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。

 

image

 

 

接着,使用 jmap -heap 查看堆內存使用情況,發現展示不了信息。

 

image

 

 

這其實是 JDK 低版本的一個 Bug。

1.6.30以上到1.7的全部版本已經確認有該問題,jdk8修復。


四、問題小結

  1. 現階段大多數應用使用 JDK 1.8,其默認回收器是 Parallel Scavenge,並且默認開啓了 AdaptiveSizePolicy。
  2. AdaptiveSizePolicy 動態調整 Eden、Survivor 區的大小,存在將 Survivor 區調小的可能。當 Survivor 區被調小後,部分 YGC 後存活的對象直接進入老年代。老年代佔用量逐漸上升從而觸發 FGC,導致較長時間的 STW。
  3. 建議使用 CMS 垃圾回收器,默認關閉 AdaptiveSizePolicy。
  4. 建議在 JVM 參數中加上 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -XX:+PrintTenuringDistribution,讓 GC log 更加詳細,方便定位問題。

 

 

 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章