learn-tech/专栏/深入浅出Java虚拟机-完/23动手实践:JIT参数配置如何影响程序运行?.md
2024-10-16 09:22:22 +08:00

286 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

因收到Google相关通知网站将会择期关闭。相关通知内容
23 动手实践JIT 参数配置如何影响程序运行?
本课时我们主要分享一个实践案例JIT 参数配置是如何影响程序运行的。
我们在前面的课时中介绍了很多字节码指令,这也是 Java 能够跨平台的保证。程序在运行的时候,这些指令会按照顺序解释执行,但是,这种解释执行的方式是非常低效的,它需要把字节码先翻译成机器码,才能往下执行。另外,字节码是 Java 编译器做的一次初级优化,许多代码可以满足语法分析,但还有很大的优化空间。
所以为了提高热点代码的执行效率在运行时虚拟机将会把这些代码编译成与本地平台相关的机器码并进行各种层次的优化。完成这个任务的编译器就称为即时编译器Just In Time Compiler简称 JIT 编译器。
热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。
在第 14 课时我们提到了参数“-XX:ReservedCodeCacheSize”用来限制 CodeCache 的大小。也就是说JIT 编译后的代码都会放在 CodeCache 里。
如果这个空间不足JIT 就无法继续编译编译执行会变成解释执行性能会降低一个数量级。同时JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。
JITWatch
在开始之前,我们首先介绍一个观察 JIT 执行过程的图形化工具JITWatch这个工具非常好用可以解析 JIT 的日志并友好地展示出来。项目地址请点击这里查看。
下载之后,进入解压目录,执行 ant 即可编译出执行文件。
产生 JIT 日志
我们观察下面的一段代码,这段代码没有什么意义,而且写得很烂。在 test 函数中循环 cal 函数 1 千万次,在 cal 函数中,还有一些冗余的上锁操作和赋值操作,这些操作在解释执行的时候,会加重 JVM 的负担。
public class JITDemo {
Integer a = 1000;
public void setA(Integer a) {
this.a = a; }
public Integer getA() {
return this.a;
}
public Integer cal(int num) {
synchronized (new Object()) {
Integer a = getA();
int b = a * 10;
b = a * 100;
return b + num;
}
}
public int test() {
synchronized (new Object()) {
int total = 0;
int count = 100_000_00;
for (int i = 0; i < count; i++) {
total += cal(i);
if (i % 1000 == 0) {
System.out.println(i * 1000);
}
}
return total;
}
}
public static void main(String[] args) {
JITDemo demo = new JITDemo();
int total = demo.test();
在方法执行的时候我们加上一系列参数用来打印 JIT 最终生成的机器码执行命令如下所示
$JAVA_HOME_13/bin/java -server -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading -XX:+PrintAssembly -XX:+LogCompilation -XX:LogFile=jitdemo.log JITDemo
执行的过程会输入到 jitdemo.log 文件里接下来我们分析这个文件
使用
单击 open log 按钮打开我们生成的日志文件
单击 config 按钮加入要分析的源代码目录和字节码目录确认后单击 start 按钮进行分析
在右侧找到我们的 test 方法聚焦光标后将弹出我们要分析的主要界面
在同一个界面上我们能够看到源代码字节码机器码的对应关系在右上角还有 C2/OSR/Level4 这样的字样可以单击切换
单击上图中的 Chain 按钮还会弹出一个依赖链界面该界面显示了哪些方法已经被编译了哪些被内联哪些是通过普通的方法调用运行的
使用 JITWatch 可以看到调用了 1 千万次的 for 循环代码已经被 C2 进行编译了
编译层次
HotSpot 虚拟机包含多个即时编译器 C1C2 Graal采用的是分层编译的模式使用 jstack 获得的线程信息经常能看到它们的身影
实验性质的 Graal 可以通过追加 JVM 参数进行开启命令行如下
$JAVA_HOME_13/bin/java -server -XX:+UnlockDiagnosticVMOptions -XX:+TraceClassLoading
-XX:+PrintAssembly -XX:+LogCompilation -XX:+UnlockExperimentalVMOptions
-XX:+UseJVMCICompiler -XX:LogFile=jitdemo.log JITDemo
不同层次的编译器会产生不一样的效果机器码也会不同我们仅看 C1C2 的一些特点
JIT 编译方式有两种一种是编译方法另一种是编译循环分层编译将 JVM 的执行状态分为了五个层次
字节码的解释执行
执行不带 profiling C1 代码
执行仅带方法调用次数以及循环执行次数 profiling C1 代码
执行带所有 profiling C1 代码
执行 C2 代码
其中profiling 指的是运行时的程序执行状态数据比如循环调用的次数方法调用的次数分支跳转次数类型转换次数等JDK 中的 hprof 工具就是一种 profiler
在不启用分层编译的情况下当方法的调用次数和循环回边的次数总和超过由参数 -XX:CompileThreshold 指定的阈值时便会触发即时编译当启用分层编译时这个参数将会失效会采用动态调整的方式进行
常见的优化方法有以下几种
公共子表达式消除
数组范围检查消除
方法内联
逃逸分析
我们重点看一下方法内联和逃逸分析
方法内联
在第 17 课时里我们可以看到方法调用的开销是比较大的尤其是在调用量非常大的情况下拿简单的 getter/setter 方法来说这种方法在 Java 代码中大量存在我们在访问的时候需要创建相应的栈帧访问到需要的字段后再弹出栈帧恢复原程序的执行
如果能够把这些对象的访问和操作纳入到目标方法的调用范围之内就少了一次方法调用速度就能得到提升这就是方法内联的概念
C2 编译器会在解析字节码的过程中完成方法内联内联后的代码和调用方法的代码会组成新的机器码存放在 CodeCache 区域里
JDK 的源码里有很多被 @ForceInline 注解的方法这些方法会在执行的时候被强制进行内联而被 @DontInline 注解的方法则始终不会被内联比如下面的一段代码
java.lang.ClassLoader getClassLoader 方法将会被强制内联
@CallerSensitive
@ForceInline // to ensure Reflection.getCallerClass optimization
public ClassLoader getClassLoader() {
ClassLoader cl = getClassLoader0();
if (cl == null)
return null;
SecurityManager sm = System.getSecurityManager();
if (sm != null) {
ClassLoader.checkClassLoaderPermission(cl, Reflection.getCallerClass());
}
return cl;
}
方法内联的过程是非常智能的内联后的代码会按照一定规则进行再次优化最终的机器码在保证逻辑正确的前提下可能和我们推理的完全不一样在非常小的概率下JIT 会出现 Bug这时候可以关闭问题方法的内联或者直接关闭 JIT 的优化保持解释执行实际上这种 Bug 我从来没碰到过
-XX:CompileCommand=exclude,com/lagou/Test,test
上面的参数表示 com.lagou.Test test 方法将不会进行 JIT 编译一直解释执行
另外C2 支持的内联层次不超过 9 太高的话CodeCache 区域会被挤爆这个阈值可以通过 -XX:MaxInlineLevel 进行调整相似的编译后的代码超过一定大小也不会再内联这个参数由 -XX:InlineSmallCode 进行调整
有非常多的参数被用来控制对内联方法的选择整体来说短小精悍的小方法更容易被优化
这和我们在日常中的编码要求是一致的代码块精简逻辑清晰的代码更容易获得优化的空间
我们使用 JITWatch 再看一下对于 getA() 方法的调用将鼠标悬浮在字节码指令上可以看到方法已经被内联了
逃逸分析
逃逸分析Escape Analysis是目前 JVM 中比较前沿的优化技术通过逃逸分析JVM 能够分析出一个新的对象使用范围从而决定是否要将这个对象分配到堆上
使用 -XX:+DoEscapeAnalysis 参数可以开启逃逸分析逃逸分析现在是 JVM 的默认行为这个参数可以忽略
JVM 判断新创建的对象是否逃逸的依据有
对象被赋值给堆中对象的字段和类的静态变量
对象被传进了不确定的代码中去运行
举个例子在代码 1 虽然 map 是一个局部变量但是它通过 return 语句返回其他外部方法可能会使用它这就是方法逃逸另外如果被其他线程引用或者赋值则成为线程逃逸
代码 2用完 Map 之后就直接销毁了我们就可以说 map 对象没有逃逸
代码1
public Map fig(){
Map map = new HashMap();
...
return map;
}
代码2
public void fig(){
Map map = new HashMap();
...
}
那逃逸分析有什么好处呢
同步省略如果一个对象被发现只能从一个线程被访问到那么对于这个对象的操作可以不考虑同步
栈上分配如果一个对象在子程序中被分配那么指向该对象的指针永远不会逃逸对象有可能会被优化为栈分配
分离对象或标量替换有的对象可能不需要作为一个连续的内存结构存在也可以被访问到那么对象的部分或全部可以不存储在内存而是存储在 CPU 寄存器中标量是指无法再分解的数据类型比如原始数据类型及 reference 类型
再来看一下 JITWatch synchronized 代码块的分析根据提示由于逃逸分析了解到新建的锁对象 Object 并没有逃逸出方法 cal它将会在栈上直接分配
查看 C2 编译后的机器码发现并没有同步代码相关的生成这是因为 JIT 在分析之后发现针对 new Object() 这个对象并没有发生线程竞争的情况则会把这部分的同步直接给优化掉我们在代码层次做了一些无用功字节码无法发现它 JIT 智能地找到了它并进行了优化
因此并不是所有的对象或者数组都会在堆上分配由于 JIT 的存在如果发现某些对象没有逃逸出方法那么就有可能被优化成栈分配
intrinsic
另外一个不得不提的技术点那就是 intrinsic这来源于一道面试题为什么 String 类的 indexOf 方法比我们使用相同代码实现的方法执行效率要高得多
在翻看 JDK 的源码时能够看到很多地方使用了 HotSpotIntrinsicCandidate 注解比如 StringBuffer append 方法
@Override
@HotSpotIntrinsicCandidate
public synchronized StringBuffer append(char c) {
toStringCache = null;
super.append(c);
return this;
}
@HotSpotIntrinsicCandidate 标注的方法 HotSpot 中都有一套高效的实现该高效实现基于 CPU 指令运行时HotSpot 维护的高效实现会替代 JDK 的源码实现从而获得更高的效率
上面的问题中我们往下跟踪实现可以发现 StringLatin1 类中的 indexOf 方法同样适用了 HotSpotIntrinsicCandidate 注解原因也就在于此
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, byte[] str) {
if (str.length == 0) {
return 0;
}
if (value.length == 0) {
return -1;
}
return indexOf(value, value.length, str, str.length, 0);
}
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, int valueCount, byte[] str, int strCount, int fromIndex) {
byte first = str[0];
JDK 中这种方法有接近 400 可以在 IDEA 中使用 Find Usages 找到它们
小结
JIT 是现代 JVM 主要的优化点能够显著地增加程序的执行效率从解释执行到最高层次的 C2一个数量级的性能提升也是有可能的但即时编译的过程是非常缓慢的耗时间也费空间所以这些优化操作会和解释执行同时进行
一般方法首先会被解释执行然后被 3 层的 C1 编译最后被 4 层的 C2 编译这个过程也不是一蹴而就的
常用的优化手段有公共子表达式消除数组范围检查消除方法内联逃逸分析等
其中方法内联通过将短小精悍的代码融入到调用方法的执行逻辑里来减少方法调用上的开支逃逸分析通过分析变量的引用范围对象可能会使用栈上分配的方式来减少 GC 的压力或者使用标量替换来获取更多的优化
这个过程的执行细节并不是那么确定”,在不同的 JVM 甚至在不同的 HotSpot 版本中效果也不尽相同
使用 JITWatch 工具能够看到字节码和机器码的对应关系以及执行过程中的一系列优化操作若想要了解这个工具的更多功能可以点击这里参考 wiki