怪力乱神
一般认为开启注入后,http调用栈变长,响应时间变长了,系统的 QPS下降,但是我的组员在对RASP 进行性能压测时发现了一个“奇怪的事情”,相比于没有开启注入的场景,开启注入之后系统的 QPS 没有降低反而更大了。
我重现了她的测试过程。首先,启动一个 web服务,压测得到 QPS
不开启注入时文件上传接口 QPS为296
开启注入时文件上传接口 QPS为300
从上面的2张图我发现了一个问题,测试时先测了不开启注入的场景,测试完成之后,web 进程没有重启,接着开启注入,再次测试,QPS 没有降低(或者变化不明显)。
初步怀疑与 web 进程在压测时系统经过了一个预热的过程(JIT编译),等到再次开启注入时,系统的 QPS 没有明显变化。
再次测试,先测试不开启注入时的 QPS,进程重启之后,开启注入,测试 QPS
测试数据:
不开启注入时文件上传接口 QPS为242
开启注入时文件上传接口 QPS为224
开启注入后对文件上传的 QPS 有影响,QPS 下降 (242-224)/ 242=7.4%
测试结论的差异在于正常的压测后是否重启了进程,这个对 RASP 的影响有较大的影响,并且测试结果还说明了一点,进过预热的后, RASP性能影响会降低,最后影响趋近于0.
下面回顾下 JVM 的解释器与编译器,加深对这个过程的理解。
概述
在JVM 中,Java 程序运行之初都是通过解释器解释执行,运行时某些方法或代码块调用超过设定的阈值后(热点代码),为了提高热点代码的执行效率,虚拟机会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器被称为即时编译器(Just In Time Compiler,简称 JIT 编译器)。
Hotspot 虚拟机内的即时编译器
本节介绍 Hotspot 虚拟机内的即时编译器的运作过程,同时还要解决以下几个问题:
1. 为何 Hotspot 虚拟机要使用解释器和编译器并存的架构?
2. 为何 Hotspot 虚拟机要实现两个不同的即时编译器?
3. 程序何时使用解释器执行?何时使用编译器执行?
4. 哪些程序代码会被编译为本地代码?如何编译为本地代码?
解释器与编译器
尽管不是所有的 Java 虚拟机都采样解释器与编译器并存的价格,但是许多主流的虚拟机,比如 Sun Hotspot、IBM J9,都同时包含解释器与编译器。解释器与编译器有各自的优势:当程序需要快速启动时,解释器可以发挥作用,省去编译时间立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码后,可以获取更高的执行效率。
当程序运行环境的内存资源限制较大时,使用解释器执行节省内存,反之可以使用编译执行提升效率。同时,解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一个大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立时,比如加载了新类后类型继承结构出现变化,出现“罕见陷阱”时可以通过逆优化退回到解释状态继续执行。因此,在虚拟机中解释器和编译器经常配合工作,如下图所示:
[图片上传失败...(image-c08c7a-1592405406505)]
HotSpot 虚拟机内置了两个即时编译器:Client Compiler 和 Server Compiler,简称 C1 编译器和 C2 编译器。默认采用解释器和其中一个编译器直接配合的方式工作,具体使用哪个编译器,取决于虚拟机工作的模式,用户可以使用 -client 参数或 -server 参数指定虚拟机的工作模式,还可以使用 -Xint 强制虚拟机运行于“解释模式”。
由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所需时间会更长。同时,解释器还要替编译器收集性能监控信息,这对解释执行速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot 虚拟机又引入了分层编译的策略。分层编译根据编译器编译、优化的规模与耗时,划分为不同的编译层次,包括:
1. 第 0 层,程序解释执行,不开启性能监控功能,可触发第 1 层编译。
2. 第 1 层,称为 C1 编译,将字节码编译为本地代码,并进行简单可靠的优化,如有必要将加入性能监控逻辑。
3. 第 2 层,称为 C2 编译,也是将字节码编译为本地代码,但是会进行耗时较长的优化,甚至会根据性能监控信息进行一些不完全可靠的激进优化。
实施分层编译后,Client Compiler 和 Server Compiler 会同时工作,许多代码可能会被编译多次,用 Client Compiler 获得更快的编译速度,用 Server Compiler 获取更好的编译质量,在解释执行的时候也无需再承担收集性能监控信息的任务。
编译对象与触发条件
在运行过程中,会被即时编译器编译的热点代码有两类:
1. 被多次调用的方法。
2. 被多次执行的循环体。
这两种情况,编译器都会编译整个方法。因为编译发生在方法执行过程中,因此形象地称之为栈上替换(On Stack Replacement,简称 OSR,即方法栈帧还在栈上,方法就被替换了)。
判断一段代码是不是热点代码,是否需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),热点探测方式主要有两种:
1. 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果某个方法经常出现在栈顶,那它就是热点方法。其优点是简单、高效,还可以获取方法调用关系;缺点是不够精确,容易受到线程阻塞或其他外接因素的影响。
2. 基于计数的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法执行次数,次数超过一定阈值就认为是热点方法。这种方法实现起来麻烦,但是其统计结果相对来说更加精确和严谨。
在 HotSpot 虚拟机里使用的是第二种方法,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back edge Counter)。在确定虚拟机运行参数的情况下,这两个计数器都有一定的阈值,超过阈值就会触发 JIT 编译。
方法调用计数器
方法调用计数器用于统计方法被调用的次数,其默认阈值在 Client 模式下是 1500,在 Server 模式下是 10000,该阈值可以通过虚拟机参数 -XX:CompileThreshold 来设置。方法调用计数器与 JIT 编译的交互如下:
[图片上传失败...(image-b03abc-1592405406505)]
默认情况下,方法调用计数器统计的不是方法被调用的绝对次数,而是一段时间内的方法被调用的次数。当超过一段的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半。这个过程称为热度衰减(Counter Decay),这段时间称为方法统计的半衰周期(Counter Half Life Time)。进行热度衰减的动作是虚拟机在垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减。另外,还可以使用 -XX:CounterHalfLifeTime 来设置半衰周期的时间,单位是秒。
回边计数器
回边计数器的作用是统计方法体中循环体代码执行次数,在字节码中遇到遇到控制流向后调整的指令称为回边。显然,建立回边计数器统计的目的就是为了触发 OSR 编译。在 HotSpot 虚拟机里,通过参数 -XX:OnStackReplacePercentage 来间接调整回边计数器的阈值,其计算公式如下:
1. Client 模式下,回边计数器阈值计算公式为:方法调用计数器阈值 * OnStackReplacePercentage / 100
2. Server 模式下,回边计数器阈值计算公式为:方法调用计数器阈值 * (OnStackReplacePercentage - 解释器监控比率) / 100
回边计数器触发即时编译的过程如下所示:
[图片上传失败...(image-68a081-1592405406505)]
与方法计数器不同,回边计数器没有衰减过程,因此统计的就是绝对次数。当计数器溢出的时候,它会把方法计数器也调整到溢出状态,它还会把方法计数器也调整到溢出状态,这样下次再进入该方法时就会触发即时编译。
在 HotSpot 虚拟机的源码里,MethodOop.hpp 文件定义了虚拟机中的内存布局,如下所示:
[图片上传失败...(image-caa2b4-1592405406505)]
在这个内存布局中,一行长度为 32bit,从中可以清楚地看到方法调用计数器和回边计数器的位置和长度,还有 from_compiled_entry 和 from_interpreted_entry 这两个方法的入口。
编译过程
Client Compiler
默认情况下,即时编译是在后台进行的,编译完成之前还是按照解释方式执行,用户可以通过参数 -XX:-BackgroundCompilation 来禁止后台编译。
那么在后台编译过程中,做了什么事情呢?Client Compiler 和 Server Compiler 两个编译器的编译过程是不一样的。Client Compiler 是一个简单快速的三段式编译器,主要关注点在于局部优化,放弃了许多耗时的全局优化手段。
1. 在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码(High Level Intermediate Representation,HIR)表示。HIR 使用静态单分配的形式来代表代码值,这使得一些在 HIR 之后和之中进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内敛、常量传播等。
2. 在第二个阶段,一个平台相关的后端从 HIR 中产生低级中间代码表示(Low Level Intermediate Representation,LIR)表示。在此之前,会在 HIR 上完成另一些优化,比如空值检查消除、范围检查消除,以便让 HIR 达到更高效的代码表示形式。
3. 最后阶段,是在平台相关的后端,使用线性扫描算法在 LIR 上分配寄存器,并在 LIR 上做窥孔优化,然后产生机器代码。
Client Compiler 大致执行过程如下图所示:
[图片上传失败...(image-b0e74-1592405406505)]
Server Compiler
Server Compiler 是面向服务端的,并且为服务端性能配置进行了特别调整,是一个充分优化过的高级编译器,几乎能达到 GNU 编译器使用 -O2 参数时的优化强度。它会执行所有经典的优化动作,比如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实施一些与 Java 语言特征密切相关的技术,比如范围检查消除、空值检查消除。另外,还可能根据解释器或 Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等。后面会挑选部分优化手段进行详细的讲解。
Server Compiler 的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构上的大寄存器集合。以即时编译的标准来看,Server Compiler 无疑是比较缓慢的,但它的编译速度依然超过传统的静态优化编译器,而且相对于 Client Compiler 来说代码质量有所提高,可以减少本地代码执行时间,从而抵消了额外的编译时间开销。
编译优化技术
Java 虚拟机设计团队几乎对代码的所有优化措施都集中在了即时编译器中,因此一般来说,即时编译器产生的本地代码会比 javac 产生的字节码更加优秀。下面,我们介绍一些 HotSpot 虚拟机即时编译器生成代码时采用的代码优化技术。
优化技术概览
下面列出了 HotSpot 虚拟机即时编译器采用的一些优化技术,既有经典编译器的优化技术,也有针对 Java 语言进行的优化技术。后面我们挑几个重要而且典型的优化进行讲解。