6.3 理解G1垃圾收集器
G1 垃圾收集器是一种工作在堆内不同分区上的并发收集器。分区(region)既可以归属于老年代,也可以归属于新生代(默认情况下,一个堆被划分成 2048 个分区),同一个代的分区不需要保持连续。为老年代设计分区的初衷是我们发现并发后台线程在回收老年代中没有引用的对象时,有的分区垃圾对象的数量很多,另一些分区的垃圾对象相对较少。虽然分区的垃圾收集工作实际仍然会暂停应用程序线程,不过由于 G1 收集器专注于垃圾最多的分区,最终的效果是花费较少的时间就能回收这些分区的垃圾。这种只专注于垃圾最多分区的方式就是 G1 垃圾收集器名称的由来,即首先收集垃圾最多的分区。
不过这一算法并不适用于新生代的分区:新生代进行垃圾回收时,整个新生代空间要么被回收,要么被晋升(对象被移动到 Survivor 空间,或者移动到老年代)。新生代也采用分区机制的部分原因,是因为采用预定义的分区能够便于代的大小调整。
G1 收集器的收集活动主要包括 4 种操作:
新生代垃圾收集;
后台收集,并发周期;
混合式垃圾收集;
以及必要时的 Full GC。
我们会依次讨论每一种操作,首先讨论的是 G1 收集器的新生代垃圾收集,如图 6-6 所示。

图 6-6:用 G1 垃圾收集器的新生代收集前后对比
图中的每一个小方块都代表一个 G1 的分区。分区中黑色的区域代表数据,每个分区中的字母表示该区域属于哪个代([E] 代表 Eden 空间,[O] 代表老年代,[S] 代表 Survivor 空间)。空的分区不属于任何一个代;需要的时候 G1 收集器会强制指定这些空的分区用于任何需要的代。
Eden 空间耗尽会触发 G1 垃圾收集器进行新生代垃圾收集(这个例子中,标识为 Eden 的 4 个分区填满之后就会触发新生代收集)。新生代收集之后不会有新的分区马上分配到 Eden 空间,因为这时 Eden 空间为空。不过至少会有一个分区分配到 Survivor 空间(这个例子中,Survivor 空间被部分填满),一部分数据会移动到老年代。
G1 垃圾收集器中,新生代垃圾收集的日志与其他的收集器略有不同。与往常一样,我们可以使用 PrintGCDetails 输出例子的垃圾回收日志,不过 G1 收集的日志要详细得多。这里仅仅列出了例子中重要的几行。
下面是新生代垃圾收集的标准流程:
23.430: [GC pause (young), 0.23094400 secs]...[Eden: 1286M(1286M)->0B(1212M)Survivors: 78M->152M Heap: 1454M(4096M)->242M(4096M)][Times: user=0.85 sys=0.05, real=0.23 secs]
这里新生代垃圾收集的 Real 时间消耗是 0.23 秒,这期间,垃圾收集线程消耗了 0.85 秒的 CPU 时间,1286 MB 的对象移出了 Eden 空间(Eden 空间的大小调整到了 1212 MB);这其中的 74 MB 移动到了 Survivor 空间(Survivor 空间的大小从 78 MB 增加到了 152 MB),其余的空间都被垃圾收集器回收掉了。通过观察堆的总占用降低了 1212 MB 我们知道,这些空间被释放了。通常情况下,一部分对象已经从 Survivor 空间移动到老年代空间,如果 Survivor 空间被填满,无法容纳新生代的晋升对象,部分 Eden 空间的对象会被直接晋升到老年代空间——这种情况下,老年代空间的占用也会增加。
图 6-7 是并发 G1 垃圾收集周期(concurrent G1 cycle)开始和结束时的情况。

图 6-7:G1 收集器进行的并发垃圾收集
这幅图中有三方面值得我们关注。首先,新生代的空间占用情况发生了变化:在并发周期中,至少有一次(很可能是多次)新生代垃圾收集。因此,在将 Eden 空间中的分区标记为完全释放之前,新的 Eden 分区已经开始分配了。
其次,我们注意到一些分区现在被标记为 X。这些分区属于老年代(注意,它们依然还保持着数据),它们就是标记周期(marking cycle)找出的包含最多垃圾的分区。
最后,我们还要留意老年代(包括标记为 O 或者 X 的分区)的空间占用,在周期结束时实际可能更多。这是因为在标记周期中,新生代的垃圾收集会晋升对象到老年代。除此之外,标记周期中实际不会释放老年代中的任何对象:它仅仅锁定了那些垃圾最多的分区。这些分区中的垃圾数据会在之后的周期中被回收释放。
G1 收集器的并发周期包括多个阶段,其中的一些会暂停所有应用线程,另一些则不会。并发周期的第一个阶段是初始—标记(initial-mark)阶段。这个阶段会暂停所有应用线程——部分源于初始—标记阶段也会进行新生代垃圾收集。
50.541: [GC pause (young) (initial-mark), 0.27767100 secs][Eden: 1220M(1220M)->0B(1220M)Survivors: 144M->144M Heap: 3242M(4096M)->2093M(4096M)][Times: user=1.02 sys=0.04, real=0.28 secs]
同常规的新生代垃圾收集一样,初始—标记阶段中,应用线程被暂停(大约时长 0.28 秒),之后新生代被清空(71 MB 的数据从新生代移到了老年代)。初始—标记阶段的输出日志表明后台并发周期启动。由于初始—标记阶段也需要暂停所有的应用线程,G1 收集器重用了新生代 GC 周期来完成这部分的工作。在新生代垃圾收集中添加初始标记阶段的影响并不大:与之前的垃圾收集相比较,CPU 周期的开销增加了大约 20%,即便如此,停顿时间只有些微的增长(幸运的是,这台机器上有空闲的 CPU 周期可以运行并发 G1 收集线程,否则停顿时间会更长一些)。
接下来,G1 收集器会扫描根分区(root region):
50.819: [GC concurrent-root-region-scan-start]51.408: [GC concurrent-root-region-scan-end, 0.5890230]
这个过程耗时 0.58 秒,不过扫描过程中不需要暂停应用现场,G1 收集器使用后台线程进行扫描工作。不过,这个阶段中不能发生新生代垃圾收集,因此预留足够的 CPU 周期给后台线程运行是非常重要的。如果扫描根分区时,新生代空间刚巧用尽,新生代垃圾收集(会暂停所有的应用线程)必须等待根扫描结束才能完成。效果上,这意味着新生代垃圾收集的停顿时间会更长(远超过正常的耗时)。这种情况在 GC 日志中如下所示:
350.994: [GC pause (young)351.093: [GC concurrent-root-region-scan-end, 0.6100090]351.093: [GC concurrent-mark-start],0.37559600 secs]
此处 GC 的停顿发生在根分区扫描之前,这意味着 GC 停顿还会继续等待,我们会看到 GC 日志中的相互交织的输出。GC 日志的时间戳显示应用线程等待了大概 100 毫秒——这就是新生代 GC 停顿时间比日志中其他停顿的平均持续时间还长 100 毫秒的原因。这是一个信号,说明你的 G1 收集器需要进行调优,下一节我们将详细讨论这部分内容。
根分区扫描完成后,G1 收集器就进入到并发标记阶段。这个阶段完全在后台运行,阶段启动和停止时在 GC 日志中各会打印一条日志。
111.382: [GC concurrent-mark-start]....120.905: [GC concurrent-mark-end, 9.5225160 sec]
并发标记阶段是可以中断的,所以这个阶段中可能发生新生代垃圾收集。紧接在标记阶段之后的是重新标记(remarking)阶段和正常的清理阶段。
120.910: [GC remark 120.959:[GC ref-PRC, 0.0000890 secs], 0.0718990 secs][Times: user=0.23 sys=0.01, real=0.08 secs]120.985: [GC cleanup 3510M->3434M(4096M), 0.0111040 secs][Times: user=0.04 sys=0.00, real=0.01 secs]
这几个阶段都会暂停应用线程,虽然暂停的时间通常很短。紧接着是一个额外的并发清理阶段:
120.996: [GC concurrent-cleanup-start]120.996: [GC concurrent-cleanup-end, 0.0004520]
这之后,正常的 G1 周期就结束了——至少是垃圾的定位就完成了。清理阶段真正回收的内存数量很少,G1 到这个点为止真正做的事情是定位出哪些老的分区可回收垃圾最多(即图 6-7 中标记为 X 的分区)。
现在,G1 会执行一系列的混合式垃圾回收(mixed GC)。这些垃圾回收被称作“混合式”是因为它们不仅进行正常的新生代垃圾收集,同时也回收部分后台扫描线程标记的分区。混合式垃圾收集的效果如图 6-8 所示。

图 6-8:使用 G1 收集器进行的混合式 GC
同新生代垃圾收集通常的行为一样,G1 收集器已经清空了 Eden 空间,同时调整了 Survivor 空间的大小。此外,标记的两个分区也已经被回收。这些分区在之前的扫描中已经证实包含大量垃圾对象,因此绝大部分已经被释放。
这些分区中的活跃数据被移动到另一个分区(就像把活跃数据从新生代移动到老年代的分区)。这就是为什么 G1 收集器最终出现碎片化的堆的频率,跟 CMS 收集器比较起来要小得多的原因——随着 G1 垃圾的回收以这种方式移动对象,实际伴随着压缩。
关于混合式垃圾回收操作,请参考下面的日志:
79.826: [GC pause (mixed), 0.26161600 secs]....[Eden: 1222M(1222M)->0B(1220M)Survivors: 142M->144M Heap: 3200M(4096M)->1964M(4096M)][Times: user=1.01 sys=0.00, real=0.26 secs]
应注意,减少的整个堆的使用不仅仅是 Eden 空间移走的 1222 MB。这其中的差异看起来很小(只有 16 MB),但是同时还有部分 Survivor 空间的对象晋升到了永久代,除此之外,每次混合式垃圾回收只会清理部分目标老年代分区。接下来的讨论中,我们会看到确保混合式垃圾收集清理掉足够的内存对避免将来发生并发失效有多重要。
混合式垃圾回收周期会持续运行直到(几乎)所有标记的分区都被回收,这之后 G1 收集器会恢复常规的新生代垃圾回收周期。最终,G1 收集器会启动再一次的并发周期,决定哪些分区应该在下一次垃圾回收中释放。
同 CMS 收集器一样,有的时候你会在垃圾回收日志中观察到 Full GC,这些日志是一个信号,表明我们需要进一步调优(具体的方式很多,甚至很可能要分配更多的堆空间)才能提升应用程序的性能。主要有 4 种情况会触发这类的 Full GC,如下所列。
并发模式失效
G1 垃圾收集启动标记周期,但老年代在周期完成之前就被填满,在这种情况下,G1 收集器会放弃标记周期:
51.408: [GC concurrent-mark-start]65.473: [Full GC 4095M->1395M(4096M), 6.1963770 secs][Times: user=7.87 sys=0.00, real=6.20 secs]71.669: [GC concurrent-mark-abort]
发生这种失败意味着堆的大小应该增加了,或者 G1 收集器的后台处理应该更早开始,或者是需要调整周期,让它运行得更快(譬如,增加后台处理的线程数)。
晋升失败
G1 收集器完成了标记阶段,开始启动混合式垃圾回收,清理老年代的分区,不过,老年代空间在垃圾回收释放出足够内存之前就会被耗尽。垃圾回收日志中,这种情况的现象通常是混合式 GC 之后紧接着一次 Full GC。
2226.224: [GC pause (mixed)2226.440: [SoftReference, 0 refs, 0.0000060 secs]2226.441: [WeakReference, 0 refs, 0.0000020 secs]2226.441: [FinalReference, 0 refs, 0.0000010 secs]2226.441: [PhantomReference, 0 refs, 0.0000010 secs]2226.441: [JNI Weak Reference, 0.0000030 secs](to-space exhausted), 0.2390040 secs]....[Eden: 0.0B(400.0M)->0.0B(400.0M)Survivors: 0.0B->0.0B Heap: 2006.4M(2048.0M)->2006.4M(2048.0M)][Times: user=1.70 sys=0.04, real=0.26 secs]2226.510: [Full GC (Allocation Failure)2227.519: [SoftReference, 4329 refs, 0.0005520 secs]2227.520: [WeakReference, 12646 refs, 0.0010510 secs]2227.521: [FinalReference, 7538 refs, 0.0005660 secs]2227.521: [PhantomReference, 168 refs, 0.0000120 secs]2227.521: [JNI Weak Reference, 0.0000020 secs]2006M->907M(2048M), 4.1615450 secs][Times: user=6.76 sys=0.01, real=4.16 secs]
这种失败通常意味着混合式收集需要更迅速地完成垃圾收集;每次新生代垃圾收集需要处理更多老年代的分区。
疏散失败
进行新生代垃圾收集时,Survivor 空间和老年代中没有足够的空间容纳所有的幸存对象。这种情形在 GC 日志中通常被当成一种特别的新生代:
60.238: [GC pause (young) (to-space overflow), 0.41546900 secs]
这条日志表明堆已经几乎完全用尽或者碎片化了。G1 收集器会尝试修复这一失败,但是你可以预期,结果会更加恶化:G1 收集器会转而使用 Full GC。解决这个问题最简单的方式是增加堆的大小,除此之外,其他一些可能的解决方案会在第 159 页“高级调优”一节讨论。
巨型对象分配失败
使用 G1 收集器时,分配非常巨大对象的应用程序可能会遭遇另一种 Full GC;参见 6.4.2 节中“使用 G1 分配巨型对象”一节。目前为止没有工具可以很方便地专门诊断这种类型的失败,尤其是从标准垃圾收集日志中进行诊断。不过,如果发生了莫名其妙的 Full GC,其源头很可能是巨型对象分配导致的问题。
快速小结
1. G1 垃圾收集包括多个周期(以及并发周期内的阶段)。调优良好的 JVM 运行 G1 收集器时应该只经历新生代周期、混合式周期和并发 GC 周期。
2. G1 的并发阶段会产生少量的停顿。
3. 恰当的时候,我们需要对 G1 进行调优,才能避免 Full GC 周期发生。
G1垃圾收集器调优
G1 垃圾收集器调优的主要目标是避免发生并发模式失败或者疏散失败,一旦发生这些失败就会导致 Full GC。避免 Full GC 的技巧也适用于频繁发生的新生代垃圾收集,这些垃圾收集需要等待扫描根分区完成才能进行。
其次,调优可以使过程中的停顿时间最小化。下面所列的这些方法都能够避免发生 Full GC。
通过增加总的堆空间大小或者调整老年代、新生代之间的比例来增加老年代空间的大小。
增加后台线程的数目(假设我们有足够的 CPU 资源运行这些线程)。
以更高的频率进行 G1 的后台垃圾收集活动。
在混合式垃圾回收周期中完成更多的垃圾收集工作。
这里有很多的调优可以做,不过 G1 垃圾收集器调优的目标之一是尽量简单。为了达到这个目标,G1 收集器最主要的调优只通过一个标志进行:这个标志跟 Throughput 收集器的标志一致,也是 -XX:MaxGCPauseMillis=N。
使用 G1 垃圾收集器时,该标志有一个默认值:200 毫秒(这一点跟 Throughput 收集器有所不同)。如果 G1 收集器发生时空停顿(stop-the-world)的时长超过该值,G1 收集器就会尝试各种方式进行弥补——譬如调整新生代与老年代的比例,调整堆的大小,更早地启动后台处理,改变晋升阈值,或者是在混合式垃圾收集周期中处理更多或更少的老年代分区(这是最重要的方式)。
通常的取舍就发生在这里:如果减小参数值,为了达到停顿时间的目标,新生代的大小会相应减小,不过新生代垃圾收集的频率会更加频繁。除此之外,为了达到停顿时间的目标,混合式 GC 收集的老年代分区数也会减少,而这会增大并发模式失败发生的机会。
如果设置停顿时间目标无法避免 Full GC,我们可以进一步针对不同的方面逐一调优。对 G1 垃圾收集器而言,调整堆大小的方法与其他的垃圾收集算法并没有什么不同。
1. 调整G1垃圾收集的后台线程数
为了帮助 G1 赢得这场垃圾收集的比赛,可以尝试增加后台标记线程的数目(假设机器有足够的空闲 CPU 可以支撑这些线程的运行)。
调整 G1 垃圾收集线程的方法与调整 CMS 垃圾收集线程的方法类似:对于应用线程暂停运行的周期,可以使用 ParallelGCThreads 标志设置运行的线程数;对于并发运行阶段可以使用 ConcGCThreads 标志设置运行线程数。不过,ConcGCThreads 标志的默认值在 G1 收集器中不同于 CMS 收集器。它的计算方法如下:
ConcGCThreads = (ParallelGCThreads + 2) / 4 3
3CMS 收集器的 ConcGCThreads 计算公式为 ConcGCThreads = (3 + ParallelGCThreads) / 4。
这个算法依然是基于整数的;G1 收集器与 CMS 收集器的计算方法相差无几。
2. 调整G1垃圾收集器运行的频率
如果 G1 收集器更早地启动垃圾收集,也能赢得这场比赛。G1 垃圾收集周期通常在堆的占用达到参数 -XX:InitiatingHeapOccupancyPercent=N 设定的比率时启动,默认情况下该参数的值为 45。注意,跟 CMS 收集器不太一样,这个参数值的依据是整个堆的使用情况,不单是老年代的。
InitiatingHeapOccupancyPercent 的值是个常数,G1 收集器自身不会为了达到停顿时间目标而修改这个参数值。如果该参数设置得过高,应用程序会陷入 Full GC 的泥潭之中,因为并发阶段没有足够的时间在剩下的堆空间被填满之前完成垃圾收集。如果该值设定得过小,应用程序又会以超过实际需要的节奏进行大量的后台处理。我们在介绍 CMS 收集器时讨论过,必须要有能支撑后台处理的 CPU 周期,因此消耗额外的 CPU 就不那么重要。然而,这可能会带来非常严重的后果,因为并发阶段会出现越来越多的短暂应用线程的停顿。这些停顿会迅速累积起来,因此使用 G1 收集器时要避免频繁地进行后台清理。并发 周期结束之后,检查下堆的大小,确保 InitiatingHeapOccupancyPercent 的值大于此时堆的大小。
3. 调整G1收集器的混合式垃圾收集周期
并发周期之后、老年代的标记分区回收完成之前,G1 收集器无法启动新的并发周期。因此,让 G1 收集器更早启动标记周期的另一个方法是在混合式垃圾回收周期中尽量处理更多的分区(如此一来最终的混合式 GC 周期就变少了)。
混合式垃圾收集要处理的工作量取决于三个因素。第一个因素是有多少分区被发现大部分是垃圾对象。目前没有标志能够直接调节这个因素:混合式垃圾收集中,如果分区的垃圾占用比达到 35%,这个分区就被标记为可以进行垃圾回收。(这个因素在将来的某个时刻可能也能调整,在开源的实验版本中已经有名为 -XX:G1MixedGCLiveThresholdPercent=N 的参数可以对其进行调整)。
第二个因素是G1垃圾收集回收分区时的最大混合式 GC 周期数,通过参数 -XX:G1MixedGCCountTarget=N 可以进行调节。这个参数的默认值为 8;减少该参数值可以帮助解决晋升失败的问题(代价是混合式 GC 周期的停顿时间会更长)。
另一方面,如果混合式 GC 的停顿时间过长,可以增大这个参数的值,减少每次混合式 GC 周期的工作量。不过调整之前我们需要确保增大值之后不会对下一次 G1 并发周期带来太大的延迟,否则可能会导致并发模式失败。
最后,第三个影响因素是 GC 停顿可忍受的最大时长(通过 MaxGCPauseMillis 参数设定)。MaxGCPauseMillis 标志设定的混合式周期时长是向上规整的,如果实际停顿时间在停顿最大时长以内,G1 收集器能够收集超过八分之一标记的老年代分区(或者其他设定的值)。增大 MaxGCPauseMillis 能在每次混合式 GC 中收集更多的老年代分区,而这反过来又能帮助 G1 收集器在更早的时候启动并发周期。
快速小结
1. 作为 G1 收集器调优的第一步,首先应该设定一个合理的停顿时间作为目标。
2. 如果使用这个设置后,还是频繁发生 Full GC,并且堆的大小没有扩大的可能,这时就需要针对特定的失败采用特定的方法进行调优。
a. 通过
InitiatingHeapOccupancyPercent标志可以调整 G1 收集器,更频繁地启动后台垃圾收集线程。b. 如果有充足的 CPU 资源,可以考虑调整
ConcGCThreads标志,增加垃圾收集线程数。c. 减小
G1MixedGCCountTarget参数可以避免晋升失败。
