谈谈zgc

1,746 阅读11分钟

这是我参与8月更文挑战的第6天,活动详情查看:8月更文挑战

什么是ZGC

ZGC是一款在JDK11中新加入的具有实验性质的低延迟垃圾收集器,目前仅支持Linux/x86-64。ZGC收集器是一款基于Region内存布局的,(暂时)不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的一款垃圾收集器。其设计目标包括:

停顿时间不超过10ms;

停顿时间不会随着堆的大小,或者活跃对象的大小而增加;

支持8MB~4TB级别的堆(未来支持16TB)。

ZGC原理

全并发的ZGC与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。 ZGC只有三个STW阶段:初始标记,再标记,初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。

ZGC核心技术

ZGC通过染色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即染色指针。下面介绍染色指针和读屏障技术细节。

染色指针

我们都知道jvm的垃圾回收器回收过程中都涉及到对对象进行标记,只有标记过的对象才是存活的对象,未被标记的对象将在GC中被回收掉。zgc的对象标记实现用的则是染色指针技术。(传统的GC都是将标记记录在对象头中,G1则是将标记记录在与对象独立的数据结构上-----Rset)

话不多说先看一张图:

64.PNG

其中,0~4TB 对应Java堆,4TB ~ 8TB 称为M0地址空间,8TB ~ 12TB 称为M1地址空间,12TB ~ 16TB 预留未使用,16TB ~ 20TB 称为Remapped空间。

当应用程序创建对象时,首先在堆空间申请一个虚拟地址,但该虚拟地址并不会映射到真正的物理地址。ZGC同时会为该对象在M0、M1和Remapped地址空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址,但这三个空间在同一时间有且只有一个空间有效。ZGC之所以设置三个虚拟地址空间,是因为它使用“空间换时间”思想,去降低GC停顿时间。“空间换时间”中的空间是虚拟空间,而不是真正的物理空间。

与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第041位,而第4245位存储元数据,第47~63位固定为0。 染色指针优点如下: 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指令向该Region的引用都被修正后才能清理。

染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是在写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,显然就可以省去一些专门的记录操作。

染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

读屏障

读屏障相比较来说比较好理解,传统gc使用的都是写屏障,去解决标记对象时漏标的问题,这部分会涉及三色标记和漏标的知识点,网上文章比较多,笔者就不过多阐述。ZGC则是使用的读屏障,在访问对象之前我们只要判断对象的引用标志位,对象是否是处于移动后,不需要整个gc过程结束,这样可以大大减少停顿时间。每次访问对象,因为由染色指针的技术,也可以在非常段的时间内判断对象的标志,所以使用读屏障并不会影响性能。

ZGC的执行流程

1.开始标记(STW),找出根节点

2.并行标记,找出垃圾

3.处理边缘情况(STW)

4.重定位

5.重映射(这一步不算是单独的一步,一部分重映射会在下一次GC开始的时候和第一步一起执行,另一部分就是应用线程协助完成的)

其执行流程如下图:

zgc周期.PNG

1.第一次ZGC的时候,ZGC会STW,根据线程栈和常量池查找到所有的根节点

2.恢复应用线程运行,把根节点分配给GC线程,每个线程只标记自己负责的根线程

3.并发的去寻找可达对象,如果对象的某个属性是引用类型并且指针显示它还没有开始标记,会把指向该引用类型的指针置为开始标记

4.标记该对象完成后会把指针置为已标记完成的状态

5.当所有的对象都标记完成后,开始进行重定位,将对象A移到新的region中,并且把旧的地址和新的地址做一个映射(Forwarding Tables,A'->A ),注意:此时指向该对象的指针仍然是开始重定位状态,如果此时应用程序访问原本指向A的指针,会根据映射修改指针的地址为新的地址,并且修改指针状态为重定位完成,这里就体现了应用程序帮助ZGC完成GC过程的一方面。

6.重定位完成后整个GC过程基本完结。

7.第二次和以后的GC,在查到到根节点根据指针获取对象时,如果指针的状态是开始重定位状态,会根据Forwarding Tabels映射修改指针。

需要注意并发标记阶段也是上一次GC的对象重定位阶段。

ZGC重要配置参数

重要参数配置样例:

-Xms10G -Xmx10G

-XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m

-XX:+UnlockExperimentalVMOptions -XX:+UseZGC

-XX:ConcGCThreads=2 -XX:ParallelGCThreads=6

-XX:ZCollectionInterval=120 -XX:ZAllocationSpikeTolerance=5

-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive

-Xlog:safepoint,classhisto*=trace,age*,gc*=info:file=/opt/logs/logs/gc-%t.log:time,tid,tags:filecount=5,filesize=50m

-Xms -Xmx:堆的最大内存和最小内存,这里都设置为10G,程序的堆内存将保持10G不变。 -XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize:设置CodeCache的大小, JIT编译的代码都放在CodeCache中,一般服务64m或128m就已经足够。 -XX:+UnlockExperimentalVMOptions -XX:+UseZGC:启用ZGC的配置。 -XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核CPU默认是1。调大后GC变快,但会占用程序运行时的CPU资源,吞吐会受到影响。 -XX:ParallelGCThreads:STW阶段使用线程数,默认是总核数的60%。 -XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒。 -XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。 -XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。 -Xlog:设置GC日志中的内容、格式、位置以及每个日志的大小。

ZGC的触发

ZGC的核心特点是并发,GC过程中一直有新的对象产生。ZGC有多种GC触发机制,总结如下:

预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。

外部触发:代码中显式调用System.gc()触发。 日志中关键字是“System.gc()”。

元数据分配触发:元数据区不足时导致,一般不需要关注。 日志中关键字是“Metadata GC Threshold”。

阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。

基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。

基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。

主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。 日志中关键字是“Proactive”。

ZGC存在的问题

ZGC在JDK15才达到production-ready,而JDK15不是长期支持的JDK版本,因此部署到生产环境中有困难。ZGC在JDK11上只是experimental的,只支持x64,想用在Arm/Mac/Windows上还需要用JDK15。

ZGC运行的时候能观察到下面的一些问题: 单代GC吞吐低:最显著的问题是Concurrent Mark阶段都需要全堆标记(耗时长),导致回收速度跟不上对象分配速度:

会出现分配停顿(Allocation Stall),需要启动一次新的ZGC,这次ZGC周期内所有应用线程都要暂停下来;

最坏情况甚至发生OOM:Concurrent Relocate阶段如果剩余的空间依然不够,就会抛出OOM;

GC线程并发运行导致CPU偏高;

由于ZGC采用colored pointer技术,因此不支持UseCompressedOops(相比之下ShenandoahGC却能支持),一定程度上影响小堆(32GB以下)的性能;不过JDK15后可以支持UseCompressedOops关闭时依然开启UseCompressedClassPointers,这样一定程度上缓解了性能上的缺憾;

对象分配卡顿,除了ZGC的暂停阶段之外,还受到下面的一些因素的影响:Page Cache Flush问题影响分配速度:ZGC把堆分为不同大小的page(对应G1的Region)——small/medium/large page(不同大小的object分配到不同类型的page中),如果各种大小对象分配速度不稳定(比如medium大小的object突然变多,那么就需要把large/small page转换成medium page,比较耗时),JDK15 production-ready之后有所缓解;

只有单个medium page:应用线程较多的情形下,如果多个线程同时分配medium大小的object且当前medium page空闲大小不够时,那么就会同时请求分配新的medium page,undo多余的分配会延迟分配,还有可能导致上述Page Cache Flush发生;

RSS特别高,可达3倍Xmx,这是由ZGC的multi-mapping机制导致的。

总结

经过此次对ZGC的学习,对zgc的染色指针,读屏障有了更深刻的理解,对生产环境使用ZGC也有了更多的信心,只有清楚其原理和构成,才可以放心的使用。