在看記憶體管理術語表的時候偶然發現了”Pig in the Python(註:有點像中文裡的貪心不足蛇吞象)”的定義,於是便有了這篇文章。表面上看,這個術語說的是GC不停地將大對象從一個分代提升到另一個分代的情景。這麼做就好比巨蟒整個吞食掉它的獵物,以至於它在消化的時候都沒辦法移動了。
在接下來的這24個小時裡我的頭腦中充斥著這個令人窒息的巨蟒的畫面,揮之不去。正如精神病醫生所說的,消除恐懼最好的方法就是說出來。於是便有了這篇文章。不過接下的故事我們要講的不是蟒蛇,而是GC的調優。我對天發誓。
大家都知道GC暫停很容易造成效能瓶頸。現代JVM在發布的時候都內建了進階的記憶體回收行程,不過從我的使用經驗來看,要找出某個應用最優的配置真是難上加難。手動調優或許仍有一線希望,但是你得瞭解GC演算法的確切機制才行。關於這點,本文倒是會對你有所協助,下面我會通過一個例子來講解JVM配置的一個小的改動是如何影響到你的應用程式的輸送量的。
樣本
我們用來示範GC對輸送量產生影響的應用只是一個簡單的程式。它包含兩個線程:
PigEater – 它會模仿巨蟒不停吞食大肥豬的過程。代碼是通過往java.util.List中添加 32MB位元組來實現這點的,每次吞食完後會睡眠100ms。
PigDigester – 它類比非同步消化的過程。實現消化的代碼只是將豬的列表置為空白。由於這是個很累的過程,因此每次清除完引用後這個線程都會睡眠2000ms。
兩個線程都會在一個while迴圈中運行,不停地吃了消化直到蛇吃飽為止。這大概得吃掉5000頭豬。
複製代碼 代碼如下:
package eu.plumbr.demo;
public class PigInThePython {
static volatile List pigs = new ArrayList();
static volatile int pigsEaten = 0;
static final int ENOUGH_PIGS = 5000;
public static void main(String[] args) throws InterruptedException {
new PigEater().start();
new PigDigester().start();
}
static class PigEater extends Thread {
@Override
public void run() {
while (true) {
pigs.add(new byte[32 * 1024 * 1024]); //32MB per pig
if (pigsEaten > ENOUGH_PIGS) return;
takeANap(100);
}
}
}
static class PigDigester extends Thread {
@Override
public void run() {
long start = System.currentTimeMillis();
while (true) {
takeANap(2000);
pigsEaten+=pigs.size();
pigs = new ArrayList();
if (pigsEaten > ENOUGH_PIGS) {
System.out.format("Digested %d pigs in %d ms.%n",pigsEaten, System.currentTimeMillis()-start);
return;
}
}
}
}
static void takeANap(int ms) {
try {
Thread.sleep(ms);
} catch (Exception e) {
e.printStackTrace();
}
}
}
現在我們將這個系統的輸送量定義為“每秒可以消化的豬的頭數”。考慮到每100ms就會有豬被塞到這條蟒蛇裡,我們可以看到這個系統理論上的最大輸送量可以達到10頭/秒。
GC配置樣本
我們來看下使用兩個不同的配置系統的表現分別是什麼樣的。不管是哪個配置,應用都運行在一台擁有雙核,8GB記憶體的Mac(OS X10.9.3)上。
第一個配置:
1.4G的堆(-Xms4g -Xmx4g)
2.使用CMS來清理老年代(-XX:+UseConcMarkSweepGC)使用並行回收器清理新生代(-XX:+UseParNewGC)
3.將堆的12.5%(-Xmn512m)分配給新生代,並將Eden區和Survivor區的大小限制為一樣的。
第二個配置則略有不同:
1.2G的堆(-Xms2g -Xms2g)
2.新生代和老年代都使用Parellel GC(-XX:+UseParallelGC)
3.將堆的75%分配給新生代(-Xmn 1536m)
4.現在是該下注的時候了,哪個配置的表現會更好一些(就是每秒能吃多少豬,還記得吧)?那些把籌碼放到第一個配置上的傢伙,你們一定會失望的。結果正好相反:
1.第一個配置(大堆,大的老年代,CMS GC)每秒能吞食8.2頭豬
2.第二個配置(小堆,大的新生代,Parellel GC)每秒可以吞食9.2頭豬
現在我們來客觀地看待一下這個結果。分配的資源少了2倍但輸送量提升了12%。這和常識正好相反,因此有必要進一步分析下到底發生了什麼。
分析GC的結果
原因其實並不複雜,你只要仔細看一下運行測試的時候GC在幹什麼就能發現答案了。這個你可以自己選擇要使用的工具。在jstat的協助下我發現了背後的秘密,命令大概是這樣的:
複製代碼 代碼如下:
jstat -gc -t -h20 PID 1s
通過分析資料,我注意到配置1經曆了1129次GC周期(YGCT_FGCT),總共花了63.723秒:
複製代碼 代碼如下:
Timestamp S0C S1C S0U S1U EC EU OC OU PC PU YGC YGCT FGC FGCT GCT
594.0 174720.0 174720.0 163844.1 0.0 174848.0 131074.1 3670016.0 2621693.5 21248.0 2580.9 1006 63.182 116 0.236 63.419
595.0 174720.0 174720.0 163842.1 0.0 174848.0 65538.0 3670016.0 3047677.9 21248.0 2580.9 1008 63.310 117 0.236 63.546
596.1 174720.0 174720.0 98308.0 163842.1 174848.0 163844.2 3670016.0 491772.9 21248.0 2580.9 1010 63.354 118 0.240 63.595
597.0 174720.0 174720.0 0.0 163840.1 174848.0 131074.1 3670016.0 688380.1 21248.0 2580.9 1011 63.482 118 0.240 63.723
第二個配置一共暫停了168次(YGCT+FGCT),只花了11.409秒。
複製代碼 代碼如下:
Timestamp S0C S1C S0U S1U EC EU OC OU PC PU YGC YGCT FGC FGCT GCT
539.3 164352.0 164352.0 0.0 0.0 1211904.0 98306.0 524288.0 164352.2 21504.0 2579.2 27 2.969 141 8.441 11.409
540.3 164352.0 164352.0 0.0 0.0 1211904.0 425986.2 524288.0 164352.2 21504.0 2579.2 27 2.969 141 8.441 11.409
541.4 164352.0 164352.0 0.0 0.0 1211904.0 720900.4 524288.0 164352.2 21504.0 2579.2 27 2.969 141 8.441 11.409
542.3 164352.0 164352.0 0.0 0.0 1211904.0 1015812.6 524288.0 164352.2 21504.0 2579.2 27 2.969 141 8.441 11.409
考慮到兩種情況下的工作量是等同的,因此——在這個吃豬的實驗中當GC沒有發現長期存活的對象時,它能更快地清理掉垃圾對象。而採用第一個配置的話,GC啟動並執行頻率大概會是6到7倍之多,而總的暫停時間則是5至6倍。
說這個故事有兩個目的。第一個也是最主要的一個,我希望把這條抽風的蟒蛇趕緊從我的腦海裡趕出去。另一個更明顯的收穫就是——GC調優是個很需要技巧的經驗活,它需要你對底層的這些概念了如指掌。儘管本文中用到的這個只是很平常的一個應用,但選擇的不同結果也會對你的輸送量和容量規劃產生很大的影響。在現實生活中的應用裡面,這裡的區別則會更為巨大。因此,就看你如何抉擇了,你可以去掌握這些概念,或者,只關注你日常的工作就好了,讓Plumbr來找出你所需要的最合適的GC配置吧。