CMS,G1 和 ZGC

本文主要介绍比较常用的垃圾收集器:CMS,G1 和 ZGC。CMS 是服务器使用比较多的收集器,侧重点在低停顿;JDK 9 以后,G1 成为了默认收集器,它的设计目标是停顿可控;最新的 JDK 11 中,加入了实验性质的 ZGC,这个收集器可以将停顿时间降至 10ms 以下。

1、JDK1.8 之前默认的垃圾收集器

JDK1.8 之前(包括 1.8),默认的垃圾收集器是 Parallel Scavenge(新生代)+Parallel Old(老年代)。

Parallel Scavenge 是一款基于复制算法的新生代垃圾处理器,它的设计目标是吞吐量优先。所谓吞吐量就是指:用户运行时间/(用户运行时间+垃圾回收时间)。很显然吞吐量越大越好。用户需要给 Parallel Scavenge 设置一个吞吐量的目标,然后 Parallel Scavenge 会自动控制每一次垃圾回收的时间。另外 Parallel Scavenge 还有自适应调节策略,只要打开-XX:+UseAdaptiveSizePolicy,它就会动态调整新生代大小、Eden 与 Survivor 的比例、晋升老年代对象大小等参数,以达到最大的吞吐量。

和 Parallel Scavenge 配套使用的老年代收集器是 Parallel Old,这是一款基于标记-整理算法的垃圾处理器。

为什么不用 CMS 作为老年代收集器?

这是因为 Parallel Scavenge 的作者没有使用 HotSpot VM 给定的代码框架,而是自己独立实现了一个。这就导致 Parallel Scavenge 和当时大部分收集器都不兼容,其中就包括 CMS。所以在 1.8 时代,比较流行的有两套收集器,一套是 Parallel Scavenge(新生代)+Parallel Old(老年代),另一套是 ParNew(使用复制算法,新生代)+ CMS(老年代)

2、低停顿的 CMS

CMS,Concurrent Mark Sweep,是一款老年代的收集器,它关注的是垃圾回收最短的停顿时间。命名中 Concurrent 说明这个收集器是有与工作执行并发的能力的,Mark Sweep 则代表算法用的是标记-清除算法。

CMS 的工作原理分为四步:

  • 初始标记:单线程执行,仅仅把 GC Roots 的直接关联可达的对象标记一下,速度很快,需要停顿。
  • 并发标记:对于初始标记过程所标记的初始对象,进行并发追踪标记,不需要停顿。
  • 重新标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 并发清除:清除之前标记的垃圾,不需要停顿。

由于最耗费时间的并发标记与并发清除阶段都不需要暂停工作,所以整体的回收是低停顿的。

由于 CMS 以上特性,缺点也是比较明显的:

  • 标记-清除算法会导致内存碎片比较多。
  • CMS 的并发能力依赖于 CPU 资源,所以在 CPU 数少和 CPU 资源紧张的情况下,性能较差。
  • 无法处理浮动垃圾。浮动垃圾是指在并发清除阶段用户线程继续运行而产生的垃圾,这部分垃圾只能等下次 GC 时处理。由于浮动垃圾的存在,CMS 不能等待内存耗尽的时候才进行 GC,而要预留一部分内存空间给用户线程。这里会浪费一些空间。

为什么不用标记-整理?

因为在并发清除阶段,其它用户线程还在工作,要保证它们运行的资源不受影响。而标记-整理算法会移动对象,所以不能使用标记-整理。

3、停顿可控的 G1

G1 是一款可以掌管所有堆内存空间的收集器。G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

通过引入 Region 的概念,将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。这种划分方法避免了空间碎片化,也提高了回收的灵活性——G1 会根据平均每个 Region 回收需要的时间(经验预测)和各个 Region 的回收收益,制定回收计划。

每个 Region 都有一个 Remembered Set,Remembered Set 记录了其他 Region 中的对象引用本 Region 中对象的关系(准确的说是老年代的 Region 到年轻代 Region 中对象的引用)。为什么要有 Remembered Set 呢?这是因为在分代收集器中,老年代的对象有可能会引用年轻代的对象,这就导致在收集器进行年轻代回收时,为了判断对象的存活性,不得不也扫描一次老年代,这个代价是非常大的。而有了 Remembered Set 之后,在进行年轻代回收时,可以根据 Remembered Set 检查老年代对该对象的引用,不需要再扫描整个老年代,大大减少了 GC 的工作量。(G1 在进行年轻代回收时不会回收被老年代引用的对象,这可能会导致有些可以被回收的对象没有被回收,但为了效率这是可以接受的)

其它收集器是否也有 Remembered Set?

在 CMS 中,也有 RSet 的概念,在老年代中有一块区域用来记录指向新生代的引用。这是一种 point-out(我引用了谁),在进行 Young GC 时,根扫描仅仅需要扫描这一块区域,而不需要扫描整个老年代。G1 中使用了 point-in(谁引用了我),point-in 会比 point-out 更高效,但也会占用更多的空间。CMS 中没有 Region 的概念,所以只能用 point-out(除非为每个对象维护一份 RSet,这样显然不能接受),而 G1 以 Region 为单位记录引用关系,在空间上就可以接受了。

为什么不记录新生代的引用?

G1 在每次进行 GC 时,所有新生代都会被扫描,所以只需要记录老年代到新生代之间的引用即可。

三种 GC 模式:

  • Young GC,发生于新生代空间不足时,回收全部新生代,可以通过控制新生代 Region 的个数来控制 Young GC 的时间开销。
  • Mixed GC,当堆中内存使用超过整个堆大小的 InitiatingHeapOccupancyPercent(默认 45)时启动。 回收全部新生代,并根据预期停顿时间回收部分收益较高的老年代。
  • Full GC(JDK 9 引入),发生于老年代空间不足时,相当于执行一次 STW 的 full gc。

整体的执行流程:

  • 初始标记:标记了从 GC Root 开始直接关联可达的对象,速度很快,单线程,需停顿。
  • 并发标记:对于初始标记过程所标记的初始对象,进行并发追踪标记,并记录下每个 Region 中的存活对象信息用于计算收益,不需要停顿。
  • 最终标记:为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,需要停顿。
  • 筛选回收:根据 GC 模式、回收时间和回收收益确定回收计划,回收后的空 Region 会加入到空闲列表,需要停顿。

由于 G1 会把存活的对象集中起来放到 Survivor Region 中,并通过空闲列表整理所有的空 Region,所以整体来看是基于“标记 - 整理”算法实现的收集器;但从局部(两个 Region 之间)上来看又是基于“复制”算法实现的。但不论如何,这两种算法都需要移动对象,所以 G1 的回收阶段是需要停顿的。

4、几乎无停顿的 ZGC

在 JDK 11 当中,加入了实验性质的 ZGC。它的回收耗时平均不到 2 毫秒。它是一款低停顿高并发的收集器。ZGC 几乎在所有地方并发执行的,除了初始标记的是 STW 的。所以停顿时间几乎就耗费在初始标记上,这部分的实际是非常少的。那么其他阶段是怎么做到可以并发执行的呢?ZGC 主要新增了两项技术,一个是着色指针 Colored Pointer,另一个是读屏障 Load Barrier。

着色指针 Colored Pointer

ZGC 利用指针的 64 位中的几位表示 Finalizable、Remapped、Marked1、Marked0(ZGC 仅支持 64 位平台),以标记该指向内存的存储状态。相当于在对象的指针上标注了对象的信息。注意,这里的指针相当于 Java 术语当中的引用。

在这个被指向的内存发生变化的时候(内存在整理时被移动),颜色就会发生变化。

读屏障 Load Barrier

由于着色指针的存在,在程序运行时访问对象的时候,可以轻易知道对象在内存的存储状态(通过指针访问对象),若请求读的内存在被着色了。那么则会触发读屏障。读屏障会更新指针再返回结果,此过程有一定的耗费,从而达到与用户线程并发的效果。

把这两项技术联合下理解,引用 R 大(RednaxelaFX)的话

与标记对象的传统算法相比,ZGC 在指针上做标记,在访问指针时加入 Load Barrier(读屏障),比如当对象正被 GC 移动,指针上的颜色就会不对,这个屏障就会先把指针更新为有效地址再返回,也就是,永远只有单个对象读取时有概率被减速,而不存在为了保持应用与 GC 一致而粗暴整体的 Stop The World。

ZGC 和 G1 一样将堆划分为 Region 来清理、移动,稍有不同的是 ZGC 中 Region 的大小是会动态变化的。

ZGC 的回收流程如下:

1、初始停顿标记

停顿 JVM 地标记 Root 对象,1,2,4 三个被标为 live。

2、并发标记

并发地递归标记其他对象,5 和 8 也被标记为 live。

3、移动对象

对比发现 3、6、7 是过期对象,也就是中间的两个灰色 region 需要被压缩清理,所以陆续将 4、5、8 对象移动到最右边的新 Region。移动过程中,有个 forward table 纪录这种转向。

活的对象都移走之后,这个 region 可以立即释放掉,并且用来当作下一个要扫描的 region 的 to region。所以理论上要收集整个堆,只需要有一个空 region 就 OK 了。

4、修正指针

最后将指针都妥帖地更新指向新地址。

ZGC 虽然目前还在 JDK 11 还在实验阶段,但由于算法与思想是一个非常大的提升,相信在未来不久会成为主流的 GC 收集器使用。

参考资料

Java Hotspot G1 GC的一些关键技术
G1收集器-欧阳亮的博客