引言:如果你在使用Java8,或者计划使用Java9,有很大可能是要么在评估G1垃圾收集器,要么已经在使用它。本文将从G1设计开始向您介绍系统介绍G1垃圾收集器如何工作,助您更加系统的学习了解G1。
本文选自《Java性能调优指南》。
G1设计
G1将Java堆分成多个分区。分区的大小可以依据堆的尺寸而改变,但必须是2的幂,同时最小为1MB,最大为32MB。由此得出可能的分区尺寸是1 MB、2MB、4 MB、8 MB、16 MB和32MB。所有分区的大小都一样,在JVM运行过程中它们的尺寸也不会发生变化。分区尺寸是基于Java堆内存的初始值和最大值的平均数来进行计算的,这样对于这个平均堆尺寸就会有2000个左右的分区。举个例子,对一个16G的Java堆使用-Xmx16g -Xms16g命令行选项,G1就会选择采用16GB/2000 = 8MB的分区尺寸。
如果Java堆内存初始值和最大值相差很远,或者这个堆内存的尺寸非常大,很有可能就会产生远超过2000个的分区。类似地,若堆内存很小,那分区数量会远远小于2000。
每个分区都有一个关联的已记忆集合(remembered set,该集合用来记录跟踪分区外指向分区内的引用,简称RSet),这样就避免了对整个堆的扫描,使得各个分区的GC更加独立。RSet总体大小有限,但也不容忽视,因此分区的数量对HotSpot的内存空间占用有直接的影响。RSet总体的尺寸严重依赖应用的行为。RSet最少时大概会占用1%左右的堆空间,最多时可能会达到20%。
一个特定的分区一次只能用于一个目的,但一旦这个分区被包含进一次收集,它就会被彻底转移,同时被释放为一个可用分区。
G1有多种类型的分区。可用分区是当前未被使用的。eden(新生代)分区组成了年轻代的eden空间,survivor(存活代)分区组成了年轻代的survivor空间。所有eden分区和survivor分区的总的集合,就是年轻代。eden分区或survivor分区的数量随着一次次的垃圾收集发生改变,包括年轻代收集、混合收集或者full收集。老年代分区由绝大部分老年代组成。最后,通常认为巨型分区是老年代的一个组成部分,它用来容纳那些大小达到或超过一个分区50%空间的对象。在JDK 8u40之前,巨型分区是作为老年代的一部分被收集的,但在JDK 8u40里,某些巨型分区是作为一个年轻代的一部分被收集的。本章后续还会提到更多关于巨型分区的细节。
实际上,一个分区可以用于任何目的,也就是说没有必要把内存堆划分成相邻的年轻代段和老年代段。G1的启发式算法会估算年轻代需要多少个分区,以及按照指定的GC暂停时间估算目前还有多少分区要被回收。一旦应用开始生产对象,G1就选中一个可用分区并将它指定为eden分区,然后从中取出内存块交给Java线程。当这个分区满了之后,另一个未被使用的分区会再被指定为eden分区。这个操作会一直持续下去,直到达到eden分区的上限数量,就触发一次年轻代垃圾收集。
一次年轻代垃圾收集会回收所有年轻代分区,包括eden分区和survivor分区。这些分区里的所有存活对象都会被转移到另外一个新的survivor分区或者老年代分区。在当前转移的目标分区满了之后,就会将新的可用分区标记为survivor分区或老年代分区,继续转移操作。
一次GC之后,当老年代的空间占用达到甚至超过了堆空间的占用门槛,G1就会启动一次老年代收集。通过命令行选项-XX:InitiatingHeapOccupancyPercent来控制占用门槛,缺省情况是Java堆内存的45%。
当标记阶段显示某些老年代分区中没有任何存活对象,G1会提前将它们回收。这些分区将被添加到可用分区集合里。那些包含存活对象的老年代分区则被安排到将来的混合收集中。
G1使用多个并发标记线程,为了尽量避免从应用线程中“偷取”太多CPU,标记线程的工作往往是爆发式的。它们在一个给定的时间段里拼命干活,然后暂定一段时间,让Java线程得以执行。
巨型(Humongous)对象
G1对大尺寸对象(G1被称为“巨型对象”)分配会做特殊处理。前面讲过,巨型对象就是大小达到甚至超过一个分区50%空间的对象。这个尺寸包括Java对象头。对象头的尺寸在32位和64位的HotSpot虚拟机中是不一样的。一个指定HotSpot虚拟机中某个指定对象的头尺寸可以通过Java对象布局工具来获取,也就是JOL。到写这本书时,在网上已经能找到Java对象布局工具了。
当发生巨型对象分配时,G1会找出一个连续的可用分区集合,这样就能汇总出足够的内存来容纳巨型对象。第一个分区别被标记为“巨型开始”(humongous start)分区,其他的分区别被标记为“巨型连续”(humongous continues)分区。如果没有足够的连续可用空间,G1就会启动一次full GC来压缩Java堆空间。
巨型分区被认为是老年代的组成部分,但它们只包含一个对象。这个性质允许G1一旦在并发标记阶段发现该对象已经不再存活,就可以尽早回收这个巨型分区。一旦发生这种情况,所有用来容纳这个巨型对象的分区都将被回收。
G1面临的一个潜在的挑战,就是某些“短命的”巨型对象虽然已经变成未被引用了,但可能一直没有被回收。JDK 8u40中实现了一个方法,某些情况下在年轻代收集时回收巨型分区。使用G1时避免过于频繁的巨型对象分配,对达成应用性能目标有决定性的帮助。对那些有大量短命巨型对象的应用来说,增强JDK 8u40有一定帮助,但不是最终的解决方案。
Full垃圾收集
G1里full GC使用的是与串行垃圾收集器相同的算法。当发生full GC时,就会执行对整个内存堆的全面压缩。这确保最大数量的空闲内存可以被系统使用。很重要的一点是G1的full GC活动是单线程的,结果就是可能导致异常长的暂停时间。当然,G1的设计方式也希望使full GC不再是必需的。G1希望不用full GC就能满足应用的性能目标,然后通过不断地调优从而不再需要full GC。
并发周期
一个G1并发周期包含了几个阶段的活动:初始标记、并发根分区扫描、并发标记,重新标记以及清除。一个并发周期从初始标记开始,到清除阶段结束。除了清除阶段,所有这些阶段都是“标记存活对象图”的组成部分。
初始标记阶段的目的是收集所有的GC根。根是对象图的起点。为了从应用线程中收集根引用,必须先暂停这些应用线程,所以初始标记阶段是stop-the-world方式的。在G1里,完成初始标记是年轻代GC暂停的一个组成部分,因为无论如何年轻代GC都必须收集所有根。
标记操作的同时还必须扫描和跟踪survivor分区里所有对象的引用。这也是并发根分区扫描所要做的事。在这个阶段,所有Java线程都允许执行,所以不会发生应用暂停。唯一的限制就是在下一次GC启动前必须先完成扫描。这样做的原因是一次新的GC会产生一个新的存活对象集合,它们跟初始标记的存活对象是有区别的。
大部分标记工作是在并发标记阶段完成的。多个线程协同标示存活对象图。所有Java线程都可以与并发标记线程同时运行,所以应用就不存在暂停,尽管会受到吞吐量下降的一些影响。
完成并发标记后就需要另一个stop-the-world方式的阶段来最终完成所有的标记工作。这个阶段被称为“重新标记阶段”,通常它只是一个非常短暂的stop-the-world的暂停。
并发标记的最终阶段是清除阶段。在这个阶段,找出来的那些没有任何存活对象的分区将被回收。正因为它们没有任何存活对象,这些分区也不会被包含在年轻代或混合GC中,它们会被添加到可用分区的队列里。
完成标记阶段之后,就能找出哪些对象是存活的,进而确定哪些分区要被包含在混合GC里。既然G1里混合GC是释放内存的基本手段,那么在G1用光可用分区之前完成标记阶段就显得至关重要,如果做不到的话,G1只能退回去发起一次full GC来释放内存,这虽然可靠却很慢。
堆空间调整
G1里的Java堆尺寸通常是分区尺寸的整数倍。除去这个限制,G1和其他HotSpot垃圾收集器一样,可以在 -Xms与 -Xmx之间动态地扩大或缩小堆大小。
基于以下几个理由,G1可能会增加Java堆尺寸:
- 在一次full GC中,基于堆尺寸的计算结果会调整堆的空间。
- 当发生年轻代收集或混合收集,G1会计算执行GC所花费的时间以及执行Java应用所花费的时间。根据命令行配置-XX:GCTimeRatio,如果将太多时间用在垃圾收集上,Java堆尺寸就会增加。这个情况下增加Java堆尺寸,其背后的想法就是允许GC减少发生频度,这样与花在应用上的时间相比,花在GC上的时间也可以随之降低。 G1中-XX:GCTimeRatio的缺省值为9,而其他所有HotSpot垃圾收集器都缺省使用99。GCTimeRatio的值越大,Java堆尺寸的增长就会更加得积极。其他HotSpot收集器在增加Java堆尺寸的策略上会更加得激进,因为它们的目标是:相对于执行应用的开销,用于GC的时间越少越好。
- 如果一个对象分配失败了(甚至是在做了一次GC之后),G1会尝试通过增加堆尺寸来满足对象分配,而不是马上退回去做一次full GC。
- 如果一个巨型对象分配无法找到足够的连续分区来容纳这个对象,G1会尝试扩展Java堆来获得更多可用分区,而不是做一次full GC。
- 当GC需要一个新的分区来转移对象时,G1更倾向于通过增加Java堆空间来获得一个新的分区,而不是通过返回GC失败并开始做一次full GC来找到一个可用分区。
本文选自《Java性能调优指南》,点此链接可在博文视点官网查看此书。
想及时获得更多精彩文章,可在微信中搜索“博文视点”或者扫描下方二维码并关注。