G1诞生的背景

Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器面向局部收集的设计思路和基于Region的内存布局形式。HotSpot开发团队最初赋予它的期望是(在比较长期的) 未来可以替换掉JDK 5中发布的CMS收集器。 现在这个期望目标已经实现过半了, JDK 9发布之日, G1宣告取代Parallel Scavenge加Parallel Old组合, 成为服务端模式下的默认垃圾收集器, 而CMS则沦落至被声明为不推荐使用(Deprecate) 的收集器[1]。 如果对JDK 9及以上版本的HotSpot虚拟机使用参数-XX: +UseConcMarkSweepGC来开启CMS收集器的话, 用户会收到一个警告信息, 提示CMS未来将会被废弃

Java HotSpot(TM) 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0

作为CMS收集器的替代者和继承人, 设计者们希望做出一款能够建立起“停顿时间模型”(PausePrediction Model) 的收集器, 停顿时间模型的意思是能够支持指定在一个长度为M毫秒的时间片段内, 消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标, 这几乎已经是实时Java(RTSJ) 的软实时垃圾收集器特征了

应用场景

G1是一种服务端应用使用的垃圾回收器,目标是用在多核,大内存的机器上,它在大多数情况下可以实现指定的GC暂停时间(-XX:MaxGCPauseMillis=200,意思是要求 G1,在任意 1 秒的时间内,停顿时间不得超过 200ms,G1开创的基于Region的堆内存布局是它能够实现这个目标的关键),同时还能保持较高的吞吐量,据研究他的吞吐量比PS降低了10%~15%

为什么说G1是收集器技术发展的一个里程碑?

从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率(Allocation Rate),而不追求一次把整个Java堆全部清理干净。这样,应用在分配,同时收集器在收集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。这种新的收集器设计思路从工程实现上看是从G1开始兴起的,所以说G1是收集器技术发展的一个里程碑

什么是软实时?

所谓的实时垃圾回收,是指在要求的时间内完成垃圾回收。“软实时”则是指,用户可以指定垃圾回收时间的限时,G1会努力在这个时限内完成垃圾回收,但是G1并不保证每次都能在这个时限内完成垃圾回收。通过设定一个合理的目标,可以让达到90%以上的垃圾回收时间都在这个时限内。

-XX:MaxGCPauseMillis设置多少比较合适?

毫无疑问,可以由用户指定期望的停顿时间是G1收集器很强大的一个功能,设置不同的期望停顿时间,可使得G1在不同应用场景中取得关注吞吐量和关注延迟之间的最佳平衡。不过,这里设置的“期望值”必须是符合实际的,不能异想天开,毕竟G1是要冻结用户线程来复制对象的,这个停顿时间再怎么低也得有个限度。它默认的停顿目标为200ms,一般来说,回收阶段占到几十到一百甚至接近两百毫秒都很正常,但如果我们把停顿时间调得非常低,譬如设置为二十毫秒,很可能出现的结果就是由于停顿目标时间太短,导致每次选出来的回收集只占堆内存很小的一部分,收集器收集的速度逐渐跟不上分配器分配的速度,导致垃圾慢慢堆积。很可能一开始收集器还能从空闲的堆内存中获得一些喘息的时间,但应用运行时间一长就不行了,最终占满堆引发Full GC反而降低性能,所以通常把期望停顿时间设置为一两百毫秒或者两三百毫秒会是比较合理的

G1如何划分内存?

前面的那些垃圾回收器的内存都是连续的,分块的,且一旦内存块大到一定程度无论怎么调优都没戏,所以G1就带来了新的内存模型,看下图:

深入浅出具有划时代意义的G1垃圾回收器-LMLPHP

软件架构设计有一个重要的思想就是分而治之,G1把内存分为一个个Region,从1M,2M到32M不等,但都是2的幂次方,可以通过参数-XX:G1HeapRegionSize设定。

每一个Region在逻辑上仍然是属于某一个分代(逻辑分代,物理不分代),这个分代分为四种:

  1. old区:存放老对象
  2. Survivor区:存放存活对象
  3. Eden区:存放新生代对象
  4. Humongous区:存放大对象区域,对象特别大可能会跨两个Region

    所以G1的模型和以前的分代模型完全不一样了

如何理解Region

  1. 每一个Region,即分区可能是年轻代也可能是老年代,但是在同一时刻只能属于某个代。年轻代,老年代,幸存区这些概念还在,只不过只是逻辑上的概念,这样方便复用之前的分代逻辑框架的逻辑。
  2. 在物理上不需要连续则带来了额外的好处:有的分区垃圾特别多,有的分区垃圾比较少,G1就会优先回收垃圾对象特别多的分区,这样就可以花费较少的时间来回收这些分区的垃圾,这也是G1垃圾回收器的由来,即首先回收垃圾较多的分区。但是新生代不使用这种算法,依然是新生代的空间满了之后才会对整个新生代进行回收。整个新生代中的对象要么被回收,要么晋升。至于新生代也采取分区的机制是因为这样和老年代的策略统一,方便调整代的大小。
  3. G1还是有压缩功能的垃圾回收器,在回收老年代的分区时,是将存活的对象从一个分区拷贝到另外的一个分区,这样拷贝的过程就实现了局部压缩的效果。
  4. G1的内存Region不是固定的E或者O或者其他,比如说:在某一个时间段内这一块区域是E,但是进行一次YGC回收之后把这个E区域擦除了,那么下一次回收的时候可能就当做O区来用了,所以说比较灵活

为什么G1可以预测停顿时间?

因为它将Region作为单次回收的最小单元, 即每次收集到的内存空间都是Region大小的整数倍, 这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集,更具体的处理思路是让G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定, 默认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来。这种使用Region划分内存空间, 以及具有优先级的区域回收方式,保证了G1收集器在有限的时间内获取尽可能高的收集效率。

特点

  1. 并发收集垃圾
  2. 压缩空闲的空间不会延长GC的暂停时间
  3. 更易预测GC暂停的时间
  4. 适用于对吞吐量没有极致要求的场景(STW的时间很短)

G1 的垃圾回收过程

在逻辑上,G1分为年轻代和老年代,但它的年轻代和老年代比例,并不是那么“固定”,为了达到 MaxGCPauseMillis 所规定的效果,G1 会自动调整两者之间的比例。

如果你强行使用 -Xmn 或者 -XX:NewRatio 去设定它们的比例的话,我们给 G1 设定的这个目标将会失效。

  1. G1“年轻代”的垃圾回收,同样叫 Minor GC,这个过程和我们前面描述的类似,发生时机就是 Eden 区满的时候。
  2. 老年代的垃圾收集,严格上来说其实不算是收集,它是一个“并发标记”的过程,顺便清理了一点点对象。
  3. 真正的清理,发生在“混合模式”(Mixed GC),它不止清理年轻代,还会将老年代的一部分区域进行清理。

老年代垃圾收集

深入浅出具有划时代意义的G1垃圾回收器-LMLPHP

具体标记过程如下:

1.初始标记(Initial Mark)

这个过程共用了 Minor GC 的暂停,这是因为它们可以复用 root scan 操作。虽然是STW的,但是时间通常非常短。

2.Root 区扫描(Root Region Scan)

3.并发标记( Concurrent Mark)

这个阶段从 GC Roots 开始对 heap 中的对象标记,标记线程与应用程序线程并行执行,并且收集各个 Region 的存活对象信息。

4.重新标记(Remaking)

和 CMS 类似,也是STW的。标记那些在并发标记阶段发生变化的对象。

5.清理阶段(Cleanup)

这个过程不需要 STW。如果发现 Region 里全是垃圾,在这个阶段会立马被清除掉。不全是垃圾的 Region,并不会被立马处理,它会在 Mixed GC 阶段,进行收集。

RSet和CSet

深入浅出具有划时代意义的G1垃圾回收器-LMLPHP

GC什么时候触发?

  1. Eden区空间不足
  2. Old空间不足或者手动调用System.gc()

MixedGC

比如YGC不行了,对象产生特别多达到了45%的阈值默认启动了MixedGC,这个值是可以自己定的,MixedGC就相当于一个完整的CMS垃圾回收过程

初始标记-STW(initial mark)

并发标记(concurrent mark)

重新标记-STW(remark)

筛选回收

G1有没有FGC?

有,而且JDK10以前都是串行的,之后才是并行,我们说G1和CMS调优的目标其中之一就是尽量避免FGC

G1如果产生FGC,你应该怎么办?

  1. 扩内存
  2. 提高CPU性能(回收的快,业务逻辑产生对象的速度固定,垃圾回收越快,可用空间就越大)
  3. 降低MixedGC的阈值,让MixedGC提早发生(默认是45%),设置这个参数:-XX:G1HeapWastePercent ?

并发标记的算法

CMS和G1并发标记采用的算法都是三色标记法,把对象在逻辑上分成三种颜色:

  1. 黑色:自己是不是垃圾已经被标记完了,并且成员变量是不是垃圾也已经被标记完毕
  2. 灰色:本身标记完了,但是还没有标记到它所引用的那些对象
  3. 白色:没有被标记的对象

三色算法的缺陷:漏标

什么情况下会产生漏标呢?黑色指向了白色,但同时指向白色的其他引用没了,在remark过程中,黑色A指向了白色C,如果不对黑色重新扫描,就会漏标,会把白色C对象当成垃圾回收掉

深入浅出具有划时代意义的G1垃圾回收器-LMLPHP

如何解决漏标?

  1. 关注引用的增加(incremental update)

    比如A指向D的时候跟踪这个引用,产生了这个引用之后,把A重新标记位灰色,原来是黑色不会扫描,但是被标记位灰色之后再下一次扫描的时候还会重新扫描一遍,因此D就会被找到,CMS采用的是这种算法
  2. 关注引用的删除(SATB:snaphot at the begining)

    刚开始有一个快照,当B和D消失的时候要把这个引用push到堆栈,保证D还能被GC扫描到,最重要的是要把这个引用push到堆栈,是灰色对象指向白色对象的引用,如果一旦某一个引用消失就会把它放进堆栈,因此还是可以扫描到它,这样白色的C就不会被漏标了,G1采用的就是这个方案。

为什么G1使用SATB,而不使用incremental update?

因为变成灰色还要重新扫描一遍,效率偏低

G1与CMS相比有什么优势?

G1不会产生内存空间碎片。与CMS的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可用内存。这种特性有利于程序长时间运行,在程序为大对象分配内存时不容易因无法找到连续内存空间而提前触发下一次收集。

G1与CMS相比有什么劣势?

如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比CMS要高。

  1. 更高的内存占用。虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单,只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的
  2. 更高的负载占用。在执行负载的角度上,同样由于两个收集器各自的细节实现特点导致了用户程序运行时的负载会有不同,譬如它们都使用到写屏障,CMS用写后屏障来更新维护卡表;而G1除了使用写后屏障来进行同样的(由于G1的卡表结构复杂,其实是更烦琐的)卡表维护操作外,为了实现原始快照搜索(SATB)算法,还需要使用写前屏障来跟踪并发时的指针变化情况。相比起增量更新算法,原始快照搜索能够减少并发标记和重新标记阶段的消耗,避免CMS那样在最终标记阶段停顿时间过长的缺点,但是在用户程序运行过程中确实会产生由跟踪引用变化带来的额外负担。由于G1对写屏障的复杂操作要比CMS消耗更多的运算资源,所以CMS的写屏障实现是直接的同步操作,而G1就不得不将其实现为类似于消息队列的结构,把写前屏障和写后屏障中要做的事情都放到队列里,然后再异步处理

以上的优缺点对比仅仅是针对G1和CMS两款垃圾收集器单独某方面的实现细节的定性分析,通常说哪款收集器要更好、要好上多少,往往是针对具体场景才能做的定量比较。按照实践经验,目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间,当然,以上这些也仅是经验之谈,不同应用需要量体裁衣地实际测试才能得出最合适的结论,随着HotSpot的开发者对G1的不断优化,也会让对比结果继续向G1倾斜

欢迎添加我的个人微信一起探讨

深入浅出具有划时代意义的G1垃圾回收器-LMLPHP

05-23 00:29