写点什么

【得物技术】服务发布时网络“抖动”

用户头像
得物技术
关注
发布于: 2021 年 07 月 30 日

抛出问题

服务部署后一段时间内经常会遇见接口调用超时,这种问题在流量稍大的时候很容易遇见,举例曾经做过的一个服务,整个服务只对外提供一个接口,功能属于密集计算型,会经过一系列的复杂处理,实例启动时借助 Redis 实现了数据的全量缓存,运行中很少有底层库的读写,且增加了 Gauva 本地缓存,所以服务处理速度很快,响应时间稳定在 1-3ms,2 个异地集群 8 台 2 核 4G 机器,平时的 QPS 维持在 1300 左右。看似完美的服务却在中期出现一个很头疼的问题,每次迭代线上部署上游总会出现“抖动”的问题,响应时间会飙升到秒级别,而这种非主链路的服务在响应时间上有严格的要求,一般不会超过 50~100ms。



解决历程

这里简单说下解决问题的历程,不再详细展开具体步骤。1.尝试第一步 起初猜想是服务启动后的一段时间内会有一些初始化和缓存的操作,或者服务还有序列化器需要建立请求和响应的序列化模型,这些都会让请求变慢,所以尝试在流量切入之前做预热。2.尝试第二步





通过以上指标可以发现服务在部署的时候 CPU 飙升,内存还算正常,活跃线程数飙高,不难看出,CPU 飙升是因,Http 线程数飙升是果,此时可以先忽略 CPU 在忙什么,通过加机器配置排查问题。3.尝试第三步 走到这里就说明前面尝试都以失败告终,无头绪的时候又仔细翻看下监控指标,发现还有一个异常指标,如下图:



JVM 的即时编译器时间达到 40s+,从而导致 CPU 飙高,线程数增加,最后出现接口超时也就不难理解了。找到原因接下来就三部曲分析下,是什么,为什么,怎么做?

即时编译器是什么

1. 概述

编程语言分为高级语言和低级语言,机器语言和汇编语言属于低级语言,人类编写的一般是高级语言,列如 C,C++,Java。机器语言是最底层的语言,能够被计算机直接执行,汇编语言通过汇编器翻译成机器执行然后执行,高级语言的执行类型有三种,编译执行、解释执行和编译解释混合模式,Java 起初被定义为解释执行,后来主流的虚拟机都包含了即时编译器,所以 Java 是属于编译和解释混合模式,首先通过 javac 将源码编译成.class 字节码文件,然后通过 java 解释器解释执行或者通过即时编译器(下文中简称 JIT)预编译成机器码等待执行。



JIT 并不是 JVM 必须的部分,JVM 规范中也没有规定 JIT 必须存在,更没有规定如何实现,它存在的主要意义在于提高程序的执行性能,根据“二八定律”,百分之二十的代码占用百分之八十的资源,我们称这些百分之二十的代码为“热点代码”(Hot Spot Code),针对热门代码我们用 JIT 编译成与本地平台相关的机器码,并进行各层次的深度优化,下次使用时不再进行编译直接取编译后的机器码执行。而针对非“热点代码”使用相对耗时的解释器执行,将我们的代码解释成机器可以识别的二进制代码,解释一句执行一句。


2. 解释器和编译器对比

  • 解释器:边解释边执行,省去编译的时间,不会生成中间文件,不用把全部代码都编译,可以及时执行。

  • 编译器:多出编译的时间,并且编译后的代码大小会成倍的膨胀,但编译后的可执行文件运行更快且能复用。


我们所说的 JIT 比解释器快,仅限于“热点代码”被编译之后的执行比解释器解释执行快,如果只是单次执行的代码,JIT 是要比解释器慢的。根据两者的优势,对于服务要求快速重启或者内存资源限制较大的解释器可以发挥优势,程序运行后,编译器将逐渐发挥作用,把越来越多的“热门代码”编译成本地代码来提高效率。此外,解释器还可以作为编译器进行优化的一个“逃生门”,当激进优化的假设不成立时可以通过逆优化退回到解释器状态继续执行,两者能够相互协作,取长补短。


3. 即时编译器的类型

在 HotSpot 虚拟机中,内置了两个 JIT:Client Complier 和 Server Complier,简称 C1、C2 编译器。前面提到 JVM 是属于解释和编译混合的模式,程序使用哪个编译器取决于虚拟机的运行模式,虚拟机会根据自身的版本与宿主机的硬件性能自动选择,用户也可以使用“-client”或者“-server”参数指定使用哪个编译器。


C1 编译器:是一个简单快速的编译器,主要的关注点在于局部性的优化,采用的优化手段相对简单,因此编译时间较短,适用于执行时间较短或对启动性能有要求的程序,比如 GUI 应用。

C2 编译器:是为长期运行的服务器端应用程序做性能调优的编译器,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高,适用于执行时间较长或对峰值性能有要求的程序。


Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势。分层编译将 JVM 的执行状态分为了 5 个层次:

第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第 1 层编译。

第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling。

第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 Profiling 的 C1 编译。

第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译。

第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。



在 Java8 中默认开启分层编译(-XX:+TieredCompilation),-client 和-server 的设置已经是无效的了。

  • 如果只想用 C1,可以在打开分层编译的同时使用参数“-XX:TieredStopAtLevel=1”。

  • 如果只想用 C2,使用参数“-XX:-TieredCompilation”关闭分层编译即可。

  • 如果只想有解释器的模式,可以使用“-Xint”,这时 JIT 完全不介入工作。

  • 如果只想有 JIT 的编译模式,可以使用“-Xcomp”,此时解释器作为编译器的“逃生门”。


通过 java -version 命令行可以直接查看到当前系统使用的编译模式。如下图所示:



4.热点探测

即时编译器判断某段代码是不是“热点代码”的行为叫做热点探测,分为基于采样的热点探测和基于计数器的热点探测。


基于采样的热点探测:虚拟机周期性的对各个线程的栈顶进行检查,如果发现某个方法经常出现那这个方法就是“热点代码”,这种热点探测实现简单、高效、容易获取方法之间的调用关系,但很难精确的确认方法的热度,而且容易受到线程阻塞或其他外因的干扰。


基于计数器的热点探测:虚拟机为每个方法或代码块建立计数器,统计代码的执行次数,如果超过一定的阈值就认为是“热点代码”,这种方式实现麻烦,不能直接获取方法间的调用关系,需要为每个方法维护计数器,但是能精确统计热度。


HotSpot 虚拟机默认是基于计数器的热点探测,因为“热点代码”分为两类:被多次调用的方法和被多次执行的循环体,所以该热点探测计数器又分为方法调用计数器和回边计数器。这里需要注意,两者最终编译的对象都是完整的方法,对于后者,尽管触发编译的动作是循环体,但编译器依然会编译整个方法,这种编译方式因为编译发生在方法的执行的过程中,因此称之为栈上替换(On Stack Replacement, 简称 OSR 编译)。


方法调用计数器:用于统计方法被调用的次数,在 C1 模式下默认阈值是 1500 次,在 C2 模式在是 10000 次,可通过参数-XX: CompileThreshold 来设定。而在分层编译的情况下,-XX: CompileThreshold 指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整,当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器,触发时执行引擎并不会同步等待编译的完成,而是继续按照解释器执行字节码,编译请求完成之后系统会把方法的调用入口改成最新的地址,下一次调用时直接使用编译后的机器码。


如果不做任务的设置,方法调用计数器统计的并不是绝对次数,而是一个相对的执行频率,即一段时间内被调用的次数,当超过时间限度调用次数仍然未达到阈值,那么该方法的调用次数就会减半,此行为称之为方法调用计数器热度的衰减,这段时间称为此方法的统计半衰周期,可以使用虚拟机参数-XX:-UseCounterDecay 关闭热度衰减,也可以使用参数-XX:CounterHalfLifeTime 设置半衰周期的时间,需要注意进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的。



回边计数器:用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边。与方法计数器不同,回边计数器没有计数热度衰减的过程,统计的是方法循环执行的绝对次数。当计数器溢出时会把方法计数器的值也调整到溢出状态,这样下次再进入该方法时就会执行编译过程。在不开启分层编译的情况下,C1 默认为 13995,C2 默认为 10700,HotSpot 提供了-XX:BackEdgeThreshold 来设置回边计数器的阈值,但是当前虚拟机实际上使用-XX: OnStackReplacePercentage 来间接调整。

Client 模式下:阈值=方法调用计数器阈值(CompileThreshold)*OSR 比率(OnStackReplacePercentage)/ 100。默认值 CompileThreshold 为 1500,OnStackReplacePercentage 为 933,最终回边计数器阈值为 13995。

Server 模式下:阈值=方法调用计数器阈值(CompileThreshold)*(OSR 比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage))/ 100。默认值 CompileThreshold 为 10000,OnStackReplacePercentage 为 140,InterpreterProfilePercentage 为 33,最终回边计数器阈值为 10700.

而在分层编译的情况下,-XX: OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。



即时编译器为什么耗时

现在我们知道服务部署时 CPU 飙高是因为触发了即时编译,而且即时编译是在用户程序运行的时候后台执行的,可通过 BackgroundCompilation 参数设置,默认值是 true,这个参数设置成 false 是很危险的操作,业务线程会等待即时编译完成,可能一个世纪已经过去了。。。

那在后台执行的过程中编译器都做了什么呢?大致分为三个阶段,流程如下。



第一阶段:一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation, 简称 HIR),HIR 使用静态单分的形式来代表代码值,这可以使得一些在 HIR 的构造之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,比如方法内联、常量传播。

第二阶段:一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation, 简称 LIR),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除、范围检查消除等。

第三阶段:在平台相关的后端使用线性扫描算法(Liner Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔优化,然后产生机器代码。 以上阶段是 C1 编译器的大致过程,而 C2 编译器会执行所有经典的优化动作,比如无用代码消除、循环展开、逃逸分析等。

怎么解决问题

知道问题的根因,只需要对症下药即可,我们最终的目标是服务在部署的时候不要影响上游的调用,所以要把即时编译的执行时间和用户程序运行时间避开,可以选择在上游流量切入之前让热点代码触发即时编译,也就是我们说的预热,实现方式比较简单,使用 Spring 提供的方法。对于整个服务大部分都是热点代码的,可以在 JVM 启动脚本中加入“-XX:-TieredCompilation -server”参数来关闭分层编译模式,启用 C2 编译器,然后在预热的代码中模拟调用一遍所有接口,这样在流量接入之前会先进行即时编译。需要注意并不是所有的代码都会进行即时编译,存储机器码的内存有限,过大的代码即使达到一定次数也不会触发。而我们大部分服务都符合“二八定律”,所以还是选择保留分层编译模式,在方法内部模拟调用热门代码,需要计算好分层模式下触发即时编译的阈值,也可以根据参数适当的调整阈值大小。


文|Shane

关注得物技术,携手走向技术的云端

发布于: 2021 年 07 月 30 日阅读数: 12
用户头像

得物技术

关注

得物APP技术部 2019.11.13 加入

关注微信公众号「得物技术」

评论

发布
暂无评论
【得物技术】服务发布时网络“抖动”