其他分享
首页 > 其他分享> > ZGC

ZGC

作者:互联网

ZGC(The Z Garbage Collector)是JDK 11中推出的一款低延迟垃圾回收器,它的设计目标包括:

从设计目标来看,我们知道ZGC适用于大内存低延迟服务的内存管理和回收。本文主要介绍ZGC在低延时场景中的应用和卓越表现,文章内容主要分为四部分:

GC之痛

很多低延迟高可用Java服务的系统可用性经常受GC停顿的困扰。GC停顿指垃圾回收期间STW(Stop The World),当STW时,所有应用线程停止活动,等待GC停顿结束。以美团风控服务为例,部分上游业务要求风控服务65ms内返回结果,并且可用性要达到99.99%。但因为GC停顿,我们未能达到上述可用性目标。当时使用的是CMS垃圾回收器,单次Young GC 40ms,一分钟10次,接口平均响应时间30ms。通过计算可知,有(40ms + 30ms) * 10次 / 60000ms = 1.12%的请求的响应时间会增加0 ~ 40ms不等,其中30ms * 10次 / 60000ms = 0.5%的请求响应时间会增加40ms。可见,GC停顿对响应时间的影响较大。为了降低GC停顿对系统可用性的影响,我们从降低单次GC时间和降低GC频率两个角度出发进行了调优,还测试过G1垃圾回收器,但这三项措施均未能降低GC对服务可用性的影响。

CMS与G1停顿时间瓶颈

在介绍ZGC之前,首先回顾一下CMS和G1的GC过程以及停顿时间的瓶颈。CMS新生代的Young GC、G1和ZGC都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。

标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:

下面以G1为例,通过G1中标记-复制算法过程(G1的Young GC和Mixed GC均采用该算法),分析G1停顿耗时的主要瓶颈。G1垃圾回收周期如下图所示:

G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段。

标记阶段停顿分析

清理阶段停顿分析

复制阶段停顿分析

四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。

G1的Young GC和CMS的Young GC,其标记-复制全过程STW,这里不再详细阐述。

ZGC原理

全并发的ZGC

与CMS中的ParNew和G1类似,ZGC也采用标记-复制算法,不过ZGC对该算法做了重大改进:ZGC在标记、转移和重定位阶段几乎都是并发的,这是ZGC实现停顿时间小于10ms目标的最关键原因。

ZGC垃圾回收周期如下图所示:

ZGC只有三个STW阶段:初始标记再标记初始转移。其中,初始标记和初始转移分别都只需要扫描所有GC Roots,其处理时间和GC Roots的数量成正比,一般情况耗时非常短;再标记阶段STW时间很短,最多1ms,超过1ms则再次进入并发标记阶段。即,ZGC几乎所有暂停都只依赖于GC Roots集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与ZGC对比,G1的转移阶段完全STW的,且停顿时间随存活对象的大小增加而增加。

ZGC关键技术

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

着色指针

着色指针是一种将信息存储在指针中的技术。

ZGC仅支持64位系统,它把64位虚拟地址空间划分为多个子空间,如下图所示:

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

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

与上述地址空间划分相对应,ZGC实际仅使用64位地址空间的第0~41位,而第42~45位存储元数据,第47~63位固定为0。

ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。

读屏障

读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。

读屏障示例:

Object o = obj.FieldA   // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o  // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i =  obj.FieldB  //无需加入屏障,因为不是对象引用

ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。

ZGC并发处理演示

接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:

其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。也即,第二次进入并发标记阶段后,地址视图调整为M1,而非M0。

着色指针和读屏障技术不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。

ZGC调优实践

ZGC不是“银弹”,需要根据服务的具体特点进行调优。网络上能搜索到实战经验较少,调优理论需自行摸索,我们在此阶段也耗费了不少时间,最终才达到理想的性能。本文的一个目的是列举一些使用ZGC时常见的问题,帮助大家使用ZGC提高服务可用性。

调优基础知识

理解ZGC重要配置参数

以我们服务在生产环境中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触发时机

相比于CMS和G1的GC触发机制,ZGC的GC触发机制有很大不同。ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。

ZGC有多种GC触发机制,总结如下:

理解ZGC日志

一次完整的GC过程,需要注意的点已在图中标出。

注意:该日志过滤了进入安全点的信息。正常情况,在一次GC过程中还穿插着进入安全点的操作。

GC日志中每一行都注明了GC过程中的信息,关键信息如下:

日志中内容较多,关键点已用红线标出,含义较好理解,更详细的解释大家可以自行在网上查阅资料。

理解ZGC停顿原因

我们在实战过程中共发现了6种使程序停顿的场景,分别如下:

调优案例

我们维护的服务名叫Zeus,它是美团的规则平台,常用于风控场景中的规则管理。规则运行是基于开源的表达式执行引擎Aviator。Aviator内部将每一条表达式转化成Java的一个类,通过调用该类的接口实现表达式逻辑。

Zeus服务内的规则数量超过万条,且每台机器每天的请求量几百万。这些客观条件导致Aviator生成的类和方法会产生很多的ClassLoader和CodeCache,这些在使用ZGC时都成为过GC的性能瓶颈。接下来介绍两类调优案例。

内存分配阻塞,系统停顿可达到秒级

案例一:秒杀活动中流量突增,出现性能毛刺

日志信息:对比出现性能毛刺时间点的GC日志和业务日志,发现JVM停顿了较长时间,且停顿时GC日志中有大量的“Allocation Stall”日志。

分析:这种案例多出现在“自适应算法”为主要GC触发机制的场景中。ZGC是一款并发的垃圾回收器,GC线程和应用线程同时活动,在GC过程中,还会产生新的对象。GC完成之前,新产生的对象将堆占满,那么应用线程可能因为申请内存失败而导致线程阻塞。当秒杀活动开始,大量请求打入系统,但自适应算法计算的GC触发间隔较长,导致GC触发不及时,引起了内存分配阻塞,导致停顿。

解决方法:

(1)开启”基于固定时间间隔“的GC触发机制:-XX:ZCollectionInterval。比如调整为5秒,甚至更短。
(2)增大修正系数-XX:ZAllocationSpikeTolerance,更早触发GC。ZGC采用正态分布模型预测内存分配速率,模型修正系数ZAllocationSpikeTolerance默认值为2,值越大,越早的触发GC,Zeus中所有集群设置的是5。

案例二:压测时,流量逐渐增大到一定程度后,出现性能毛刺

日志信息:平均1秒GC一次,两次GC之间几乎没有间隔。

分析:GC触发及时,但内存标记和回收速度过慢,引起内存分配阻塞,导致停顿。

解决方法:增大-XX:ConcGCThreads, 加快并发标记和回收速度。ConcGCThreads默认值是核数的1/8,8核机器,默认值是1。该参数影响系统吞吐,如果GC间隔时间大于GC周期,不建议调整该参数。

GC Roots 数量大,单次GC停顿时间长

案例三: 单次GC停顿时间30ms,与预期停顿10ms左右有较大差距

日志信息:观察ZGC日志信息统计,“Pause Roots ClassLoaderDataGraph”一项耗时较长。

分析:dump内存文件,发现系统中有上万个ClassLoader实例。我们知道ClassLoader属于GC Roots一部分,且ZGC停顿时间与GC Roots成正比,GC Roots数量越大,停顿时间越久。再进一步分析,ClassLoader的类名表明,这些ClassLoader均由Aviator组件生成。分析Aviator源码,发现Aviator对每一个表达式新生成类时,会创建一个ClassLoader,这导致了ClassLoader数量巨大的问题。在更高Aviator版本中,该问题已经被修复,即仅创建一个ClassLoader为所有表达式生成类。

解决方法:升级Aviator组件版本,避免生成多余的ClassLoader。

案例四:服务启动后,运行时间越长,单次GC时间越长,重启后恢复

日志信息:观察ZGC日志信息统计,“Pause Roots CodeCache”的耗时会随着服务运行时间逐渐增长。

分析:CodeCache空间用于存放Java热点代码的JIT编译结果,而CodeCache也属于GC Roots一部分。通过添加-XX:+PrintCodeCacheOnCompilation参数,打印CodeCache中的被优化的方法,发现大量的Aviator表达式代码。定位到根本原因,每个表达式都是一个类中一个方法。随着运行时间越长,执行次数增加,这些方法会被JIT优化编译进入到Code Cache中,导致CodeCache越来越大。

解决方法:JIT有一些参数配置可以调整JIT编译的条件,但对于我们的问题都不太适用。我们最终通过业务优化解决,删除不需要执行的Aviator表达式,从而避免了大量Aviator方法进入CodeCache中。

值得一提的是,我们并不是在所有这些问题都解决后才全量部署所有集群。即使开始有各种各样的毛刺,但计算后发现,有各种问题的ZGC也比之前的CMS对服务可用性影响小。所以从开始准备使用ZGC到全量部署,大概用了2周的时间。在之后的3个月时间里,我们边做业务需求,边跟进这些问题,最终逐个解决了上述问题,从而使ZGC在各个集群上达到了一个更好表现。

升级ZGC效果

延迟降低

TP(Top Percentile)是一项衡量系统延迟的指标:TP999表示99.9%请求都能被响应的最小耗时;TP99表示99%请求都能被响应的最小耗时。

在Zeus服务不同集群中,ZGC在低延迟(TP999 < 200ms)场景中收益较大:

超低延迟(TP999 < 20ms)和高延迟(TP999 > 200ms)服务收益不大,原因是这些服务的响应时间瓶颈不是GC,而是外部依赖的性能。

吞吐下降

对吞吐量优先的场景,ZGC可能并不适合。例如,Zeus某离线集群原先使用CMS,升级ZGC后,系统吞吐量明显降低。究其原因有二:第一,ZGC是单代垃圾回收器,而CMS是分代垃圾回收器。单代垃圾回收器每次处理的对象更多,更耗费CPU资源;第二,ZGC使用读屏障,读屏障操作需耗费额外的计算资源。

总结

ZGC作为下一代垃圾回收器,性能非常优秀。ZGC垃圾回收过程几乎全部是并发,实际STW停顿时间极短,不到10ms。这得益于其采用的着色指针和读屏障技术。

转自:https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html

 

标签:标记,对象,停顿,线程,GC,ZGC
来源: https://www.cnblogs.com/ylz8401/p/15893744.html