JVM调优-重启CPU飙高优化

本文参考:

咸鱼技术的文档: https://juejin.cn/post/7174610735434547236#heading-9

美团非常详细地讲解JIT原理:https://tech.meituan.com/2020/10/22/java-jit-practice-in-meituan.html

研究背景

在服务发布或者重启的过程中,服务经常都会出"CPU 利用率超限 75%" 的报警,从监控看来,CPU 确实存在抖动飙高甚至打满的现象。这主要是由于应用重启时,JVM 重新进行类加载鱼对象初始化。从网上搜索了相关文章,学习一下它们的解决思路。

问题定位

通过 Arthas dashboard 观察 CPU 使用情况,发现应用启动时,C2 CompilerThread 以及 C1 CompilerThread 占用了大量的 CPU,导致 CPU 使用率飙增。这俩都是 JIT complier 中的线程,故可以确认,CPU 飙高的原因是 激进的JIT编译

C1(Client Compiler)是一个简单快速的编译器,主要实现浅层的局部优化,而放弃了需要花费大量时间精力的全局深度优化,默认被触发编译的阈值为1500次。

C2(Server Compiler)则是专门面向服务器端的,运行时会收集更多信息,花费更多时间,实现更为充分的全局优化,被触发编译的阈值为10000次。

JIT 编译

发布时CPU利用率的飙高是由于 JIT 编译导致的,因此在处理问题之前,我们需要了解Java编译的机制,这对于后续的理解很重要。

常见的编译型语言如C++,通常会把代码直接编译成CPU所能理解的机器码来运行。然而为了实现"一次编译,处处运行"的特性。Java把编译的过程分成两个阶段

  • 先由javac编译成通用的中间形式(字节码),该阶段通常被称为编译期。
  • 「解释器」逐条将字节码解释为机器码来执行,该阶段则属于运行期。

这里有几个概念提前辨析一下:

字节码:javac 编译的中间形式,以此实现一次编译,处处执行

机器码:最终需要由CPU操作系统运行的文件,C++是直接编译出来的,而 Java 需要由「解释器」将字节码 -> 机器码

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

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

  • 解释器(Interpreter):当程序需要迅速启动时,使用解释器解释字节码,节省编译的时间,快速执行。
  • JIT编译器(JIT Compiler):在程序启动后并且长时间提供服务时,JIT将越来越多的代码编译为本地机器码,获得更高的执行效率。

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

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

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

有点类似于之前 OS 里学习的 CPU 指令 Cache 机制,整体总结下来,就是 JIT 编译器通过热点代码计数器,将原先解释执行字节码后的产物 - 机器码,存储在 CodeCache 中,下次可以直接从 CodeCache 取用执行。然而 JIT 编译时需要占用 CPU 的,所以激进的 JIT 编译导致了 CPU 飙高问题,下面看看解法。

解决方案

1. 分层编译

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

2. 调整 CodeCache

当codeCache容量不足时,在JDK1.7.0_4之后默认开启的回收机制是Speculative flushing。最早被编译的一半方法将会被放到一个old列表中等待回收。在一定时间间隔内,如果old列表中方法没有被调用,这个方法就会被从codeCache中清除,flushing操作则会带来CPU使用率的飙高。因此我们需要对其容量进行观测和调整。 通过JVM参数**-XX:ReservedCodeCacheSize=256M **设置Code Cache 的总容量上限。

对于应用程序复杂,CodeCache 容量不足的场景,通过扩大其容量可以避免编译优化的机器码被反复 flushing,从而避免 JIT 反复编译热点代码。

3. 龙井预热

需要使用阿里的 Ajdk 才能使用此功能,是「提前预热」思想的体现

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

4. 逐步放开流量

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

5. 调整 JIT 阈值

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

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

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

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

  • Tier 0 - Interpertor 解释执行
  • Tier 1 - C1 no profiling
  • Tier 2 - C1 limited profiling
  • Tier 3 - C1 full profiling
  • Tier 4 - C2

对于集群 CPU 资源有限,且发布时 RT 没有极高要求的场景,通过调高 JIT 阈值,可以避免大量代码被当作热点代码所产生的激进的 JIT 编译。

相关推荐
码熔burning28 分钟前
JVM 面试精选 20 题(续)
jvm·面试·职场和发展
完全学不完8 小时前
JVM对象创建和内存分配
jvm
IT利刃出鞘14 小时前
Java线程的6种状态和JVM状态打印
java·开发语言·jvm
黑客影儿16 小时前
Java技术总监的成长之路(技术干货分享)
java·jvm·后端·程序人生·spring·tomcat·maven
没有bug.的程序员17 小时前
JVM 运行时数据区详解:内存模型与对象生命周期全景解析
java·jvm·运行时数据区·内存模型·对象生命周期
风吹落叶32571 天前
深入解析JVM内存管理与垃圾回收机制
java·开发语言·jvm
我们从未走散1 天前
JVM学习笔记-----StringTable
jvm·笔记·学习
甄超锋2 天前
python sqlite3模块
jvm·数据库·python·测试工具·django·sqlite·flask
找不到、了2 天前
JVM的逃逸分析深入学习
java·jvm
长臂人猿3 天前
JVM常用工具:jstat、jmap、jstack
linux·运维·jvm