垃圾回收全集之十:GC 調優的實戰篇—高分配速率(High Allocation Rate)

語言: CN / TW / HK

高分配速率(High Allocation Rate)

分配速率( Allocation rate )表示單位時間內分配的記憶體量。通常使用  MB/sec 作為單位, 也可以使用  PB/year 等。

分配速率過高就會嚴重影響程式的效能。在JVM中會導致巨大的GC開銷。

如何測量分配速率?

指定JVM引數: -XX:+PrintGCDetails -XX:+PrintGCTimeStamps , 通過GC日誌來計算分配速率. GC日誌如下所示:

0.291: [GC (Allocation Failure)
[PSYoungGen: 33280K->5088K(38400K)]
33280K->24360K(125952K), 0.0365286 secs]
[Times: user=0.11 sys=0.02, real=0.04 secs]
0.446: [GC (Allocation Failure)
[PSYoungGen: 38368K->5120K(71680K)]
57640K->46240K(159232K), 0.0456796 secs]
[Times: user=0.15 sys=0.02, real=0.04 secs]
0.829: [GC (Allocation Failure)
[PSYoungGen: 71680K->5120K(71680K)]
112800K->81912K(159232K), 0.0861795 secs]
[Times: user=0.23 sys=0.03, real=0.09 secs]

計算 上一次垃圾收集之後 ,與 下一次GC開始之前 的年輕代使用量, 兩者的差值除以時間,就是分配速率。 通過上面的日誌, 可以計算出以下資訊:

  • JVM啟動之後  291ms , 共建立了  33,280 KB  的物件。 第一次 Minor GC(小型GC) 完成後, 年輕代中還有  5,088 KB  的物件存活。
  • 在啟動之後  446 ms , 年輕代的使用量增加到  38,368 KB , 觸發第二次GC, 完成後年輕代的使用量減少到  5,120 KB
  • 在啟動之後  829 ms , 年輕代的使用量為  71,680 KB , GC後變為  5,120 KB

可以通過年輕代的使用量來計算分配速率, 如下表所示:

Event Time Young before Young after Allocated during Allocation rate
1st GC 291ms 33,280KB 5,088KB 33,280KB 114MB/sec
2nd GC 446ms 38,368KB 5,120KB 33,280KB 215MB/sec
3rd GC 829ms 71,680KB 5,120KB 66,560KB 174MB/sec
Total 829ms N/A N/A 133,120KB 161MB/sec

通過這些資訊可以知道, 在測量期間, 該程式的記憶體分配速率為 161 MB/sec

分配速率的意義

分配速率的變化,會增加或降低GC暫停的頻率, 從而影響吞吐量。 但只有年輕代的 minor GC 受分配速率的影響, 老年代GC的頻率和持續時間不受  分配速率 ( allocation rate )的直接影響, 而是受到  提升速率 ( promotion rate )的影響, 請參見下文。

現在我們只關心 Minor GC 暫停, 檢視年輕代的3個記憶體池。因為物件在  Eden區 分配, 所以我們一起來看 Eden 區的大小和分配速率的關係. 看看增加 Eden 區的容量, 能不能減少 Minor GC 暫停次數, 從而使程式能夠維持更高的分配速率。

經過我們的實驗, 通過引數 -XX:NewSize 、  -XX:MaxNewSize 以及  -XX:SurvivorRatio 設定不同的 Eden 空間, 運行同一程式時, 可以發現:

  • Eden 空間為  100 MB  時, 分配速率低於  100 MB/秒
  • 將 Eden 區增大為  1 GB , 分配速率也隨之增長,大約等於  200 MB/秒

為什麼會這樣? —— 因為減少GC暫停,就等價於減少了任務執行緒的停頓,就可以做更多工作, 也就建立了更多物件, 所以對同一應用來說, 分配速率越高越好。

在得出 “Eden區越大越好” 這個結論前, 我們注意到, 分配速率可能會,也可能不會影響程式的實際吞吐量。 吞吐量和分配速率有一定關係, 因為分配速率會影響 minor GC 暫停, 但對於總體吞吐量的影響, 還要考慮 Major GC(大型GC)暫停, 而且吞吐量的單位不是 MB/秒 , 而是系統所處理的業務量。

示例

參考 Demo程式 。假設系統連線了一個外部的數字感測器。應用通過專有執行緒, 不斷地獲取感測器的值,(此處使用隨機數模擬), 其他執行緒會呼叫  processSensorValue() 方法, 傳入感測器的值來執行某些操作:

public class BoxingFailure {
private static volatile Double sensorValue;
private static void readSensor() {
while(true) sensorValue = Math.random();
}
private static void processSensorValue(Double value) {
if(value != null) {
//...
}
}
}

如同類名所示, 這個Demo是模擬 boxing 的。為了 null 值判斷, 使用的是包裝型別  Double 。 程式基於感測器的最新值進行計算, 但從感測器取值是一個重量級操作, 所以採用了非同步方式: 一個執行緒不斷獲取新值, 計算執行緒則直接使用暫存的最新值, 從而避免同步等待。

Demo 程式 在執行的過程中, 由於分配速率太大而受到GC的影響。下一節將確認問題, 並給出解決辦法。

高分配速率對JVM的影響

首先,我們應該檢查程式的吞吐量是否降低。如果建立了過多的臨時物件, minor GC的次數就會增加。如果併發較大, 則GC可能會嚴重影響吞吐量。

遇到這種情況時, GC日誌將會像下面這樣,當然這是上面的 示例程式 產生的GC日誌。 JVM啟動引數為  -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xmx32m :

2.808: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003076 secs]
2.819: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003079 secs]
2.830: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0002968 secs]
2.842: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003374 secs]
2.853: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0004672 secs]
2.864: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003371 secs]
2.875: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003214 secs]
2.886: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003374 secs]
2.896: [GC (Allocation Failure)
[PSYoungGen: 9760K->32K(10240K)], 0.0003588 secs]

很顯然 minor GC 的頻率太高了。這說明建立了大量的物件。另外, 年輕代在 GC 之後的使用量又很低, 也沒有 full GC 發生。 種種跡象表明, GC對吞吐量造成了嚴重的影響。

解決方案

在某些情況下,只要增加年輕代的大小, 即可降低分配速率過高所造成的影響。增加年輕代空間並不會降低分配速率, 但是會減少GC的頻率。如果每次GC後只有少量物件存活, minor GC 的暫停時間就不會明顯增加。

執行 示例程式 時, 增加堆記憶體大小,(同時也就增大了年輕代的大小), 使用的JVM引數為  -Xmx64m :

2.808: [GC (Allocation Failure)
[PSYoungGen: 20512K->32K(20992K)], 0.0003748 secs]
2.831: [GC (Allocation Failure)
[PSYoungGen: 20512K->32K(20992K)], 0.0004538 secs]
2.855: [GC (Allocation Failure)
[PSYoungGen: 20512K->32K(20992K)], 0.0003355 secs]
2.879: [GC (Allocation Failure)
[PSYoungGen: 20512K->32K(20992K)], 0.0005592 secs]

但有時候增加堆記憶體的大小,並不能解決問題。通過前面學到的知識, 我們可以通過分配分析器找出大部分垃圾產生的位置。實際上在此示例中, 99%的物件屬於  Double 包裝類, 在 readSensor 方法中建立。最簡單的優化, 將建立的  Double 物件替換為原生型別  double , 而針對 null 值的檢測, 可以使用  Double.NaN 來進行。由於原生型別不算是物件, 也就不會產生垃圾, 導致GC事件。優化之後, 不在堆中分配新物件, 而是直接覆蓋一個屬性域即可。

對示例程式進行 簡單的改造檢視diff ) 後, GC暫停基本上完全消除。有時候 JVM 也很智慧, 會使用 逃逸分析技術(escape ****ysis technique) 來避免過度分配。簡單來說,JIT編譯器可以通過分析得知, 方法建立的某些物件永遠都不會“逃出”此方法的作用域。這時候就不需要在堆上分配這些物件, 也就不會產生垃圾, 所以JIT編譯器的一種優化手段就是: 消除記憶體分配。請參考  基準測試