去哪儿网机票选座(ZGC在去哪儿机票运价系统实践)
去哪儿网机票选座(ZGC在去哪儿机票运价系统实践)有些场景下我们自己服务的监控指标并不客观,我们通常会挑选几个核心上游的指标作为我们关注的核心指标,以评估我们服务接口真实的可用性,这些指标我们每天都会查看,所以我们明确的知道这些上游在 200ms 的超时时间下,调用我们接口的超时率在千分之四。那么这次百分之二的超时率是怎么造成的呢?答案是超时时间设置为 100ms ,那么问题可能是 100ms 设置的不合理,最简单的办法是让调用方调整超时时间。值得注意的是,一般,随着需求迭代,系统整体会缓慢的向熵增的方向发展(系统复杂性、接口时长等),这种增长在监控指标表现上可能会比较平缓,平时维护过程中难以发现,等到发现的时候,往往就已经是一个大的故障了。好的建议是,如果可以,我们应该在 P99 指标上设置一个稍灵敏的报警,能及时发现问题。我所负责的机票运价系统是去哪儿机票底层最核心的价格计算和存储引擎,其提供的基础航班运价数据供去哪儿机票几乎所有业务
作者介绍
余辉
5年一线项目开发经验,2019年加入去哪儿网,深耕于去哪儿机票底层数据系统,擅长高并发系统的设计及开发。
一、背景
我所负责的机票运价系统是去哪儿机票底层最核心的价格计算和存储引擎,其提供的基础航班运价数据供去哪儿机票几乎所有业务系统使用,提供接口调用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迁移注意点
详见Oracle官方指南链接。(点击文末阅读链接可直接跳转)
我的实践
我在两个项目上从 JDK8 升级到了 JDK11 ,可能是由于环境部署问题已经被去哪儿云原生解决,花费的时间较短,整体流程都很顺畅。
- 代码改动
由于JDK11删除了Java EE,所以你可能有以下依赖修改,我的项目使用这些就够了。你可能会遇到更多的问题,请参考上面的官方迁移指南。
<!-去哪儿项目指定JDK版本为11的方式-->
<properties>
<java_source_version>11</java_source_version>
<java_target_version>11</java_target_version>
</properties>
<!-如果你的项目中使用了@Resource、@PostContruct等注解,请增加下面依赖-->
<dependency>
<groupId>javax.annotation</groupId>
<artifactId>javax.annotation-api</artifactId>
<version>1.3.2</version>
</dependency>
<!-如果你的项目中使用了JDK事务相关,请增加下面依赖-->
<dependency>
<groupId>javax.transaction</groupId>
<artifactId>javax.transaction-api</artifactId>
<version>1.3</version>
</dependency>
<!-如果你的项目中使用了XML解析相关工具,请增加下面依赖-->
<dependency>
<groupId>javax.activation</groupId>
<artifactId>activation</artifactId>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.2.11</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-core</artifactId>
<version>2.2.11</version>
</dependency>
<dependency>
<groupId>com.sun.xml.bind</groupId>
<artifactId>jaxb-impl</artifactId>
<version>2.2.11</version>
</dependency>
- 环境准备
我这里使用去哪儿的云原生环境,可以直接选择JDK11 tomcat8镜像直接部署:
你也可以自己下载Open JDK 11和tomcat7.0.84/8.0.48/8.5.24以上即可
- JVM参数配置,参考下一小节ZGC使用
- 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)
- 对象 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 位都是无用的)。
- 指针如何染色指针
直接裁剪的问题
指针的原本的作用在于寻址,如果我们想实现染色指针,就得把43~46位赋予特殊含义,这样寻址就不对了。所以最简单的方式是寻址之前把指针进行裁剪,只使用低 42 位去寻址。那么解决的方案可能是:
// 比如我们的一个指针如:0x13210 ,高位1表示标记,低位3210表示地址。
ptr_with_metadata = 0x13210;
// 移除标记位,得到真实地址
AddressBitsMask = ((1 << 16) - 1);
address = ptr_with_metadata & AddressBitsMask
// 使用真实地址
use(*address)
导致的问题是,标记位的这种删除将 CPU 指令添加到生成的代码中,会导致应用程序变慢。
使用mmap多映射内存进行指针染色
为了解决上面指针裁剪的问题,ZGC 使用了 mmap 内核函数进行多虚拟地址内存映射。mmap 这个函数你可能比较熟悉,一般在我们提到零拷贝技术时会用到,如果你不熟悉可以在linux帮助手册中进行查看。使用 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,且都可以访问到内存。这样实现了指针染色的目的,且不用对指针进行裁剪,提高了效率。
视图 (View)
- 和染色指针适配的三种视图
ZGC 将我们所看到的堆内存的视图分为 3 种:marked0、marked1、remapped,同一时刻只能处于其中一种视图。比如:在没有进行垃圾回收时,视图为 remapped 。在 GC 进行标记开始,将视图从 remapped 切换到 marked0/marked1 。在 GC 进行转移阶段,又将视图从marked0/marked1 切换到 remapped 。
- “好”指针和“坏”指针
当前访问指针的状态(地址视图)和当前所处的视图匹配时,则当前指针为“好”指针;当前指访问针的状态和当前所处的视图不一致时,则为“坏指针”。
- 触发读屏障
读取到“坏”指针时,则需要读屏障进行 GC 相关处理,下图总结了一部分的重要的屏障操作:
读屏障(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));
}
}
转发表 (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
{
ZMarkStartClosure cl;
vm_operation(&cl);
}
// Phase 2: 并发标记 Concurrent Mark
{
ZStatTimer timer(ZPhaseConcurrentMark);
ZHeap::heap()->mark();
}
// Phase 3: 最终标记(STW) Pause Mark End
{
ZMarkEndClosure cl;
while (!vm_operation(&cl)) {
// Phase 3.5: 如果超时,继续并发标记
ZStatTimer timer(ZPhaseConcurrentMarkContinue);
ZHeap::heap()->mark();
}
}
// Phase 4: 并发弱引用处理 Concurrent Reference Processing
{
ZStatTimer timer(ZPhaseConcurrentReferencesProcessing);
ZHeap::heap()->process_and_enqueue_references();
}
// Phase 5: 并发重置Relocation Set 在进行标记后,GC统计了垃圾最多的若干region,将它们称作:relocation set
{
ZStatTimer timer(ZPhaseConcurrentResetRelocationSet);
ZHeap::heap()->reset_relocation_set();
}
// Phase 6: 并发回收无效页
{
ZStatTimer timer(ZPhaseConcurrentDestroyDetachedPages);
ZHeap::heap()->destroy_detached_pages();
}
// Phase 7: 并发选择Relocation Set
{
ZStatTimer timer(ZPhaseConcurrentSelectRelocationSet);
ZHeap::heap()->select_relocation_set();
}
// Phase 8: 初始转移前准备(STW)
{
ZStatTimer timer(ZPhaseConcurrentPrepareRelocationSet);
ZHeap::heap()->prepare_relocation_set();
}
// Phase 9: 初始转移(STW)
{
ZRelocateStartClosure cl;
vm_operation(&cl);
}
// Phase 10: 并发转移
{
ZStatTimer timer(ZPhaseConcurrentRelocated);
ZHeap::heap()->relocate();
}
}
让我们仔细分析下这个过程,看看 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 以及未来更加优秀的垃圾回收器的出现,你的调优过程将越来越简单。最后,希望我的文章能给你带来一点点帮助~
七、FAQ
ZGC中的”Z“代表什么?
它不代表任何东西,ZGC 只是一个名字。它最初是受到 ZFS(文件系统)的启发或向其致敬,ZFS(文件系统)在它刚问世时在许多方面都是革命性的。最初,ZFS 是“Zettabyte File System”的首字母缩写词,但这个含义被放弃了,后来据说它不代表任何东西。这只是一个名字。
八、参考文档
https://wiki.openjdk.org/display/zgc/Main
http://cr.openjdk.java.net/~pliden/slides/ZGC-Jfokus-2018.pdf
https://docs.oracle.com/en/java/javase/11/gctuning/z-garbage-collector1.html#GUID-A5A42691-095E-47BA-B6DC-FB4E5FAA43D0
https://pdai.tech/md/java/jvm/java-jvm-gc-zgc.html#gc---java-垃圾回收器之zgc详解
作者:余辉
来源:Qunar技术沙龙
出处:https://mp.weixin.qq.com/s/dbnNS2KONdPKRp1A_1FU0A