[toc]
1. 前言
本文为了衔接公众号中的前几篇ZGC相关的文章,继续探索ZGC在HBase集群中真实的GC表现能力,并把其与G1 GC做一个简单的对比,验证ZGC是否真如传言中的那般,令人叹为观止。
在前几篇文章中,我为大家分享了使用JDK15编译HBase(和CDH HBase)的踩坑记录和ZGC在HBase集群中的配置方法,有对ZGC感兴趣的小伙伴,也可以亲自动手尝试一下,体验体验这个来自未来的技术。
2. GC之痛
很多低延迟高可用的Java服务的系统可用性饱受GC停顿的困扰,例如:HBase,GC停顿是影响HBase读写延时的一大元凶。GC停顿是指垃圾回收期间的STW(Stop The World),当STW发生的时候,所有应用线程停止活动,等待GC停顿的结束。
我们线上HBase集群的GC优化经历过CMS和G1,G1 GC调优之后,在很长的一段时间之内,是可以满足我们线上接口对HBase查询延时的需求。但更高敏感的业务上线之后,我们的集群便立马捉襟见肘,例如:我们的某些核心业务要求100ms内返回结果,并且可用性要达到99.9%甚至99.99%,但在各种各样因素的综合影响之下,我们的集群一直无法满足业务方的要求。
我们做过数据请求测试,持续用一个rowKey来循环请求HBase集群,统计查询耗时,一直无法满足99.9%的查询目标,而且,在耗时查询发生的相同时间点,也伴随着GC的发生。单次GC的停顿,可能是导致我们在这种查询场景下,出现耗时查询的最大元凶。
降低单次GC的时间和降低GC发生的频率,可能会进一步提升我们集群的查询性能,出于这个目标,我们才开始了对ZGC的探索之路。
3. CMS和G1停顿时间瓶颈
介绍ZGC之前,先简单回顾下CMS和G1的GC过程,以及停顿时间的瓶颈。CMS新生代的Young GC、G1和ZGC都基于标记-复制算法,但算法具体实现的不同就导致了巨大的性能差异。
标记-复制算法应用在CMS新生代(ParNew是CMS默认的新生代垃圾回收器)和G1垃圾回收器中。标记-复制算法可以分为三个阶段:
- 标记阶段,即从GC Roots集合开始,标记活跃对象;
- 转移阶段,即把活跃对象复制到新的内存地址上;
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
下面以G1为例,通过G1中标记-复制算法过程(G1的Young GC和Mixed GC均采用该算法),分析G1停顿耗时的主要瓶颈。G1垃圾回收周期如下图所示:
G1的混合回收过程可以分为标记阶段、清理阶段和复制阶段。
标记阶段停顿分析
- 初始标记阶段:初始标记阶段是指从GC Roots出发标记全部直接子节点的过程,该阶段是STW的。由于GC Roots数量不多,通常该阶段耗时非常短。
- 并发标记阶段:并发标记阶段是指从GC Roots开始对堆中对象进行可达性分析,找出存活对象。该阶段是并发的,即应用线程和GC线程可以同时活动。并发标记耗时相对长很多,但因为不是STW,所以我们不太关心该阶段耗时的长短。
- 再标记阶段:重新标记那些在并发标记阶段发生变化的对象。该阶段是STW的。
清理阶段停顿分析
- 清理阶段清点出有存活对象的分区和没有存活对象的分区,该阶段不会清理垃圾对象,也不会执行存活对象的复制。该阶段是STW的。
复制阶段停顿分析
- 复制算法中的转移阶段需要分配新内存和复制对象的成员变量。转移阶段是STW的,其中内存分配通常耗时非常短,但对象成员变量的复制耗时有可能较长,这是因为复制耗时与存活对象数量与对象复杂度成正比。对象越复杂,复制耗时越长。
四个STW过程中,初始标记因为只标记GC Roots,耗时较短。再标记因为对象数少,耗时也较短。清理阶段因为内存分区数量少,耗时也较短。转移阶段要处理所有存活的对象,耗时会较长。因此,G1停顿时间的瓶颈主要是标记-复制中的转移阶段STW。为什么转移阶段不能和标记阶段一样并发执行呢?主要是G1未能解决转移过程中准确定位对象地址的问题。
G1的Young GC和CMS的Young GC,其标记-复制全过程STW,不再详细阐述,这里列举几篇范欣欣大神写的文章。
HBase GC的前生今世 – 身世篇
HBase GC的前生今世 – 演进篇
HBase最佳实践-CMS GC调优
4. ZGC 原理
4.1 全并发的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的,且停顿时间随存活对象的大小增加而增加。
4.2 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位地址空间的第041位,而第4245位存储元数据,第47~63位固定为0。
ZGC将对象存活信息存储在42~45位中,这与传统的垃圾回收并将对象存活信息放在对象头中完全不同。
读屏障
| 读屏障是JVM向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。需要注意的是,仅“从堆中读取对象引用”才会触发这段代码。
读屏障示例:
ZGC中读屏障的代码作用:在对象标记和转移过程中,用于确定对象的引用地址是否满足条件,并作出相应动作。
ZGC并发处理演示
接下来详细介绍ZGC一次垃圾回收周期中地址视图的切换过程:
- 初始化:ZGC初始化之后,整个内存空间的地址视图被设置为Remapped。程序正常运行,在内存中分配对象,满足一定条件后垃圾回收启动,此时进入标记阶段。
- 并发标记阶段:第一次进入标记阶段时视图为M0,如果对象被GC标记线程或者应用线程访问过,那么就将对象的地址视图从Remapped调整为M0。所以,在标记阶段结束之后,对象的地址要么是M0视图,要么是Remapped。如果对象的地址是M0视图,那么说明对象是活跃的;如果对象的地址是Remapped视图,说明对象是不活跃的。
- 并发转移阶段:标记结束后就进入转移阶段,此时地址视图再次被设置为Remapped。如果对象被GC转移线程或者应用线程访问过,那么就将对象的地址视图从M0调整为Remapped。
其实,在标记阶段存在两个地址视图M0和M1,上面的过程显示只用了一个地址视图。之所以设计成两个,是为了区别前一次标记和当前标记。即第二次进入并发标记阶段后,地址视图调整为M1,而非M0。
着色指针
和读屏障技术
不仅应用在并发转移阶段,还应用在并发标记阶段:将对象设置为已标记,传统的垃圾回收器需要进行一次内存访问,并将对象存活信息放在对象头中;而在ZGC中,只需要设置指针地址的第42~45位即可,并且因为是寄存器访问,所以速度比访问内存更快。
5. 初探ZGC在HBase中的GC表现
ZGC相关的调优参数究竟该如何配置,实在无法提供出来一个标准的答案。我们参考美团ZGC实践中的一个案例,来针对我所用的HBase集群来进行ZGC相关参数的设置,然后在YCSB的压测场景下,收集、分析ZGC的GC日志。
参考的文章链接是,其中上文有关G1和ZGC的理论知识剖析也是摘选自这篇文章。
https://www.secpulse.com/archives/137305.html
新一代垃圾回收器ZGC的探索与实践——美团
https://www.secpulse.com/archives/137305.html
此次测试使用的HBase集群由三个节点组成,物理机配置:24核,内存370G,其中为HBase分配了31G的堆内存。HBase的版本是cdh-6.3.2-hbase2.1.0。压测时使用的工具是阿里的AHBench(基于YCSB包装了一层,方便对YCSB测试结果数据的收集和汇总),并保证在测试期间,唯一的变量是GC的使用方式。
YCSB压测的场景是:数据量一个亿,分别在使用G1和ZGC的场景下跑AHBench的full_test,然后对测试期间G1和ZGC的详细gc日志生成GC指标分析报告。
RegionServer重要配置参数示例:
-Xms31G -Xmx31G
-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=/var/log/hbase/region-server-zgc-%t.log:time,tid,tags:filecount=5,filesize=500m
--illegal-access=deny
--add-exports=java.base/jdk.internal.access=ALL-UNNAMED
--add-exports=java.base/jdk.internal=ALL-UNNAMED
--add-exports=java.base/jdk.internal.misc=ALL-UNNAMED
--add-exports=java.base/sun.security.pkcs=ALL-UNNAMED
--add-exports=java.base/sun.nio.ch=ALL-UNNAMED
--add-opens=java.base/java.nio=ALL-UNNAMED
--add-opens java.base/jdk.internal.misc=ALL-UNNAMED
-Dorg.apache.hbase.thirdparty.io.netty.tryReflectionSetAccessible=true
-Xms -Xmx:堆的最大内存和最小内存,这里都设置为31G,程序的堆内存将保持31G不变。
-XX:ReservedCodeCacheSize -XX:InitialCodeCacheSize: 设置CodeCache的大小, JIT编译的代码都放在CodeCache中,一般服务64m或128m就已经足够。这里设置的数值也只是参考了美团ZGC实践示例。
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC:启用ZGC的配置。
-XX:ConcGCThreads:并发回收垃圾的线程。默认是总核数的12.5%,8核CPU默认是1。调大后GC变快,但会占用程序运行时的CPU资源,吞吐会受到影响。
这个参数的设置效果,在CDH的CPU指标监控图例中就可以明显看得到。
在ZGC测试期间,我们观察到CPU的消耗较以往显著增加,尤其是在集群高负载的情况下格外明显,而其他使用G1 GC的HBase集群中的CPU负载趋势则如下图所示:
-XX:ParallelGCThreads:STW阶段使用线程数,默认是总核数的60%。
-XX:ZCollectionInterval:ZGC发生的最小时间间隔,单位秒,该参数的设置效果在CDH中的GC时间监控图例中得到体现。
正常情况下GC发生的频次,时间间隔均匀,正是两分钟(120s)。-XX:ZCollectionInterval=120。而且,在集群高负载的情况下,ZGC的GC时间可以达到分钟级别,这也正印证了,ZGC全程并发,不会影响到你的应用进程。因为,如果是秒级别甚至分钟级别的STW,你的业务方早已提刀而来。G1 GC场景下,GC的消耗时间趋势如下图:
我们在进行G1 GC调优设置参数的时候,期望的GC时间是在100ms,但真实的情况是不管如何调整,GC的耗时远超100ms。
200ms GC耗时均值中的STW的时间占比,将直接影响着HBase集群查询延时的占比。
-XX:ZAllocationSpikeTolerance:ZGC触发自适应算法的修正系数,默认2,数值越大,越早的触发ZGC。
-XX:+UnlockDiagnosticVMOptions -XX:-ZProactive:是否启用主动回收,默认开启,这里的配置表示关闭。
-Xlog:设置GC日志中的内容、格式、位置以及每个日志的大小。
理解ZGC的触发时机
相比于CMS和G1的GC触发机制,ZGC的GC触发机制有很大不同。ZGC的核心特点是并发,GC过程中一直有新的对象产生。如何保证在GC完成之前,新产生的对象不会将堆占满,是ZGC参数调优的第一大目标。因为在ZGC中,当垃圾来不及回收将堆占满时,会导致正在运行的线程停顿,持续时间可能长达秒级之久。
ZGC有多种GC触发机制,总结如下:
- 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。我们应当避免出现这种触发方式。日志中关键字是“Allocation Stall”。
- 基于分配速率的自适应算法:最主要的GC触发方式,其算法原理可简单描述为”ZGC根据近期的对象分配速率以及GC时间,计算出当内存占用达到什么阈值时触发下一次GC”。自适应算法的详细理论可参考彭成寒《新一代垃圾回收器ZGC设计与实现》一书中的内容。通过ZAllocationSpikeTolerance参数控制阈值大小,该参数默认2,数值越大,越早的触发GC。我们通过调整此参数解决了一些问题。日志中关键字是“Allocation Rate”。
- 基于固定时间间隔:通过ZCollectionInterval控制,适合应对突增流量场景。流量平稳变化时,自适应算法可能在堆使用率达到95%以上才触发GC。流量突增时,自适应算法触发的时机可能会过晚,导致部分线程阻塞。我们通过调整此参数解决流量突增场景的问题,比如定时活动、秒杀等场景。日志中关键字是“Timer”。
- 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是ZGC自行算出来的时机,我们的服务因为已经加了基于固定时间间隔的触发机制,所以通过-ZProactive参数将该功能关闭,以免GC频繁,影响服务可用性。日志中关键字是“Proactive”。
- 预热规则:服务刚启动时出现,一般不需要关注。日志中关键字是“Warmup”。
- 外部触发:代码中显式调用System.gc()触发。日志中关键字是“System.gc()”。
- 元数据分配触发:元数据区不足时导致,一般不需要关注。日志中关键字是“Metadata GC Threshold”。
更细致的GC 日志分析,可以参考美团ZGC实践那篇文章中的分析思路。
6. ZGC与G1 GC的数据统计对比
我们收集ZGC与G1 GC在相同压测场景下生成的详细gc日志,上传到https://gceasy.io/之后,分别得出的GC报告如下图所示:
6.1 G1
6.2 ZGC
仅从这两个GC报告对比来看,ZGC确实做到了几乎百分之百的GC时间在10ms内。
6.3 G1与ZGC吞吐量相关指标比较
以下图例记录了相同YCSB压测场景下,G1与ZGC各项指标比较。
读写吞吐量指标比较
读写平均延时指标比较
G1与ZGCp999延时指标比较
以上指标对比,在不同的压测场景,不同的集群环境之下的结果可能会有所不同,不能代表线上真正的表现情况,希望大家如感兴趣,可以亲自尝试测试一波。
7. 总结
本篇文章为大家分享了ZGC的特点,简单记录了ZGC的一些核心技术,如着色指针、读屏障等。并在相同的YCSB压测场景下,分别测试了G1和ZGC在真实的应用环境中的GC的表现能力,并得出GC分析报告,从GC停顿时间和读写吞吐、延迟等方面,做了比较详细的对比,然后初步验证了以下几个观点:
- ZGC 可以达到几乎百分百GC耗时在10ms内的目标
- 通过设置参数,可以主动控制ZGC的GC发生频率
- 与G1相比,ZGC在GC过程中会消耗更多的CPU
有关GC更深入的理解和使用,甚至进一步调优ZGC的表现能力,这将在后续的文章中继续和大家探讨。同时,本人对GC的认知有限,文中个别地方描述不恰当,或对实验数据心存异议的伙伴,还请及时告知。
8. 参考链接
https://www.secpulse.com/archives/137305.html
http://hbasefly.com/2016/05/21/hbase-gc-1/
http://hbasefly.com/2016/05/29/hbase-gc-2/
http://hbasefly.com/2016/08/09/hbase-cms-gc/