作者介绍

余辉,5年一线项目开发经历,2019年加入去哪儿网,深耕于去哪儿机票底层数据体系,擅长高并发体系的规划及开发。

一、背景

我所担任的机票运价体系是去哪儿机票底层最中心的价格计算和存储引擎,其供给的基础航班运价数据供去哪儿机票简直一切事务体系运用,供给接口调用QPS 3万+,日均调用 7 亿次以上,均匀呼应时刻小于 2 ms,是名副其实的亿级流量、高并发、低推迟体系。在这样的体系中,接口 P99 长尾往往会成为性能瓶颈。这次在上游中心体系重构的契机下,发现调用运价接口超时率到达了 2% ,这表明有至少 2% 的用户领会将受到影响,作为机票最中心的服务之一,这种可用性是不能被承受的。咱们需求排查这个问题并做出优化。假如你的项目对低推迟有很高的要求,这篇文章对你一定有协助。

二、问题剖析

剖析事务监控目标,有无需求迭代影响

首要咱们需求重视的是自己体系的监控目标,因为均匀呼应时长咱们每天都会重视,且排查小概率超时问题,这儿咱们着重看接口时长 P99 ,能够看到下图近 3 个月目标没有明显的改变,维持在 8ms 左右。

ZGC在去哪儿机票运价系统实践

值得留意的是,一般,跟着需求迭代,体系整领会缓慢的向熵增的方向发展(体系复杂性、接口时长等),这种添加在监控目标表现上或许会比较平缓,平常维护进程中难以发现,比及发现的时候,往往就现已是一个大的毛病了。好的建议是,假如能够,咱们应该在 P99 目标上设置一个稍灵敏的报警,能及时发现问题。

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

有些场景下咱们自己服务的监控目标并不客观,咱们通常会挑选几个中心上游的目标作为咱们重视的中心目标,以评估咱们服务接口实在的可用性,这些目标咱们每天都会检查,所以咱们清晰的知道这些上游在 200ms 的超时时刻下,调用咱们接口的超时率在千分之四。那么这次百分之二的超时率是怎样形成的呢?答案是超时时刻设置为 100ms ,那么问题或许是 100ms 设置的不合理,最简略的方法是让调用方调整超时时刻。调用方A(超时时刻:200ms)监控目标如下:200ms下超时率约为千分之三:3.93/1411=0.00278

ZGC在去哪儿机票运价系统实践

ZGC在去哪儿机票运价系统实践

调用方B(超时时刻:100ms)监控目标如下:100ms下超时率挨近百分之三:103/3498=0.029

ZGC在去哪儿机票运价系统实践

ZGC在去哪儿机票运价系统实践

能够看到超时时刻从 200ms 下降为 100ms ,这儿超时率简直是升高了 10 倍(疏忽了调用方自身的影响)

假如超时时刻不能调整呢

假如能够让上游都调整超时时刻到 200ms 乃至更长,那么这个问题很简略就能处理了,通常这也是最快处理问题的方法,可是这个方法并不高雅,添加超时时刻会形成整个调用链路的呼应时刻添加,直接影响用户的交互领会。更为高雅的方法是添加超时时刻的前提下,再运用异步调用,通常 200ms 不会是整个链路的要害瓶颈,异步后既能处理超时问题也不会添加用户感知时长。然而复杂体系的调用组合联系往往会十分复杂,各种依靠联系往往导致不能异步,像这次的事例中,咱们的上游需求拿到其他调用的结果才能调用咱们的服务,这样就没法异步了。所以从调用方的视点,迫切的期望咱们能下降服务 P99 呼应时长,进步服务的可用性。从咱们自己的服务监控目标来看,咱们服务的均匀时刻才不到 2ms ,P99 也仅仅只要 8ms 。你或许重视到一个问题,为什么咱们服务供给方记载的监控目标 P99 才 8ms ,而调用方的 P98 就到达了 100ms ,这中心到底经历了什么,咱们也十分好奇,所以咱们需求找满足的超时的 case 用于剖析。

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

经过 dubbo 的 access 日志,咱们能够很快找到这些超时的 case ,然后凭借去哪儿的中心件-全链路追寻体系(QTRACER),能够看到链路的调用全进程,发现这些超时都具有相同的特征,下面我截取了中心的两步

ZGC在去哪儿机票运价系统实践

第一步是调用方履行时长(咱们上游的处理时长),因为超越 100ms 的超时时刻,显现为异常。

第二步是供给方的履行时长(咱们服务的处理时长),显现为 0ms 。接下来让咱们看看详细的细节,下面第一张图是调用方记载的处理进程,第二张图是供给方记载的处理进程:

ZGC在去哪儿机票运价系统实践

ZGC在去哪儿机票运价系统实践

咱们能够看到调用方在 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排队、网络链路问题等)——概率小

是咱们的服务有问题吗

经过上面的剖析,咱们大约确定了排查的方向。首要,咱们需求确定是咱们的问题仍是调用方的问题,咱们选用的策略是对咱们的服务进行扩容,假如超时率大幅度下降,那根本上能够确定是咱们的问题了。所以咱们把集群的数量添加一倍,持续调查超时监控目标。

ZGC在去哪儿机票运价系统实践

扩容后,调用方监控的确有比较明显下降,根本确定是咱们服务供给方的问题了。

线程池巨细调整

咱们首要是怀疑咱们线程池缺乏导致,排查日志能发现极少量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 时刻监控如下所示:

ZGC在去哪儿机票运价系统实践

在 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结束后正常返回。

ZGC在去哪儿机票运价系统实践

ZGC在去哪儿机票运价系统实践

所以到这儿,咱们得出结论:咱们服务的超时率和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镜像直接部署:

ZGC在去哪儿机票运价系统实践

你也能够自己下载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在去哪儿机票运价系统实践

ZGC 是一款适当智能的废物收回器,装备参数不算太多,需求优化的就更少了,后面会说到,悉数装备如下:

ZGC在去哪儿机票运价系统实践

G1 和 ZGC 效果比照

咱们选取的服务是一个 CPU 密集型服务,且产生许多目标,所以 GC 略微频频一些。堆的巨细均装备为 7G ,下面是运用 G1 和 ZGC 的废物收回次数和时刻的监控,能够看到 ZGC 在坚持废物收回次数和 G1 相差不大的情况下, STW 的时刻减少了 3 倍。

G1收回次数(大约7秒每次)

ZGC在去哪儿机票运价系统实践

G1收回时刻(30ms)

ZGC在去哪儿机票运价系统实践

ZGC收回次数(大约6秒每次)

ZGC在去哪儿机票运价系统实践

ZGC收回时刻(13ms)

ZGC在去哪儿机票运价系统实践

五、优化效果

GC次数小幅添加,STW 时刻下降为10ms

ZGC在去哪儿机票运价系统实践

调用方超时率下降简直100倍,超时率从百分之二下降到万分之三

调查调用方监控,超时量从高峰期 100qps 以上下降到 1 左右,超时率下降 100 倍。咱们的服务在 100ms 超时下,完成了 3 个 9 ,挨近 4 个 9 的可用性。

ZGC在去哪儿机票运价系统实践

六、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在去哪儿机票运价系统实践

需求留意的是 ZGC 尽管极大的减少了 STW 的时刻,可是加长了并发符号和并发搬运的时刻,导致多个 GC 线程长时刻运转,这样就下降了体系的吞吐量,据官方数据最高或许丢失体系 15% 的吞吐量。

ZGC内存模型

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

  • 小页面:容量固定为 2MB,用于寄存小于 256KB 的小目标。
  • 中页面:容量固定为 32MB,用于寄存大于等于 256KB 但小于 4MB 的目标。
  • 大页面:容量不固定,能够动态改变,但有必要为 2MB 的整数倍,用于寄存大于等于 4MB 的目标。每个大页面只会一个大目标,也便是尽管它叫大页面,但它的容量或许还没中页面大,最小容量为 4MB 。

ZGC在去哪儿机票运价系统实践

ZGC中心组件介绍

依据上文可知,ZGC 的中心在于处理并发搬运的问题,那咱们看看在目标搬运(仿制)进程中,如何做到 GC 线程和用户线程并发履行的,这儿触及几个 ZGC 的中心组件:

染色指针(Color Pointer)

  • 目标 MarkWord 中的 GC 符号

咱们知道在 ZGC 之前,GC 符号(三色符号算法运用)和分代年纪等会放在 Java 目标头的 MarkWord 中,这样咱们需求依据指针,再到堆内存中找到对应的目标,从目标头中获得 GC 信息,这个进程仍是比较繁琐的。更为重要的是,在并发搬运场景下,用户指针所指向的内存或许被 GC 线程搬运了,无法再从目标头中获得精确信息。所以 ZGC 的做法是把 GC 的符号放在指针中,经过指针就能够获取 GC 符号。

ZGC在去哪儿机票运价系统实践

上图是一个 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 相关处理,下图总结了一部分的重要的屏障操作:

ZGC在去哪儿机票运价系统实践

ZGC在去哪儿机票运价系统实践

读屏障(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收集进程

ZGC 大的流程分为两步,符号和搬运。

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

ZGC在去哪儿机票运价系统实践

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次

ZGC在去哪儿机票运价系统实践

废物收回日志,收回1次打印1次

ZGC在去哪儿机票运价系统实践

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”的首字母缩写词,但这个意义被放弃了,后来听说它不代表任何东西。这仅仅一个名字。

八、参阅文档

wiki.openjdk.org/display/zgc…

cr.openjdk.java.net/~pliden/sli…

docs.oracle.com/en/java/jav…

pdai.tech/md/java/jvm…