抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

JVM垃圾收集器-ZGC

概述

ZGC是一款JDK 11中新加入的具有实验性质的低延迟垃圾收集器,ZGC源自于是Azul System公司开发的C4(Concurrent Continuously Compacting Collector) 收集器。

来源

ZGC收集器(Z Garbage Collector)是由Oracle公司研发的。2018年创建了JEP 333将ZGC提交给OpenJDK,推动其进入OpenJDK11的发布清单中。

是什么

​ ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的以低延迟为首要目标的一款垃圾收集器

实现目标

​ 希望能在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10ms以内的低延迟

GC的堆内存布局

与Shenandoah和G1一样,ZGC也采用基于Region的堆内存布局。

ZGC的Region具有动态性。

  • 动态创建和销毁
  • 动态的区域容量大小

分类如下

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。

  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。

  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,所以实际容量可能小于中型Region,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂

并发整理算法

算法的由来

  • G1收集器的筛选回收阶段是stop the world的,但收集器线程间是并行的,之所以不和用户线程并发执行,是因为G1只回收一部分Region,停顿时间是用户可以控制的。所以并不着急去实现,交给了ZGC去实现。

  • 并且因为G1为了不影响吞吐量才选择stw的。停顿用户线程可以最大幅度提高垃圾收集效率。

读屏障

​ 当对象从堆中加载的时候,就会使用到读屏障(Load Barrier)。这里使用读屏障的主要作用就是检查指针上的三色标记位,根据标记位判断出对象是否被移动过,如果没有可以直接访问,如果移动过就需要进行“自愈”(对象访问会变慢,但也只会有一次变慢),当“自愈”完成后,后续访问就不会变慢了。

读写屏障可以理解成对象访问的“AOP”操作

指针的自愈能力
  • 在ZGC中,当读取处于重分配集的对象时,会被读屏障拦截,通过转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象。ZGC将这种行为叫做指针的“自愈能力”。

  • 好处是:第一次访问旧对象访问会变慢,但也只会有一次变慢,当“自愈”完成后,后续访问就不会变慢了。

    • Shenandoah每次访问都慢,对比发现,ZGC的执行负载更低。

染色指针技术

HotSpot虚拟机的标记实现方案
  • 把标记直接记录在对象头上(如Serial收集器);

  • 把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息);

  • 直接把标记信息记在引用对象的指针上(如ZGC)

为什么会放在指针上
  • 追踪式收集算法的标记阶段就是看有没有引用,所以可以只和指针打交道而不管指针所引用的对象本身。

  • 例如对象标记过程就是打个三色标记,这些标记本质上只和对象引用有关,和对象本身无关。某个对象只有它的引用关系才能决定它的存活。

染色指针的解释

​ 染色指针是一种直接将少量额外的信息存储在指针上的技术。目前在Linux下64位的操作系统中高18位是不能用来寻址的,但是剩余的46为却可以支持64T的空间,到目前为止我们几乎还用不到这么多内存。于是ZGC将46位中的高4位取出,用来存储4个标志位,剩余的42位可以支持4TB(2的42次幂)的内存,也直接导致ZGC可以管理的内存不超过4TB,如图所示:

​ 限制:只能在64位系统上,因为ZGC设置就是用的42-46位,32位明显不够嘛。。并且不支持压缩指针(这一块可以参考Java对象模型中的OOP,meta中有一个Klass直接指向Klass,还一个压缩指针)如下。

1
2
3
4
5
union _metadata {
之前都是oop,现在直接指向Klass了
Klass* _klass;
narrowKlass _compressed_klass;
} _metadata;
指针含义
  • Linux下64位指针的高18位不能用来寻址,所有不能使用;

  • Finalizable:表示是否只能通过finalize()方法才能被访问到,其他途径不行;

  • Remapped:表示是否进入了重分配集(即被移动过);

  • Marked1、Marked0:表示对象的三色标记状态;

  • 最后42用来存对象地址,最大支持4T;

三色标记

在并发的可达性分析算法中我们使用三色标记(Tri-color Marking)来标记对象是否被收集器访问过:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

可达性分析的扫描过程,其实就是一股以灰色为波峰的波纹从黑向白推进的过程,但是在并发的推进过程中会产生“对象消失”的问题,如图:

对象消失理论,只有同时满足才会发生对象消失:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;

  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用;

要解决对象消失问题只需要破坏其中一条就行了,目前常用有两种方案:

  • 增量更新(Incremental Update):增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
  • 原始快照(Snapshot At TheBeginning,SATB):原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

染色指针的设计

  • ZGC使用了内存多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射。
  • 因为染色指针只是重新定义内存中某些指针的其中几位,OS又不支持,OS只会把整个指针当做一个内存地址来对待,只是它自己瞎想,为了解决这个问题,使用了现代处理器的虚拟内存映射技术
  • 现代处理器一般使用请求分页机制+虚拟内存映射技术。
    • 请求分页机制把线性地址空间和物理地址空间分别划分为大小相等的块。这样的块称为页。通过在线性虚拟空间的页和物理地址空间的页建立映射表,分页机制会进行线性地址到物理地址的映射,完成线性地址到物理地址的转换。
    • Linus/x86-64平台上的ZGC使用了多重映射将多个不同的虚拟内存地址映射到同一个物理内存地址上,多对一映射。意味着ZGC在虚拟内存空间中看到的地址空间比实际的堆内存容量更大。
  • 把染色指针中的标志位看做是地址的分段符,只要把这些不同的地址段映射到同一个物理地址空间就行了,经过多重映射转换后,就可以使用染色指针正常进行寻址了。
    • 标志位就是上图的Remapped,Marked1,Marked0。

内存多重映射

​ ZGC使用了内存多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,效果如图:

ZGC的多重映射只是它采用染色指针技术的伴生产物

染色指针的作用

  • 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。而Shenandoah需要等到更新阶段结束才能释放回收集中的Region,如果Region里面对象都存活的时候,需要1:1的空间才能完成收集。
  • 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。因为信息直接维护在指针中。
  • 染色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

ZGC的过程

  • 并发标记(Concurrent Mark):与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,**前后也要经过类似于G1、Shenandoah的初始标记和最终标记(ZGC中就是名字不同而已)的短暂的停顿**,整个标记阶段只会更新染色指针中的Marked 0、Marked 1标志位。

    • 停顿时间和堆大小无关,只和GC Roots数量有关。
    • 总结就是:并发标记阶段会有两个短暂STW。
    • ZGC只有短暂的STW,大部分的过程都是和应用线程并发执行,比如最耗时的并发标记和并发移动过程。
  • 并发预备重分配(Concurrent Prepare for Relocate):这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。

    • ZGC的重分配集只是决定里面的存活对象会被复制到其他的Region。不是为了效益回收。
    • JDK12的ZGC中开始支持的类卸载以及弱引用的处理,也是在这个阶段完成的。
  • 并发重分配(Concurrent Relocate): 重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。

    • ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。
    • ZGC的染色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢,而Shenandoah的Brooks转发指针是每次都会变慢。 一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉,因为可能还有访问在使用这个转发表。
    • 举例如:因为在标记和移动过程中,GC线程和应用线程是并发执行的,所以存在这种情况:对象A内部的引用所指的对象B在标记或者移动状态,为了保证应用线程拿到的B对象是对的,那么在读取B的指针时会经过一个 “load barriers” 读屏障,这个屏障可以保证在执行GC时,数据读取的正确性。
  • 并发重映射(Concurrent Remap):重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。

ZGC的优点

  • 低停顿,高吞吐量,ZGC收集过程中额外耗费的内存小。

    • 低停顿,几乎所有过程都是并发的,只有短暂的STW。
    • 内存小,ZGC没有写屏障,卡表之类的。
    • 吞吐量方面,在ZGC的‘弱项’吞吐量方面,因为和用户线程并发,还是有影响的。但是!但是!,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1。
  • G1通过写屏障维护记忆集,才能处理跨代指针,得以实现增量回收。记忆集占用大量内存,写屏障对正常程序造成额外负担。

  • 在多核处理器的某种架构下,ZGC优先在线程当前所处的处理器的本地内存上分配对象,以保证内存高效访问。

  • 并发停顿方面:ZGC只有短暂的STW,大部分的过程都是和应用线程并发执行,比如最耗时的并发标记和并发移动过程。

  • ZGC中没有引入分代,也就没有新生代和老年代的概念,只有一块一块的内存区域page,以page单位进行对象的分配和回收。

  • 并发的标记-整理算法。没有内存碎片。

ZGC的缺点

承受的对象分配速率不会太高,因为浮动垃圾。

  • ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。
  • 造成回收到的内存空间小于期间并发产生的浮动垃圾所占的空间。
  • ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。所以就不存在Young GC、Old GC,所有的GC行为都是Full GC。
  • ZGC目前只在Linux/x64上可用。以后可能会支持别的吧。也不算啥缺点。

解决办法

  • 增加堆容量大小,使得程序得到更多的喘息时间。治标不治本的方案。

  • 从根本上解决这个问题,还是需要引入分代收集。让新生对象在一个专门区域创建,然后专门针对这个区域进行更频繁的,更快的收集。

官方测试数据

停顿时间

在ZGC的停顿时间测试上,和其他收集器相比完全不在一个数量级,如图:

吞吐量

ZGC的“弱项”吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1,如图:

ZGC的使用

编译

1
2
3
4
$ hg clone https://wiki.openjdk.java.net/display/hg.openjdk.java.net/jdk/jdk
$ cd jdk
$ sh configure
$ make images

如果正在编译的版本是 11.0.0, 11.0.1 or 11.0.2,必须加上配置参数--with-jvm-features=zgc开启ZGC的编译,在11.0.3或者12之后,可以忽略这个参数,已经默认支持。

编译结束之后,你会得到一个完整的JDK,在Linux中,可以在下面目录中找到这个新的JDK

1
./build/linux-x86_64-normal-server-release/images/jdk

可以进入bin文件夹,执行 ./java -version 验证一下。

使用

编译完成之后,已经迫不及待的想试试ZGC,需要配置以下JVM参数,才能使用ZGC.

1
2
3
4
-XX:+UnlockExperimentalVMOptions #解锁任何额外的隐藏参数。
-XX:+UseZGC # 使用ZGC
-Xmx10g # 设置堆大小
-Xlog:gc* # 打印GC日志
参数说明
Heap Size

通过-Xmx10g进行设置。

​ -Xmx是ZGC收集器中最重要的调优选项,大大解决了程序员在JVM参数调优上的困扰。ZGC是一个并发收集器,必须要设置一个最大堆的大小,应用需要多大的堆,主要有下面几个考量:

  • 对象的分配速率,要保证在GC的时候,堆中有足够的内存分配新对象。
  • 一般来说,给ZGC的内存越多越好,但是也不能浪费内存,所以要找到一个平衡。
Concurrent GC Threads

通过-XX:ConcGCThread = 4进行设置。

​ 并发执行的GC线程数,如果没有设置,在JVM启动的时候会根据CPU的核数计算出一个合理的数量,默认是核数的12.5%,但是根据应用的特性,可以通过手动设置调整。

​ 因为在并发标记和并发移动时,GC线程和应用线程是并发执行的,所以存在抢占CPU的情况,对于一些对延迟比较敏感的应用,这个并发线程数就不能设置的过大,不然会降低应用的吞吐量,并有可能增加应用的延迟,因为GC线程占用了太多的CPU,但是如果设置的太小,就有可能对象的分配速率比垃圾收集的速率来的大,最终导致应用线程停下来等GC线程完成垃圾收集,并释放内存。

​ 一般来说,如果低延迟对应用程序很重要,那么不要这个值不要设置的过于大,理想情况下,系统的CPU利用率不应该超过70%。

Parallel GC Threads

通过-XX:ParallelGCThreads = 20

​ 当对GC Roots进行标记和移动时,需要进行STW,这个过程会使用ParallelGCThreads个GC线程进行并行执行。

ParallelGCThreads默认为CPU核数的60%,为什么可以这么大?
因为这个时候,应用线程已经完全停下来了,所以要用尽可能多的线程完成这部分任务,这样才能让STW尽可能的短暂。

查看GC日志

垃圾回收时机

在ZGC算法中,并没有分代的概念,所以就不存在Young GC、Old GC,所有的GC行为都是Full GC。

触发ZGC的逻辑,位于zDirector.cpp文件。虚拟机启动时,会启动一个线程执行如下逻辑:

1
2
3
4
5
6
7
8
9
10
void ZDirector::run_service() {
// Main loop
while (_metronome.wait_for_tick()) {
sample_allocation_rate();
const GCCause::Cause cause = make_gc_decision();
if (cause != GCCause::_no_gc) {
ZCollectedHeap::heap()->collect(cause);
}
}
}

其中_metronome.wait_for_tick()每间隔100ms返回一次,意味着每100ms执行一次make_gc_decision(),决定是否执行ZGC。

触发策略

make_gc_decision()中提供了4种策略,只要满足其中1个策略就可以触发ZGC。

rule_timer

​ 第一个策略,从行为表现上,我把它叫做是周期性GC,默认是不生效的,但是如果配置 -XX:ZCollectionInterval=1(单位是秒),那么每隔1s,就会执行一次ZGC,太暴力了。

rule_warmup

​ JVM启动之后,如果一直没有发生过GC,那么会在堆内存使用超过10%、20%、30%时,分别触发一次GC,这样做是为了收集一些GC相关的数据,为后面的条件规则提供数据支撑。

rule_allocation_rate

​ 根据对象分配速率决定是否GC。
​ 如果当前的可用堆内存,根据估计出来的对象最大分配速率,很快会被耗尽,则执行一次GC,这种策略一般在qps很高、对象分配很快时会被触发。

rule_proactive

​ 这个策略是积极主动型的。
​ 如果能够接受因为GC引起的应用吞吐量下降,那么就触发GC,这个策略允许我们降低堆内存,并且在堆内存还有很多剩余空间时,执行引用处理,具体的条件是:

  1. 自从上次GC之后,堆的使用量至少涨了10%
  2. 自从上次GC之后,已经过去5分钟没有发生GC

小结

这有助于在对象分配率非常低的应用程序时避免多余的GC。

​ 这4种都是在还有空闲内存的时候就执行GC的策略,那如果垃圾回收的速度赶不上对象分配的速率,怎么办?
​ 这个时候,分配对象的应用线程,只能停下来,等待垃圾对象的回收,如果回收掉一部分内存,就可以直接拿到用了,不需要等垃圾回收执行完成。

低延迟垃圾收集器的思考

衡量垃圾收集器的三项最重要的指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟(Latency),三者共同构成了一个“不可能三角”。在这三者中,低延迟的重要性日益显著。因为硬件性能增长,收集器可以多用点内存,硬件性能增长也可以有助于降低收集器运行时对引用程序的影响。另一方面,内存的扩大会对延迟有影响,因为堆内存容量变大了呀,要回收更大的内存空间,延迟自然可能会变多。所以啊,我觉得OS才是王道。没了好的OS,啥都是白说。

  • Shenandoah和ZGC之所以被称为低延迟GC,因为它几乎整个工作过程全部都是并发的,只有初始标记、最终标记这些阶段有短暂的停顿,这部分停顿的时间基本上是固定的,与堆的容量、堆中对象的数量没有正比例关系。
  • 实际上,它们都可以在任意可管理的(譬如现在ZGC只能管理4TB以内的堆)堆容量下,实现垃圾收集的停顿都不超过十毫秒的目标。这两款目前仍处于实验状态的收集器,被官方命名为“低延迟垃圾收集器”。

评论