查看原文
其他

谈谈Java应用发布时CPU抖动的优化

唤辰 闲鱼技术 2023-01-14

研究背景

通常情况下应用发布或重启时都存在cpu抖动飙高,甚至打满的现象,这是由于应用启动时,JVM重新进行类加载与对象的初始化,CPU在整个过程中需要进行比平时更多的编译工作。同样,闲鱼的消息系统在重新发布时经常有抖动的问题,如下图显示:日常情况下CPU使用率基本不超过20%,而每当应用重新发布时,服务器的cpu使用率骤增至40%以上。本文正是为了减少这种抖动,进而保障应用发布时的稳定性。

Java的编译

发布时CPU利用率的飙高很大程度上是编译造成的,因此在处理问题之前,我们需要了解Java编译的机制,这对于后续的理解很重要。如果已经该部分知识非常熟悉,则可以跳过本节直接阅读第三部分。常见的编译型语言如C++,通常会把代码直接编译成CPU所能理解的机器码来运行。然而为了实现“一次编译,处处运行”的特性。

Java把编译的过程分成两个阶段

  • • 先由javac编译成通用的中间形式(字节码),该阶段通常被称为编译期。

  • • 解释器逐条将字节码解释为机器码来执行,该阶段则属于运行期。

为了优化Java字节码运行的性能 ,HotSpot在解释器之外引入了JIT(Just In Time)即时编译器,形成了用解释器+JIT编译器混合的执行引擎

二者会在运行期并肩作战,但分工不同

  • • 解释器(Interpreter):当程序需要迅速启动时,使用解释器解释字节码,节省编译的时间,快速执行。

  • • JIT编译器(JIT Compiler):在程序启动后并且长时间提供服务时,JIT将越来越多的代码编译为本地机器码,获得更高的执行效率。

Java程序在JVM上执行的过程如下图所示:

编译期先由javac将源码编译成字节码,在这个过程中会进行词法分析、语法分析、语义分析等操作,该过程也被称为前端编译。当类加载完成,程序运行时,JVM会利用热点代码计数器进行判断,如果此时运行的代码是热点代码则使用JIT,如果不是则使用解释器。对于热点代码的判断方式有采样估计和计数两种方式,Hotspot采用计数方式,到达一定阈值时触发编译。

大多数情况下解释器首先发挥作用,将字节码按条解释执行。随着时间推移,通过不断对解释的代码进行信息采集,JIT逐渐发挥作用。把越来越多的字节码编译优化为本地机器码并存储在CodeCache中,来获取更高的执行效率。解释器这时可以作为编译运行的降级手段,在一些不可靠的编译优化出现问题时,再切换回解释执行,保证程序可以正常运行。JIT极大地提高了Java程序的运行速度,而且跟静态编译相比,即时编译器可以选择性地编译热点代码,省去了很多编译时间,也节省很多的空间。

定位问题

在线诊断

Arthas 是阿里巴巴推出的一款免费的线上监控诊断产品,通过全局视角实时查看应用 load、内存、gc、线程的状态信息。首先,我们使用Arthas在预发环境下连接服务器,进而对对常规时刻的CPU使用率进行监控,操作步骤如下:

下载:>> curl -O https://arthas.aliyun.com/arthas-boot.jar  启动:>> java -jar arthas-boot.jar 看板:>> dashboard

dashboard面板显示如下图,可以看到此时的各线程所占用的CPU。随后,我们进行应用的发布重启,来观察该过程的CPU使用率变化,下图是常规时段的CPU利用率。

随后我们开始发布应用。开始发布后不久搭建的ssh链接会被服务器断开,此时应用被停止。在连接断开之前捕捉到了如下记录,“VM Thread”等线程,占用了一定的的CPU,但不是很多。

等到应用被重新启动时,我们重新连接服务器,此时需要再次启动Arthas看板,来观察各线程对CPU的使用,操作如下:

启动:>> java -jar arthas-boot.jar 看板:>> dashboard

此时我们捕捉到了如下线程对CPU的占用信息。可以发现应用启动时刻进行的C2编译线程占用了大量的CPU资源,导致CPU利用率激增。这轮编译的占用会在几分钟内逐步减弱,随之通过监控看到,CPU使用率也逐步恢复正常。

 原因分析

在上述诊断过程中,我们定位到了两类占用CPU利用率较高的线程:

 1、在应用关闭时,出现了与JVM关闭相关的“VM Thread”。"VM Thread"在每一次关闭JVM时都会出现,然而在单纯关机的时候监控并没有显示出CPU抖动,况且其占用的CPU利用率在15%以内,故该类线程并非CPU利用率抖动的原因。

"VM Thread" 是JVM自身的一个线程, 它主要用来协调其它线程达到安全点,而在该时机,堆内存不发生修改. 被该线程执行的操作有:"stop-the-world" GC, 线程堆栈dumps, 线程挂起以及偏向锁的revocation。

2、重新启动后,我们观察到了C1 ComplierThread和C2 ComplierThread线程。而时机也与性能监控的抖动时间刚好吻合,故可以确定是由于应用重启,大量的代码被识别为热点代码,触发了JIT complier的编译行为从而带来了CPU利用率的飙高。

C1(Client Compiler)是一个简单快速的编译器,主要实现浅层的局部优化,而放弃了需要花费大量时间精力的全局深度优化,默认被触发编译的阈值为1500次。C2(Server Compiler)则是专门面向服务器端的,运行时会收集更多信息,花费更多时间,实现更为充分的全局优化,被触发编译的阈值为10000次。

方法汇总

在对消息系统发布过程的诊断与分析之后,我们成功定位了问题——激进的JIT编译。

随后,基于我们对JVM启动和JIT编译的理解,我们在解决过程中调研并使用了五种完全不同的方法——分层编译、codeCache利用、龙井预热、逐步放开流量、调整JIT参数。接下来会对这些方法逐一进行介绍:

分层编译

JIT常用的编译方式有如下几种:

  • • mixed:最常规的方式,先采用解释方案执行代码,当代码执行到一定次数的时候,JIT编译器才会进行编译优化。编译后的本地代码不需要JVM 虚拟机进行解释执行,效率会提高很多,当应用中的热点代码都进行编译优化后,代码的性能就会有很大的提升。

  • • full compilation:纯编译方式。在所有代码第一次执行的时候就能使用JIT编译后的本地代码,后期提供服务时有着很高的性能。但是由于编译本身是非常耗时的,因此也会导致应用在刚刚启动的时候就进行大量的JIT编译,CPU负载会骤增。

  • • tried compilation :分层编译方式。与mixed方式类似,先采用解释器解释执行,热点代码计数器到达一定阈值后开始进行JIT编译。分层编译最核心的是分层,即在编译过程中使用多种编译器,到达不同的阈值时会使用不同的编译器。

分析:

Java的分层编译可以渐进过渡的方式充分利用C1的灵活性和C2的深层优化,追求启动速度和峰值性能的平衡。在Java8之前,我们需要通过JVM参数-XX:TireCompilation 来打开分层编译。而对于Java8及之后的应用分层编译测试默认进行的。我们的应用基于Java8,因此已经打开了分层编译

codeCache

JIT编译之所以能够带来性能的提升源于其将编译好的机器码存储在了本地,而存储的位置就是CodeCache

CodeCache是一块独立于 java 堆之外的内存区域,存放 jit 编译的代码,也存放java所使用的本地方法代码以client模式或者是分层编译模式运行的应用,C1编译阈值比较低,更容易达到编译标准,所以更容易耗尽codeCache。

通过Arthas的Dashborad,在应用运行期可以监控到codeCache的使用情况

通过JVM参数XX:+PrintCodeCache在 jvm 停止的时候打印出 codeCache 的使用情况。

size为codeCache的总容量, max_used 则为整个运行过程中codeCache的最大使用量。

通过JVM参数-XX:ReservedCodeCacheSize=256M设置Code Cache 的总容量上限。 具体的设置应根据监控数据估算,例如单位时间增长量、系统最长连续运行时间等。如果没有相关统计数据,一种推荐的设置思路是设置为当前值(或者默认值)的2倍。但也不能占用JVM过多的内存,即我们需要设置一个合理的codeCache大小,在保证应用正常运行的情况下减少内存使用。

分析:

当codeCache容量不足时,在JDK1.7.0_4之后默认开启的回收机制是Speculative flushing。最早被编译的方法将会被放到一个old列表中等待回收。在一定时间间隔内,如果old列表中方法没有被调用,这个方法就会被从codeCache中清除,flushing操作则会带来CPU使用率的飙高。因此我们需要对其容量进行观测和调整。对于我们的消息系统来说,codeCache使用百分比最高点在50%左右,并不会影响到JIT编译的过程。

龙井预热

作为全球最主要的Java用户之一,阿里内部在OpenJdk的基础上进行了扩展形成Ajdk,拥有更多的功能,而龙井(DragonWell)是Ajdk定制版的开源版本,供各界使用学习。这次用到的正是Ajdk的Jwarmup功能。

JwarmUp的基本原理:根据前一次程序运行的情况,记录热点代码以及类加载顺序等信息。在应用下一次启动的时候积极主动地对相关类进行加载,并积极编译相关代码,进而使得应用尽快使用上C2编译优化的指令。从而在流量进来之前,提前完成类的加载、初始化和方法编译, 跳过解释阶段, 直接执行编译好的native code, 避免一面解释执行一面后台编译带来的CPU与load飙高, rt超时等问题。

使用步骤:

  • • 记录编译信息阶段

-XX:+CompilationWarmUpRecording  -XX:CompilationWarmUpLogfile=jwarmup.log  -XX:CompilationWarmUpRecordTime=300

记录模式、记录存储的jwarmup.log,在5分钟后生成profiling data

  • • 使用编译信息阶段

-XX:+CompilationWarmUp  -XX:CompilationWarmUpLogfile=jwarmup.log  -XX:CompilationWarmUpDeoptTime=0

JWarmUp会在指定时间退优化warmup编译的方法,设置CompilationWarmUpDeoptTime为0可以取消这个定时。

1、recording记录下来的日志,是怎么分发到其他线上机的?

答:在应用启动的脚本文件进行控制:

  • • 预热节点,会将记录下来的编译信息上传到远程服务器oss上,

  • • 发布节点,在启动时从远处机器主动pull下来预热节点上传的编译信息。

2、是怎么制定一台机器做recording的呢?是访问某个url还是判断beta机器?

答:是通过访问oss做了一个类似于“文件锁”的东西,先拿到锁的beta机器做为预热节点,其余机器为发布节点。想要达到预热的效果请确保:

  • • 发布的机器的参数中有 -XX:+CompilationWarmUp

  • • 每次beta发布后,记得检查下编译信息文件是否已经上传

  • • beta发布的那台机器必须是有流量的,Recording时间不要太短,尽量多编译一些方法。

如果不保证上述两点的话,便无法完成预热发布,即没有充分利用beta的编译信息,仍然走正常发布的流程

分析:

jwarmup使用的场景如下图蓝色曲线所示:项目发布阶段,大量的解释执行时把CPU占满,导致没有足够的CPU进行编译,会导致CPU打满并长时间在解释运行,没有机会编译,CPU的利用率会长时间居高不下。而开启了jwarmup后如下图红色曲线所示,大大缩短了编译的时间。

对于我们消息应用发布cpu利用率抖动(CPU利用率在短时间内飙高)的问题,jwarmup并不能避免。即jwarmup能跳过解释执行阶段直接进入JIT编译,而我们的应用CPU 飙高正是因为JIT过于激进。但是这种思路仍值得我们学习和借鉴。

逐步放开流量

通过控制发布机器的流量大小, 用低流量来先去诱发JIT, 再把发布机器的流量设置到正常水位, 避免在JIT过程中, 因为全量流量进来导致的CPU飚高、LOAD飚高、RT飚高等问题, 使得应用发布或重启时顺滑平稳。

较为典型的是应用中的RPC服务,通过将项目中的HSF服务分批发布,逐步放开HSF调用的流量,可以减小由于大流量导致的JIT编译,缓解c2 compiler线程骤增对CPU占用过高的问题。

应用启动后,利用网关的流量控制功能,按照时间间隔逐步放入流量,如:10%,20%...100%,或者给予不同的访问权重,使得服务能够逐渐到达正常访问的热度。例如,如果发现应用是重启,则开启流量分步加载策略,每当入口流量达到流量上限, 线程就Sleep下一秒,过后继续放量。根据时间间隔,逐步放开流量限制

分析:

逐步放开流量时:通过预发机器性能监控可以看出,即使是在无流量的情景下,应用发布时CPU仍会严重抖动,因此可以推断出这次的抖动与入口流量并不强相关,故这种方式也本次试验中也不是很适用。并且而在发布时我们的中间件如HSF、diamond、notify等也会占用少量CPU(10%左右),但相比于C2可以忽略不计。并且我们应用的RPC流量本就不是非常大,还未到达分层发布的地步。这种方式更适用于在线上流量过高且不均匀的情况下使用。

调整JIT阈值

通常情况下,我们可以使用-XX:CompileThreshold=5000 修改JIT编译阈值为5000。

注意:开启分层编译的情况下,-XX:CompileThreshold-XX:OnStackReplacePercentage中参数设置的阈值将会失效,触发编译会由以下公式中新的参数的条件来判断:

满足上述其中一个条件就会触发即时编译,i为调用次数,b是循环回边次数,s是系数,并且JVM会根据当前的编译方法数以及编译线程数动态调整系数s。通过查看JVM运行时的参数,我们可以看到相关的阈值参数如下:

JVM 系统的分层编译支持5种级别

  • • Tier 0 - Interpertor 解释执行

  • • Tier 1 - C1 no profiling

  • • Tier 2 - C1 limited profiling

  • • Tier 3 - C1 full profiling

  • • Tier 4 - C2

C1 是client compiler. C2是 server compiler.profiling就是收集能够反映程序执行状态的数据,分层编译。下图显示了在我们将advanced JIT 阈值提升后取得了较好的效果

将上述阈值调高意味着提高即时编译的门槛,将热点代码的编译工作分散开来,以防止某一时刻CPU的飙高。调整参数后可以发现,C2 CompilerThread在任意时刻对CPU的占用率大幅下降(从原来动辄80%,90%变化到现如今20%左右)。这也让Tomcat的启动线程localhost-startStop-1的占用显得理所应当。

上图是机器监控显示的集群点CPU利用率,红色圈圈的部分是参数调优之后。几个CPU利用率的峰值均下降了10%~15%,该方法在一定程度上缓解了抖动问题。

JIT编译优化有分层机制,随着Threshold的增加,C2的激进编译得到了缓和,使得瞬时的CPU峰值下降,从而让给业务线程更多的计算资源,以避免在应用发布短时间内RT飙高。

但是该阈值并不是越高越好,C2虽然会占用大量CPU,但是其目的是为字节码生成较为优化的本地机器码,如果迟迟不能触发,那么在当请求到来时,系统仍运行着C1编译的命令,甚至是解释器解释的结果,那么必将会导致接下来一段时间的服务RT略高一些。

总结展望

针对Java应用启动性能的优化涉及很多方面,本文提供了五种不同方法,这些方法基本可以解决大多数场景下CPU飙高的问题,方案及对应的使用场景总结如下:

对于Java应用,HotSpot本身有着非常多的机制可以利用。这也我们需要深入了解JVM原理,比如JIT编译优化的方式原理,垃圾回收机制等,以便更敏锐地发现应用所存在的缺陷。实际上,上述的五个方法都是基于JVM层面上的优化,较为通用,也可以覆盖多数场景。

除此之外,在未来我们还可以进行在应用层面上的优化,应用层面的优化需要深入了解我们应用的细节,具体到依赖了哪些模块,系统的瓶颈时段,哪些接口的QPS较高等。尽可能地减少单体应用的复杂度是最有效,最具针对性的方案。

对于不同的应用需要具体问题具体分析,做好足够的调研和实验。从而根据我们应用的特性地进行优化,提升系统的性能。以我们的消息系统为例,其中还存在着RASP,本质上是javaagent(相当于JVM级别的AOP),在运行时进行的二次编译部署存在着一部分开销;大多数运行多年的系统中都存在着很多陈旧待废弃的类与模块,这部分的影响也不得不考虑在内;最后,在CPU利用率优化时也要做出可能牺牲其他方面的考量与权衡,比如内存消耗、启动速度、RT等。

参考资料

《深入理解Java虚拟机》机械工业出版社 周志明

Java Developer's Guide https://docs.oracle.com/en/database/oracle/oracle-database/21/jjdev/Oracle-JVM-JIT.html

Arthas 使用手册 https://arthas.aliyun.com/doc/arthas-tutorials.html?language=cn&id=arthas-basics

阿里巴巴龙井使用指南 https://github.com/alibaba/dragonwell8/wiki/%E9%98%BF%E9%87%8C%E5%B7%B4%E5%B7%B4Dragonwell8%E7%94%A8%E6%88%B7%E6%8C%87%E5%8D%97

OpenJDK8 HotSpot VM Options https://chriswhocodes.com/hotspot_options_openjdk8.html


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

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