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 收集器的特点
G1 是一款面向服务端应用的垃圾收集器,Sun(Oracle)赋予它的使命是(在比较长期的)未来可以替换掉 JDK 5 中发布的 CMS(Concurrent Mark Sweep)收集器,与其他 GC 收集器相比,G1 具备如下特点:
- 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿的时间,部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。
- 分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。
- 空间整合:与 CMS 的“标记 - 清理”算法不同,G1 从整体看来是基于“标记 - 整理”算法实现的收集器,从局部(两个 Region 之间)上看是基于“复制”算法实现,无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
- 可预测的停顿:这是 G1 相对于 CMS 的另外一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒,这几乎已经是实时 Java(RTSJ)的垃圾收集器特征了。
实现思路
在 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 收集器的运作大致可划分为以下几个步骤:
- 初始标记(Initial Marking)
- 并发标记(Concurrent Marking)
- 最终标记(Final Marking)
- 筛选回收(Live Data Counting and Evacuation)
对 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 收集器几乎可以说还没有经过实际应用的考验,网上关于 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% 分别代表的含义为:
- V%:表示测试过程中,软实时目标失败的概率,软实时目标失败即某个时间片段中实际 GC 时间超过了允许的最大 GC 时间。
- avgV%:表示在所有实际 GC 时间超标的时间片段里,实际 GC 时间超过最大 GC 时间的平均百分比(实际 GC 时间减去允许最大 GC 时间,再除以总时间片段)。
- wV%:表示在测试结果最差的时间片段里,实际 GC 时间占用执行时间的百分比。
测试结果如下表所示:
表 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 给出一个自己的建议:直到现在为止还没有一款“最好的”收集器出现,更加没有“万能的”收集器,所以我们选择的只是对具体应用最合适的收集器。对于不同的硬件环境、不同的软件应用、不同的参数配置、不同的调优目标都会对调优时的收集器选择产生影响,选择适合的收集器,除了理论和别人的数据经验作为指导外,最终还是应当建立在自己应用的实际测试之上,别人的测试,大可抱着“至于你信不信,反正我自己没测之前是不信的”的态度。
参考资料
- Sun 实验室的论文《Garbage-First Garbage Collection》,作者为:David Detlefs、Christine Flood、Steve Heller、Tony Printezis
- 《The Garbage-First Garbage Collector》
- 《G1: Java’s Garbage First Garbage Collector》
感谢张凯峰对本文的审校。
给InfoQ 中文站投稿或者参与内容翻译工作,请邮件至 editors@cn.infoq.com 。也欢迎大家加入到 InfoQ 中文站用户讨论组中与我们的编辑和其他读者朋友交流。
评论