查看原文
其他

真的,你很难想象,当我一次性把这三篇文章看完之后,到底有多爽!

why技术 2023-04-08

The following article is from Qunar技术沙龙 Author 余辉

“”你好呀,我是歪歪。

最近在“去哪儿技术沙龙”的公众号上看到一篇他们分享的关于一个底层核心业务系统的性能优化的文章。

文章从问题介绍出发,从现象观测、场景定位、案例解析、方向确定、方案对比到最后的方案落地,循序渐进,行文流畅,我也就看了个三五遍吧。

写得非常好,所以分享给大家。

在分享之前,我先自己多啰嗦几句。就当做个导读吧。

作者在文章中,通过一些监控的基础建设定位到问题的表象之后,给到了这样一个图片:

注意看我用蓝色框起来的这两个时间,我用一句话转述就是:Dubbo 调用的超时时间设置为 100ms,上游服务在 T1 时间已经超时了,但是下游服务在 T2 时间才收到请求。

那么这超时的 100ms 去哪里了呢,被谁给消耗了呢?

作者排查了框架配置(Dubbo 线程池参数调优)、GC 问题、网络问题几个方向,最后定位到是 GC 导致的问题。

在这部分中,对于网络问题的排查定位过程没有体现出来,框架配置方面也只是简单的一笔带过。

我能理解作者全文的重点在于针对 GC 方面优化,想要聚焦于此,所以并不想花太多笔墨在其他部分进行扩展。

但是,我个人反倒是觉得,在排查问题的过程中,在“错误”的方向,比如文中的配置和网络等其他非 GC 因素,在这些方向上进行过的探索同样重要,也可以进行较为详细的阐述。

在排查问题的过程中,其实“排除法”是一个非常好用的、行之有效的方案,而这个方案势必要求我们要在多个不同的方向中去探索。

虽然笔者在这篇文章中没有写网络和框架方面的问题,但是我看到这个文章的时候,想到了之前看过的这篇文章:《工商银行分布式服务 C10K 场景解决方案》

在一定程度上,这两篇文章形成了“互补”

同样是 Dubbo,同样是服务提供方导致的超时,同样是服务端业务处理非常快,同样是耗时发生在客服端发起请求到服务接收到请求之间。

但是这篇文章给出的解决方案,就是网络和框架两个方向,和 GC 毫无关系:

为什么?

为什么问题类似,但是方法却截然不同?

因为场景不一样。

“去哪儿技术沙龙”的场景是真实的业务场景,而“工商银行”则偏重的是验证框架在 C10K 场景下的表现。

但是我倒是觉得,这几个方案是可以综合在一起用的,都是能一定程度上缓解问题的,只不过是不同的场景下,同样的方案缓解的程度不一样而已。

另外,文中通过控制变量法,在 G1 和 ZGC 两种垃圾回收器之间做了对比这一段,虽然很简短,但是我特别喜欢,显得数据特别有说服力。

同时也让我再次想起了之前思考过的一个问题:越是基于底层的优化,越是高级的优化。

因为在上层运行的代码,不需要做任何改变(理想情况下),或者只需要做细微调整的情况下,仅仅是无缝替换了底层的某个模块或者组件,甚至是仅修改了某个参数,就带来了大幅度的性能提升,这才是高级的体现。

然后,文章中既然重点提到了 ZGC,那就不得不提起美团在 2020 年发布的这篇文章了:《新一代垃圾回收器ZGC的探索与实践》

工行的、美团的和这篇去哪儿网的,这三篇文章结合起来一起看:

你都想象不到我看得有多爽。

最后,实名羡慕“去哪儿”的同学,在通过一系列的场景分析、案例分析、数据分析之后,拥有一次宝贵的、在生产环境下升级使用 ZGC 的经历,也非常感谢能写成文章分享出来,造福大家。

好了,我就啰嗦到这里。

温馨提示:一次看不完,就先收藏起来吧。找一个“整块的时间,细细品,慢慢啃。

以下是“去哪儿技术沙龙”的正文,原文标题为:《ZGC 在去哪儿机票运价系统实践》。

一、背景

我所负责的机票运价系统是去哪儿机票底层最核心的价格计算和存储引擎,其提供的基础航班运价数据供去哪儿机票几乎所有业务系统使用,提供接口调用QPS 3万+,日均调用 7 亿次以上,平均响应时间小于 2 ms,是当之无愧的亿级流量、高并发、低延迟系统。

在这样的系统中,接口 P99 长尾往往会成为性能瓶颈。

这次在上游核心系统重构的契机下,发现调用运价接口超时率达到了 2% ,这表示有至少 2% 的用户体验将受到影响,作为机票最核心的服务之一,这种可用性是不能被接受的。

我们需要排查这个问题并做出优化。

如果你的项目对低延迟有很高的要求,这篇文章对你一定有帮助。

二、问题分析

分析业务监控指标,有无需求迭代影响

首先我们需要关注的是自己系统的监控指标,由于平均响应时长我们每天都会关注,且排查小概率超时问题,这里我们着重看接口时长 P99 ,可以看到下图近 3 个月指标没有明显的变化,维持在 8ms 左右。

值得注意的是,一般,随着需求迭代,系统整体会缓慢的向熵增的方向发展(系统复杂性、接口时长等),这种增长在监控指标表现上可能会比较平缓,平时维护过程中难以发现,等到发现的时候,往往就已经是一个大的故障了。

好的建议是,如果可以,我们应该在 P99 指标上设置一个稍灵敏的报警,能及时发现问题。

讨论接口超时率,先看超时时长设置

有些场景下我们自己服务的监控指标并不客观,我们通常会挑选几个核心上游的指标作为我们关注的核心指标,以评估我们服务接口真实的可用性。

这些指标我们每天都会查看,所以我们明确的知道这些上游在 200ms 的超时时间下,调用我们接口的超时率在千分之四。

那么这次百分之二的超时率是怎么造成的呢?

答案是超时时间设置为 100ms ,那么问题可能是 100ms 设置的不合理,最简单的办法是让调用方调整超时时间。

调用方A(超时时间:200ms)监控指标如下:

200ms 下超时率约为千分之三:3.93/1411=0.00278

调用方B(超时时间:100ms)监控指标如下:

100ms 下超时率接近百分之三:103/3498=0.029

可以看到超时时间从 200ms 降低为 100ms ,这里超时率几乎是升高了 10 倍(忽略了调用方本身的影响)

如果超时时间不能调整呢

如果可以让上游都调整超时时间到 200ms 甚至更长,那么这个问题很简单就能解决了,通常这也是最快解决问题的办法,可是这个方式并不优雅,增加超时时间会造成整个调用链路的响应时间增长,直接影响用户的交互体验。

更为优雅的方式是增加超时时间的前提下,再使用异步调用,通常 200ms 不会是整个链路的关键瓶颈,异步后既能解决超时问题也不会增加用户感知时长。

然而复杂系统的调用组合关系往往会非常复杂,各种依赖关系往往导致不能异步,像这次的案例中,我们的上游需要拿到其他调用的结果才能调用我们的服务,这样就没法异步了。

所以从调用方的角度,迫切的希望我们能降低服务 P99 响应时长,提高服务的可用性。

从我们自己的服务监控指标来看,我们服务的平均时间才不到 2ms ,P99 也仅仅只有 8ms 。

你可能关注到一个问题:

为什么我们服务提供方记录的监控指标 P99 才 8ms ,而调用方的 P98 就达到了 100ms ,这中间到底经历了什么,我们也非常好奇,于是我们需要找足够的超时的 case 用于分析。

全链路追踪,超时 case 一览无遗

通过 dubbo 的 access 日志,我们可以很快找到这些超时的 case ,然后借助去哪儿的中间件-全链路追踪系统(QTRACER),可以看到链路的调用全过程,发现这些超时都具有相同的特征。

下面我截取了核心的两步:

第一步是调用方执行时长(我们上游的处理时长),由于超过 100ms 的超时时间,显示为异常。

第二步是提供方的执行时长(我们服务的处理时长),显示为 0ms 。

接下来让我们看看具体的细节,下面第一张图是调用方记录的处理过程,第二张图是提供方记录的处理过程:

我们可以看到调用方在 16:58:23:041 发起了调用,一直到 16:58:23:147 超过了 100ms ,由于超时结束调用。

而提供方是在 16:58:23:208 才收到请求开始处理。

这意味着调用方都超时了,提供方还没有收到请求,并不是我们的服务业务处理慢导致。

那么问题是,中间的 100ms 去哪里了?

消失的 100ms 到底去哪了

我们先看看上面截图的全链路追踪系统的时间是怎么记录的,全链路追踪系统和 dubbo 整合,使用 Dubbo 的 Filter 来记录时间等指标:

public class QTraceFilter {
    @Activate(group = {Constants.CONSUMER}, before = "qaccesslogconsumer")
    public static class Consumer implements Filter {
        private static final QTraceClient traceClient = QTraceClientGetter.getClient();

        @Override
        public Result invoke(Invoker<?> invoker, Invocation inv) throws RpcException {
            final long startTime = System.currentTimeMillis();
            Result result = invoker.invoke(inv);
            //收集consumer指标
        }
    }

    @Activate(group = {Constants.PROVIDER}, before = "qaccesslogprovider")
    public static class Provider implements Filter {
        private static final QTraceClient traceClient = QTraceClientGetter.getClient();

        @Override
        public Result invoke(Invoker<?> invoker, Invocation inv) throws RpcException {
            final long startTime = System.currentTimeMillis();
            Result result = invoker.invoke(inv);
            //收集provider指标
        }
    }
}

通过以上代码我们可以知道,链路系统记录的时间只是业务的执行时间,那消失的 100ms 可能在如下部分:

  • 1、业务线程池(Dubbo 线程池)任务堵塞(我们服务使用 Dubbo 线程池作为业务线程池)--- provider 端导致
  • 2、IO 线程池(Netty worker 线程池)任务堵塞 --- provider 和 consumer 端均可能导致
  • 3、GC 导致 STW --- provider 和 consumer 端均可能导致
  • 4、网络问题(内核socket排队、网络链路问题等)--- 概率小

是我们的服务有问题吗

通过上面的分析,我们大概确定了排查的方向。

首先,我们需要确定是我们的问题还是调用方的问题,我们采用的策略是对我们的服务进行扩容,如果超时率大幅度降低,那基本上可以确定是我们的问题了。

于是我们把集群的数量增加一倍,继续观察超时监控指标。

扩容后,调用方监控确实有比较明显降低,基本确定是我们服务提供方的问题了。

线程池大小调整

我们首先是怀疑我们线程池不足导致,排查日志能发现极少量 Dubbo 线程池用尽的关键日志 "Thread pool is EXHAUSTED!",于是我们把线程池扩大了一倍进行尝试。

Dubbo 线程池从 400 扩大到 800,netty 线程池从原来 16 个扩大到 32 个,继续观察超时监控指标。

<dubbo:protocol name="dubbo" port="20880" id="main" threads="800" iothreads="32"/>

遗憾的是超时率是没有任何的变化,扩大线程池没有太大作用。

终究还是 STW 惹的祸

排查到这里,我们会重点把注意力放到 GC 上面来。我们的 GC 使用的是 ParNew+CMS 的组合,参数如下所示:

-Xms7g -Xmx7g -XX:NewSize=5g -XX:PermSize=256m -server -XX:SurvivorRatio=8 -XX:GCTimeRatio=2 -XX:+UseParNewGC -XX:ParallelGCThreads=2 -XX:+CMSParallelRemarkEnabled -XX:+UseCMSCompactAtFullCollection -XX:+UseFastAccessorMethods -XX:+CMSPermGenSweepingEnabled -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=70 -XX:SoftRefLRUPolicyMSPerMB=0 -XX:+UseCMSInitiatingOccupancyOnly -XX:+DisableExplicitGC -Dqunar.logs=$CATALINA_BASE/logs -Dqunar.cache=$CATALINA_BASE/cache -verbose:gc -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:$CATALINA_BASE/logs/gc.log

可以看到在 GC 上也是做了比较多的优化,主要针对 CMS 做了各种优化,对于新生代的优化主要是两个:

  • 设置新生代堆内存为 5g。
  • 设置 ParNew 的垃圾回收线程数为 2。

通常我们会忽略新生代的 GC 的影响,下意识认为 YoungGC 很快,且 STW 的时间短,不会对服务产生大的影响。

而我们这次的问题恰恰就是 YoungGC 导致的问题。

让我们仔细分析一下:由于我们的服务 qps 高,又有大量的本地缓存(缓存时间短)的使用,会产生大量的对象,这些对象朝生夕死,一般的调优思路为加大新生代内存,不让这些对象由于内存不足进入老年代,在新生代就完成 GC。

可是问题是 YoungGC 真的快吗?

针对于大内存(超过4G)的 YoungGC 其实并不快,ParNew 本质上是一个多线程垃圾回收器,采用了标记复制算法,在多线程标记和复制的过程中,用户线程就会 STW ,新生代越大则 STW 时间越长。

我们的 YoungGC 时间监控如下所示:

在 GC 日志中可以看到 ParNew 在不同维度的耗时,user 是 GC 实际使用 CPU 的时间,sys 是系统调用或系统事件响应的耗时,real 是导致应用程序暂停的时间,也就是 STW 的时间,以下截取自我们线上服务日志:


2022-08-22T15:06:12.131+0800: 1051305.996: [GC (Allocation Failure) 2022-08-22T15:06:12.132+0800: 1051305.997: [ParNew: 4381100K->188250K(4718592K), 0.1881919 secs] 6342998K->2153358K(6815744K), 0.1890062 secs] [Times: user=0.37 sys=0.00, real=0.19 secs]
2022-08-22T15:06:22.782+0800: 1051400.647: [GC (Allocation Failure) 2022-08-22T15:06:22.783+0800: 1051400.648: [ParNew: 4382554K->192088K(4718592K), 0.1679972 secs] 6347662K->2163478K(6815744K), 0.1687044 secs] [Times: user=0.32 sys=0.01, real=0.17 secs]

可以看到 GC 的频率在 10s 一次,即每分钟 6 次,STW 的时间为 170ms~200ms。

如果超时时间为 100ms,则上游请求受 STW 影响的比例为:((200ms-100ms) * 6) / (60 * 1000ms)=0.01。

那么 STW 中有至少有 1% 的超时和 GC 有关。如果超时时间设置为 200ms,则大概率能等 STW 结束后正常返回。

所以到这里,我们得出结论:

我们服务的超时率和 YoungGC 相关,我们需要优化 GC。

GC 优化我们有 3 个方案可以选择:

方案一:继续使用 ParNew+CMS,优化参数,减少新生代堆内存大小,让 CMS 也可以发挥作用

这个方案调整简单,预期收益不是很高,由于 ParNew+CMS 采用分代模型,无论怎么调优也无法解决大内存带来的问题。

方案二:使用 G1 垃圾回收器

方案调整简单,我们线上使用 JDK8 ,可以很方便调整到 G1 ,可以尝试。

方案三:升级使用 ZGC ,让性能达到极致

方案调整复杂,ZGC 号称垃圾回收器里的黑科技,可以实现 STW 在 10ms 以内(在 JDK17 中使用,甚至可以达到 1ms 以内),久闻大名,未曾实践,本次我们很希望能在线上实战,完美解决我们的问题。

最终我们决定先选取一个 P3 (非核心应用)级别服务,使用 G1 和升级 ZGC 进行对比,看看 ZGC 究竟提升有多大以及服务是否稳定,用来决定我们是否在运价服务中使用 ZGC 。

四、ZGC 线上实践

首先我们把 P3 服务一半机器使用 G1 垃圾回收器(对照组),G1 的使用这里就不在赘述了。

重点是升级 ZGC ,ZGC 最初是作为 JDK 11 中的实验性功能引入的,并在 JDK 15 中被宣布为 Production Ready ,由此可见官方并不支持在 JDK11 直接在生产环境中使用 ZGC ,如果有条件应该升级到 JDK15 或 JDK17 中使用。

由于我们的服务使用 JDK8 ,直接升级到 JDK15 或 JDK17 ,版本跳跃过大,可能产生未知问题,所以我们决定先升级 JDK11 ,先在 JDK11 中使用 ZGC 。

这个过程中涉及到 JDK11 升级。

JDK11 版本重大变化如下:

  • 删除部署堆栈
  • 删除 Java EE 和 CORBA 模块
  • 安全更新
  • 移除 API、工具和组件

关于 JDK8 迁移到 JDK11 的注意点,详见 Oracle 官方指南链接:

https://docs.oracle.com/en/java/javase/11/migrate/index.html#JSMIG-GUID-8C36237D-76BB-4ADD-B026-4EB3EAA6BE99

ZGC使用

JDK11 默认使用 G1 垃圾回收器,如果使用 ZGC ,需要配置 JVM 启动参数,我这边的配置如下:

-Xmx7g -Xms7g  -XX:ReservedCodeCacheSize=256m -XX:InitialCodeCacheSize=256m  -XX:+UnlockExperimentalVMOptions  -XX:+UseZGC -XX:ConcGCThreads=4  -XX:ZAllocationSpikeTolerance=5  -Xlog:gc*:file=$CATALINA_BASE/logs/gc.log:time

ZGC 是一款相当智能的垃圾回收器,配置参数不算太多,需要优化的就更少了,后面会提到,全部配置如下:

G1 和 ZGC 效果对比

我们选取的服务是一个 CPU 密集型服务,且产生大量对象,所以 GC 稍微频繁一些,堆的大小均配置为 7G 。

下面是使用 G1 和 ZGC 的垃圾回收次数和时间的监控,可以看到 ZGC 在保持垃圾回收次数和 G1 相差不大的情况下, STW 的时间减少了 3 倍。

G1回收次数(大概7秒每次)
G1回收时间(30ms)
ZGC回收次数(大概6秒每次)
ZGC回收时间(13ms)

五、优化效果

GC 次数小幅增加,STW 时间降低为 10ms:

调用方超时率降低几乎 100 倍,超时率从百分之二降低到万分之三。

观察调用方监控,超时量从高峰期 100qps 以上降低到 1 左右,超时率降低 100 倍。

我们的服务在 100ms 超时下,实现了 3 个 9 ,接近 4 个 9 的可用性。

六、ZGC 原理分析

从 CMS 到 G1 再到 ZGC ,到底优化了啥

CMS 全称 Concurrent Mark Sweep,是 GC 承上启下之作,也是第一款支持并发标记和并发清理的垃圾回收器。

并发表示 GC 线程可以和用户线程同时执行,可以很大程度降低 STW 的时间,这相比之前的垃圾回收器有很大的优化。

但是 CMS 的问题在于使用标记清除算法,虽然做到了并发清理,但是会产生大量的内存碎片,并且使用分代模型,每次只能在年轻代回收、老年代回收、全部回收中选择一种,这样就无法控制 STW 的时间,STW 的时间也会随堆内存的增大而增大。

G1 也是一款有划时代意义的垃圾回收器,它在吸收了 CMS 并发标记的优点下,使用了堆内存分区模型(物理分区,逻辑分代),默认将堆划分成 2048 个 region ,这样就可以有策略的选择需要回收的内存区域,进而控制 STW 的时间,所以 G1 有一个很重要的优化参数:-XX:MaxGCPauseMillis

不过 G1 为了解决 CMS 并发清理导致内存碎片化的问题,使用了复制算法转移对象,这样如果在转移过程中 GC 线程和用户线程并行,会导致指针无法准确定位对象的问题,G1 的做法是转移全阶段 STW ,停止用户线程,这样 G1 的 STW 的瓶颈就在对象转移阶段。

ZGC 是一款全新的垃圾回收器,是后续所有垃圾回收器的基础,完全摒弃了分代的思想,采用内存分区,使用染色指针和读屏障解决了复制算法并发转移对象导致的指针无法准确定位对象的问题,并且 STW 的时间不会随堆内存的增大而增大,基本只和 GC Roots 相关。

但是 ZGC 仍然还有很多问题需要解决,比如产生了过多的浮动垃圾,去掉了分代后对象没有冷热之分,长时间的并发标记和并发转移牺牲了系统的吞吐量等。

ZGC 设计核心特点如下:

需要注意的是 ZGC 虽然极大的减少了 STW 的时间,但是加长了并发标记和并发转移的时间,导致多个 GC 线程长时间运行,这样就降低了系统的吞吐量,据官方数据最高可能损失系统 15% 的吞吐量。

ZGC 内存模型

ZGC 内存分区,将堆内存分为小页面、中页面、大页面三种类型:

  • 小页面:容量固定为 2MB,用于存放小于 256KB 的小对象。
  • 中页面:容量固定为 32MB,用于存放大于等于 256KB 但小于 4MB 的对象。
  • 大页面:容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于存放大于等于 4MB 的对象。每个大页面只会一个大对象,也就是虽然它叫大页面,但它的容量可能还没中页面大,最小容量为 4MB 。

ZGC核心组件介绍

根据上文可知,ZGC 的核心在于解决并发转移的问题,那我们看看在对象转移(复制)过程中,如何做到 GC 线程和用户线程并发执行的,这里涉及几个 ZGC 的核心组件:

染色指针(Color Pointer)

1.对象 MarkWord 中的 GC 标记

我们知道在 ZGC 之前,GC 标记(三色标记算法使用)和分代年龄等会放在 Java 对象头的 MarkWord 中,这样我们需要根据指针,再到堆内存中找到对应的对象,从对象头中取得 GC 信息,这个过程还是比较繁琐的。

更为重要的是,在并发转移场景下,用户指针所指向的内存可能被 GC 线程转移了,无法再从对象头中取得准确信息。

所以 ZGC 的做法是把 GC 的标记放在指针中,通过指针就可以获取 GC 标记。

上图是一个 64 位的指针,在 jdk11 中,ZGC 使用低 42 位寻址(2^42=4T),使用 43~46 位作为 GC 染色标记,高 18 位不被使用。

到了 jdk13 以后,寻址范围增加了 2 位(2^44=16T),高 16 位不被使用(现在的 CPU 的数据总线出于成本和实际使用情况的考虑在硬件层面只支持 48 位,所以高 16 位都是无用的)。

2.指针如何染色指针

先说直接裁剪方案及问题。

指针的原本的作用在于寻址,如果我们想实现染色指针,就得把43~46位赋予特殊含义,这样寻址就不对了。

所以最简单的方式是寻址之前把指针进行裁剪,只使用低 42 位去寻址。

那么解决的方案可能是:

// 比如我们的一个指针如:0x13210 ,高位1表示标记,低位3210表示地址。
ptr_with_metadata = 0x13210;

// 移除标记位,得到真实地址
AddressBitsMask = ((1 << 16) - 1);
address = ptr_with_metadata & AddressBitsMask

// 使用真实地址
use(*address)

导致的问题是,标记位的这种删除将 CPU 指令添加到生成的代码中,会导致应用程序变慢。

为了解决上面指针裁剪的问题,ZGC 使用了 mmap 内核函数进行多虚拟地址内存映射。

mmap 这个函数你可能比较熟悉,一般在我们提到零拷贝技术时会用到。

使用 mmap 可以将同一块物理内存映射到多个虚拟地址上:


// 将物理内存pmem映射到marked0、marked1、remapped
map_view(ZAddress::marked0(offset), pmem);
map_view(ZAddress::marked1(offset), pmem);
map_view(ZAddress::remapped(offset), pmem);


// 最终对于linux mmap函数的调用
void ZPhysicalMemoryBacking::map(uintptr_t addr, size_t size, uintptr_t offset) const {
  const void* const res = mmap((void*)addr, size, PROT_READ|PROT_WRITE, MAP_FIXED|MAP_SHARED, _fd, offset);
  if (res == MAP_FAILED) {
    ZErrno err;
    fatal("Failed to map memory (%s)", err.to_string());
  }
}

这样,就可以实现堆中的一个对象,有 3 个虚拟地址,不同的地址标记不同的状态 marked0、marked1、remapped,且都可以访问到内存。

这样实现了指针染色的目的,且不用对指针进行裁剪,提高了效率。

3.视图 (View)

和染色指针适配的三种视图

ZGC 将我们所看到的堆内存的视图分为 3 种:marked0、marked1、remapped,同一时刻只能处于其中一种视图。

比如:在没有进行垃圾回收时,视图为 remapped。

在 GC 进行标记开始,将视图从 remapped 切换到 marked0/marked1。

在 GC 进行转移阶段,又将视图从marked0/marked1 切换到 remapped。

“好”指针和“坏”指针

当前访问指针的状态(地址视图)和当前所处的视图匹配时,则当前指针为“好”指针。

当前指访问针的状态和当前所处的视图不一致时,则为“坏指针”。

触发读屏障

读取到“坏”指针时,则需要读屏障进行 GC 相关处理。

下图总结了一部分的重要的屏障操作:

4.读屏障(Load Barrier)

读屏障是一小段在特殊位置由 JIT 注入的代码,类似我们 JAVA 中常用的 AOP 技术;主要目的是处理地址转发,我们来看一段官方所给出的伪代码。

Object o = obj.fieldA; // 只有从堆中获取一个对象时,才会触发读屏障

//读屏障伪代码
if (!(o & good_bit_mask)) {
   if (o != null) {
     //处理并注册地址
     slow_path(register_for(o), address_of(obj.fieldA));
   }
}

5.转发表 (Forwarding Tables)

转发表是在 ZGC 的内存分区(Region)中存在一小块内存空间,用来存储着转移阶段的活跃对象的老地址和转移后的新地址,也就是上图中所说的对象活跃信息表。

这样在并发场景下,用户线程使用读屏障就可以通过转发表拿到新地址,用户线程可以准确访问并发转移阶段的对象了。

除了在读屏障中使用了转发表外,在并发标记阶段也会遍历转发表,完成所有的地址转发过程,最后在并发转移准备阶段会清空转发表。

ZGC 收集过程

ZGC 大的流程分为两步,标记和转移。

细分的流程如下图所示(图片引用自pdai.tech)

void ZDriver::run_gc_cycle(GCCause::Cause cause) {
  ZDriverCycleScope scope(cause);

  // Phase 1: 初始标记(STW)   Pause Mark Start
  // Phase 2: 并发标记  Concurrent Mark
  // Phase 3: 最终标记(STW)   Pause Mark End
      // Phase 3.1: 如果超时,继续并发标记
  // Phase 4: 并发弱引用处理   Concurrent Reference Processing
  // Phase 5: 并发重置Relocation Set, 在进行标记后,GC统计了垃圾最多的若干region,将它们称作:relocation set
  // Phase 6: 并发回收无效页
  // Phase 7: 并发选择Relocation Set
  // Phase 8: 初始转移前准备(STW)
  // Phase 9: 初始转移(STW)
  // Phase 10: 并发转移
}

让我们仔细分析下这个过程,看看 ZGC 是如何做到 STW 不受堆内存扩大的影响。

ZGC 只有三个 STW 阶段:

  • 初始标记
  • 最终标记
  • 初始转移

其中,初始标记和初始转移类似,都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短;再标记阶段 STW 时间更短,最多 1ms ,超过 1ms 则再次进入并发标记阶段。

即 ZGC 几乎所有暂停都只依赖于 GC Roots 集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。

与 ZGC 对比,G1 的转移阶段完全 STW 的,且停顿时间随存活对象的大小增加而增加。

ZGC 日志解读

统计日志,默认 10 秒打印 1 次

垃圾回收日志,回收 1 次打印 1 次

ZGC调优

ZGC 相当智能,我们需要调整的参数很少,由于 ZGC 已经自动将垃圾回收时间控制在 10ms 左右,我们主要关心的是垃圾回收的次数。

要优化次数,我们需要先搞清楚几个主要的 ZGC 触发垃圾回收的算法:

  • 阻塞内存分配请求触发:当垃圾来不及回收,垃圾将堆占满时,会导致部分线程阻塞。日志中关键字是“Allocation Stall”。
  • 基于分配速率的自适应算法:最主要的 GC 触发方式,其算法原理可简单描述为” ZGC 根据近期的对象分配速率以及 GC 时间,计算出当内存占用达到什么阈值时触发下一次 GC ”。日志中关键字是“Allocation Rate”。
  • 主动触发规则:类似于固定间隔规则,但时间间隔不固定,是 ZGC 自行算出来的时机。日志中关键字是“Proactive”。其中,最主要使用的是 Allacation Stall GC 和 Allocation Rate GC。

我们的调优思路为尽量不出现 Allocation Stall GC , 然后 Allocation Rate GC 尽量少。

为了做到不出现 Allocation Stall GC ,我们需要做到垃圾尽量提前回收,不要让堆被占满,所以我们需要在堆内存占满前进行 Allocation Rate GC。

为了 Allocation Rate GC 尽量少,我们需要提高堆的利用率,尽量在堆占用 80% 以上进行 Allocation Rate GC。

基于此,Oracle 官方 ZGC 调优指南只建议我们调整两个参数:

  • 堆大小(-Xmx -Xms):设置更大的堆内存空间
  • ZGC 线程数 (-XX:ConcGCThreads):调整线程数控制 Allocation Rate GC 回收的速度

你可以在服务中反复调整这些值,让GC表现更加优秀。

六、总结

ZGC 是一款相当优秀的垃圾回收器,但也不是银弹。

在我们的实践中,它在低延迟服务中(服务的P99小于30ms),往往能发挥更大的作用,解决由于 STW 带来的长尾问题,让你的服务在超时时间极短的情况下,还能轻松实现 3 个 9 甚至 4 个 9 的可用性。

反之由于并发标记和清理的时间加长,会影响系统的吞吐量,得不偿失,而且在 JDK11 使用过程中我们发现 ZGC 会占用更多的堆外内存,比 G1 约高出 15%,所以我们需要合理设置堆的大小。

不过好消息是,在 Java 服务中,本来 GC 调优一直是一个难题,随着 G1、ZGC 以及未来更加优秀的垃圾回收器的出现,你的调优过程将越来越简单。

最后,希望我的文章能给你带来一点点帮助~

哦,对了,最最后再说一句。京东今天和明天,这两天计算机类自营图书满100 减 50,我这还有一个减 30 元的优惠券,如果你最近打算买书的话,可以看看这个链接没事,那啥,就通知一下,过节了,这一波羊毛又可以薅了。

··············  END  ··············

推荐👍一个莫得名堂的引用和一个坑

推荐👍体检的时候遇到一事儿,贼特么尴尬...

推荐👍酸了,来看看这个牛逼的实习经历。

推荐👍 :拼多多是真的把链接玩明白了。

推荐👍 :2021,我这一年。

你好呀,我是歪歪。我没进过一线大厂,没创过业,也没写过书,更不是技术专家,所以也没有什么亮眼的title。

当年高考,随缘调剂到了某二本院校计算机专业。纯属误打误撞,进入程序员的行列,之后开始了运气爆棚的程序员之路。

说起程序员之路还是有点意思,可以点击蓝字,查看我的程序员之路

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存