GC对吞吐量的影响

英文原文链接译文链接,原文作者:Nikita Salnikov-Tarnovski,译者:有孚

在看内存管理术语表的时候偶然发现了”Pig in the Python(注:有点像中文里的贪心不足蛇吞象)”的定义,于是便有了这篇文章。表面上看,这个术语说的是GC不停地将大对象从一个分代提升到另一个分代的情景。这么做就好比巨蟒整个吞食掉它的猎物,以至于它在消化的时候都没办法移动了。

在接下来的这24个小时里我的头脑中充斥着这个令人窒息的巨蟒的画面,挥之不去。正如精神病医生所说的,消除恐惧最好的方法就是说出来。于是便有了这篇文章。不过接下的故事我们要讲的不是蟒蛇,而是GC的调优。我对天发誓。

大家都知道GC暂停很容易造成性能瓶颈。现代JVM在发布的时候都自带了高级的垃圾回收器,不过从我的使用经验来看,要找出某个应用最优的配置真是难上加难。手动调优或许仍有一线希望,但是你得了解GC算法的确切机制才行。关于这点,本文倒是会对你有所帮助,下面我会通过一个例子来讲解JVM配置的一个小的改动是如何影响到你的应用程序的吞吐量的。

示例

我们用来演示GC对吞吐量产生影响的应用只是一个简单的程序。它包含两个线程:

  • PigEater – 它会模仿巨蟒不停吞食大肥猪的过程。代码是通过往java.util.List中添加 32MB字节来实现这点的,每次吞食完后会睡眠100ms。
  • PigDigester – 它模拟异步消化的过程。实现消化的代码只是将猪的列表置为空。由于这是个很累的过程,因此每次清除完引用后这个线程都会睡眠2000ms。

两个线程都会在一个while循环中运行,不停地吃了消化直到蛇吃饱为止。这大概得吃掉5000头猪。

[code lang=”java”]

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();
}
}
}

[/code]

现在我们将这个系统的吞吐量定义为“每秒可以消化的猪的头数”。考虑到每100ms就会有猪被塞到这条蟒蛇里,我们可以看到这个系统理论上的最大吞吐量可以达到10头/秒。

GC配置示例

我们来看下使用两个不同的配置系统的表现分别是什么样的。不管是哪个配置,应用都运行在一台拥有双核,8GB内存的Mac(OS X10.9.3)上。

第一个配置:

  • 4G的堆(-Xms4g -Xmx4g)
  • 使用CMS来清理老年代(-XX:+UseConcMarkSweepGC)使用并行回收器清理新生代(-XX:+UseParNewGC)
  • 将堆的12.5%(-Xmn512m)分配给新生代,并将Eden区和Survivor区的大小限制为一样的。

第二个配置则略有不同:

  • 2G的堆(-Xms2g -Xmx2g)
  • 新生代和老年代都使用Parellel GC(-XX:+UseParallelGC)
  • 将堆的75%分配给新生代(-Xmn 1536m)

现在是该下注的时候了,哪个配置的表现会更好一些(就是每秒能吃多少猪,还记得吧)?那些把筹码放到第一个配置上的家伙,你们一定会失望的。结果正好相反:

  • 第一个配置(大堆,大的老年代,CMS GC)每秒能吞食8.2头猪
  • 第二个配置(小堆,大的新生代,Parellel GC)每秒可以吞食9.2头猪

现在我们来客观地看待一下这个结果。分配的资源少了2倍但吞吐量提升了12%。这和常识正好相反,因此有必要进一步分析下到底发生了什么。

分析GC的结果

原因其实并不复杂,你只要仔细看一下运行测试的时候GC在干什么就能发现答案了。这个你可以自己选择要使用的工具。在jstat的帮助下我发现了背后的秘密,命令大概是这样的:

 

jstat -gc -t -h20 PID 1s

 

通过分析数据,我注意到配置1经历了1129次GC周期(YGCT_FGCT),总共花了63.723秒:

[code lang=”java”]

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

[/code]

第二个配置一共暂停了168次(YGCT+FGCT),只花了11.409秒。

[code lang=”java”]

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
[/code]

考虑到两种情况下的工作量是等同的,因此——在这个吃猪的实验中当GC没有发现长期存活的对象时,它能更快地清理掉垃圾对象。而采用第一个配置的话,GC运行的频率大概会是6到7倍之多,而总的暂停时间则是5至6倍。

说这个故事有两个目的。第一个也是最主要的一个,我希望把这条抽风的蟒蛇赶紧从我的脑海里赶出去。另一个更明显的收获就是——GC调优是个很需要技巧的经验活,它需要你对底层的这些概念了如指掌。尽管本文中用到的这个只是很平常的一个应用,但选择的不同结果也会对你的吞吐量和容量规划产生很大的影响。在现实生活中的应用里面,这里的区别则会更为巨大。因此,就看你如何抉择了,你可以去掌握这些概念,或者,只关注你日常的工作就好了,让Plumbr来找出你所需要的最合适的GC配置吧。

本文最早发表于我的个人博客Java译站

原创文章,转载请注明: 转载自并发编程网 – ifeve.com本文链接地址: GC对吞吐量的影响

  • Trackback 关闭
  • 评论 (9)
    • Sartner
    • 2014/09/23 8:43上午

    好文

    • Bank Ren
    • 2014/09/23 10:38下午

    有明显错误:
    2G的堆(-Xms2g -Xms2g)
    应改为
    2G的堆(-Xms2g -Xmx2g)

    • Snway
    • 2014/09/24 9:35上午

    static volatile int pigsEaten = 0; pigsEaten+=pigs.size(); 这个貌似不是线程安全的 volatile变量 只保证可见性 不保持原子性 使用volatile变量时 不应该依赖本身的值

    • 菜刀
    • 2014/09/24 9:56上午

    第二个比第一个更快主要原因是第二个新生代给的大小比第一个大很多 1536m比512m,需要gc的次数少很多,在年轻代被gc掉的概率要高很多,当然gc的效率也高。

    • 是的,这样GC也不用将这些对象提升了老年代再回收了,效率会更高。

    • 码农
    • 2014/09/24 11:57上午

    我觉得上面的评论的朋友更有水准啊

    • EL
    • 2014/09/25 9:03下午

    嗯,刚好将新生代设得小于700MB,这样方案一中,新生代就不够用,频发激活gc,一开始进入新生代的对象age就不断增长,满足阈值,进入老年代。
    其实就是多了老年代的垃圾收集而已吧。
    不过倒是一遍既幽默又具有实用性的gc案例设计。

    不过他这样设置gc的奥妙还没看出来。回头看看书再分析一下。Mark.

    • @康
    • 2014/10/14 10:07上午

    这个是不是应该把内存,Edge,Survior区的大小调整的相同,才可以看出收集器搭配的优势呢。

return top