Garbage-First(后文简称G1)收集器是当今收集器技术发展的最前沿成果,在Sun公司给出的JDK RoadMap里面,它被视作JDK 7的HotSpot VM 的一项重要进化特征。从JDK 6u14中开始就有Early Access版本的G1收集器供开发人员实验、试用,虽然在JDK 7正式版发布时,G1收集器仍然没有摆脱“Experimental”的标签,但是相信不久后将会有一个成熟的商用版本跟随某个JDK 7的更新包发布出来。
因版面篇幅限制,笔者行文过程中假设读者对HotSpot其他收集器(例如CMS)及相关JVM内存模型已有基本的了解,涉及到基础概念时,没有再延伸介绍,读者可参考相关资料。
G1是一款面向服务端应用的垃圾收集器,Sun(Oracle)赋予它的使命是(在比较长期的)未来可以替换掉JDK 5中发布的CMS(Concurrent Mark Sweep)收集器,与其他GC收集器相比,G1具备如下特点:
在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局与就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。
G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内获可以获取尽可能高的收集效率。
G1把内存“化整为零”的思路,理解起来似乎很容易理解,但其中的实现细节却远远没有现象中简单,否则也不会从04年Sun实验室发表第一篇G1的论文拖至今将近8年时间都还没有开发出G1的商用版。笔者举个一个细节为例:把Java堆分为多个Region后,垃圾收集是否就真的能以Region为单位进行了?听起来顺理成章,再仔细想想就很容易发现问题所在:Region不可能是孤立的。一个对象分配在某个Region中,它并非只能被本Region中的其他对象引用,而是可以与整个Java堆任意的对象发生引用关系。那在做可达性判定确定对象是否存活的时候,岂不是还得扫描整个Java堆才能保障准确性?这个问题其实并非在G1中才有,只是在G1中更加突出了而已。在以前的分代收集中,新生代的规模一般都比老年代要小许多,新生代的收集也比老年代要频繁许多,那回收新生代中的对象也面临过相同的问题,如果回收新生代时也不得不同时扫描老年代的话,Minor GC的效率可能下降不少。。
在G1收集器中Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,虚拟机都是使用Remembered Set来避免全堆扫描的。G1中每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查引是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。当进行内存回收时,GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
对CMS收集器运作过程熟悉的读者,一定已经发现G1的前几个步骤的运作过程和CMS有很多相似之处。初始标记阶段仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。并发标记阶段是从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。而最终标记阶段则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。最后筛选回收阶段首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,从Sun透露出来的信息来看,这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。通过图1可以比较清楚地看到G1收集器的运作步骤中并发和需要停顿的阶段。
图1 G1收集器运行示意图
由于目前还没有成熟的版本,G1收集器几乎可以说还没有经过实际应用的考验,网上关于G1收集器的性能测试非常贫乏,笔者没有Google到有关的生产环境下的性能测试报告。强调“生产环境下的测试报告”是因为对于垃圾收集器来说,仅仅通过简单的Java代码写个Microbenchmark程序来创建、移除Java对象,再用-XX:+PrintGCDetails等参数来查看GC日志是很难做到准衡量其性能的(为何Microbenchmark的测试结果不准确可参见笔者这篇博客:http://icyfenix.iteye.com/blog/1110279)。因此关于G1收集器的性能部分,笔者引用了Sun实验室的论文《Garbage-First Garbage Collection》其中一段测试数据,以及一段在StackOverfall.com上同行们对G1在真实生产环境下的性能分享讨论。
Sun给出的Benchmark的执行硬件为Sun V880服务器(8×750MHz UltraSPARC III CPU、32G内存、Solaris 10操作系统)。执行软件有两个,分别为SPECjbb(模拟商业数据库应用,堆中存活对象约为165MB,结果反映吐量和最长事务处理时间)和telco(模拟电话应答服务应用,堆中存活对象约为100MB,结果反映系统能支持的最大吞吐量)。为了便于对比,还收集了一组使用ParNew+CMS收集器的测试数据。所有测试都配置为与CPU数量相同的8条GC线程。
在反应停顿时间的软实时目标(Soft Real-Time Goal)测试中,横向是两个测试软件的时间片段配置,单位是毫秒,以(X/Y)的形式表示,代表在Y毫秒内最大允许GC时间为X毫秒(对于CMS收集器,无法直接指定这个目标,通过调整分代大小的方式大致模拟)。纵向是两个软件在对应配置和不同的Java堆容量下的测试结果,V%、avgV%和wV%分别代表的含义为:
测试结果如下表所示:
表1:软实时目标测试结果
Benchmark / confguration |
Soft real-time goal compliance statistics by Heap Size |
|||||||||
V% |
avgV% |
wV% |
V% |
avgV% |
wV% |
V% |
avgV% |
wV% |
||
SPECjbb |
512M |
640M |
768M |
|||||||
G1 |
(100/200) |
4.29% |
36.40% |
100.00% |
1.73% |
12.83% |
63.31% |
1.68% |
10.94% |
69.67% |
G1 |
(150/300) |
1.20% |
5.95% |
15.29% |
1.51% |
4.01% |
20.80% |
1.78% |
3.38% |
8.96% |
G1 |
(150/450) |
1.63% |
4.40% |
14.32% |
3.14% |
2.34% |
6.53% |
1.23% |
1.53% |
3.28% |
G1 |
(150/600) |
2.63% |
2.90% |
5.38% |
3.66% |
2.45% |
8.39% |
2.09% |
2.54% |
8.65% |
G1 |
(200/800) |
0.00% |
0.00% |
0.00% |
0.34% |
0.72% |
0.72% |
0.00% |
0.00% |
0.00% |
CMS |
(150/450) |
23.93% |
82.14% |
100.00% |
13.44% |
67.72% |
100.00% |
5.72% |
28.19% |
100.00% |
Telco |
384M |
512M |
640M |
|||||||
G1 |
(50/100) |
0.34% |
8.92% |
35.48% |
0.16% |
9.09% |
48.08% |
0.11% |
12.10% |
38.57% |
G1 |
(75/150) |
0.08% |
11.90% |
19.99% |
0.08% |
5.60% |
7.47% |
0.19% |
3.81% |
9.15% |
G1 |
(75/225) |
0.44% |
2.90% |
10.45% |
0.15% |
3.31% |
3.74% |
0.50% |
1.04% |
2.07% |
G1 |
(75/300) |
0.65% |
2.55% |
8.76% |
0.42% |
0.57% |
1.07% |
0.63% |
1.07% |
2.91% |
G1 |
(100/400) |
0.57% |
1.79% |
6.04% |
0.29% |
0.37% |
0.54% |
0.44% |
1.52% |
2.73% |
CMS |
(75/225) |
0.78% |
35.05% |
100.00% |
0.54% |
32.83% |
100.00% |
0.60% |
26.39% |
100.00% |
从上面结果可见,对于telco来说,软实时目标失败的概率控制在0.5%~0.7%之间,SPECjbb就要差一些,但也控制在2%~5%之间,概率随着(X/Y)的比值减小而增加。另一方面,失败时超出允许GC时间的比值随着总时间片段增加而变小(分母变大了嘛),在(100/200)、512MB的配置下,G1收集器出现了某些时间片段下100%时间在进行GC的最坏情况。而相比之下,CMS收集器的测试结果对比之下就要差很多,3种Java堆容量下都出现了100%时间进行GC的情况,
在吞吐量测试中,测试数据取3次SPECjbb和15次telco的平均结果。在SPECjbb的应用下,各种配置下的G1收集器表现出了一致的行为,吞吐量看起来只与允许最大GC时间成正比关系,而在telco的应用中,不同配置对吞吐量的影响则显得很微弱。与CMS收集器的吞吐量对比可以看到,在SPECjbb测试中,在堆容量超过768M时,CMS收集器有5%~10%的优势,而在telco测试中CMS的优势则要小一些,只有3%~4%左右。
图2:吞吐量测试结果
在更大规模的生产环境下,笔者引用一段在StackOverfall.com上看到的经验分享:“我在一个真实的、较大规模的应用程序中使用过G1:大约分配有60~70GB内存,存活对象大约在20~50GB之间。服务器运行Linux操作系统,JDK版本为6u22。G1与PS/PS Old相比,最大的好处是停顿时间更加可控、可预测,如果我在PS中设置一个很低的最大允许GC时间,譬如期望50毫秒内完成GC(-XX:MaxGCPauseMillis=50),但在65GB的Java堆下有可能得到的直接结果是一次长达30秒至2分钟的漫长的Stop-The-World过程;而G1与CMS相比,它们都立足于低停顿时间,CMS仍然是我现在的选择,但是随着Oracle对G1 的持续改进,我相信G1会是最终的胜利者。如果你现在采用的收集器没有出现问题,那就没有任何理由现在去选择G1,如果你的应用追求低停顿,那G1现在已经可以作为一个可尝试的选择,如果你的应用追求吞吐量,那G1并不会为你带来什么特别的好处。”
在这节笔者引了两段别人的测试结果、经验后,对于G1给出一个自己的建议:直到现在为止还没有一款“最好的”收集器出现,更加没有“万能的”收集器,所以我们选择的只是对具体应用最合适的收集器。对于不同的硬件环境、不同的软件应用、不同的参数配置、不同的调优目标都会对调优时的收集器选择产生影响,选择适合的收集器,除了理论和别人的数据经验作为指导外,最终还是应当建立在自己应用的实际测试之上,别人的测试,大可抱着“至于你信不信,反正我自己没测之前是不信的”的态度。