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 编译。

相关推荐
LUCIAZZZ3 小时前
JVM之内存管理(二)
java·jvm·后端·spring·操作系统·springboot
黄雪超8 小时前
JVM——Java语法糖与Java编译器
java·开发语言·jvm
大G哥8 小时前
Rust 之 trait 与泛型的奥秘
java·开发语言·jvm·数据结构·rust
isyangli_blog8 小时前
(1-1)Java的JDK、JRE、JVM三者间的关系
java·开发语言·jvm
钰爱&15 小时前
【Linux】POSIX 线程信号量与互斥锁▲
java·开发语言·jvm
黄雪超20 小时前
JVM——即时编译器的中间表达形式
java·开发语言·jvm
黄雪超20 小时前
JVM——即时编译
java·开发语言·jvm
每次的天空1 天前
Android Handler 机制面试总结
java·开发语言·jvm
等等5431 天前
Java EE初阶——初识多线程
java·开发语言·jvm