JVM系列文章目录
前言
本文基于JDK1.8,Hotspot版本的JVM。
JVM编译
我们在初识JVM那篇博文介绍过,class代码编译器分两种,字节码解释器和JIT。
解释编译与JIT
- 解释编译:Java程序在运行时,指令按照一般通过字节码解释器顺序解释执行。
- JIT(Just In Time Compiler):为了提高热点代码的执行效率,JVM将热点代码直接转换成计算机能识别的机器码进行缓存,这样调用热点代码到时候就不用在通过字节码解释器慢慢执行,可以直接拿着机器码运行,这个过程就叫及时编译,使用的编译器就叫及时编译器。
及时编译器的种类
在Hotspot中中及时编译器有两种:C1编译器和C2编译器。
- C1编译器 :这是简单快捷的编译器,主要侧重于局部优化,适用于执行时间较短或对启动性能有要求的程序,例如, GUI 应用对界面启动速 度就有一定要求,C1 也被称为 Client Compiler。
- C2编译器:为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这种即时编译 也被称为 Server Compiler。
热点代码
热点代码其实就是频繁被调用的代码,当然这个频繁不是几百次,在JVM中有专门的阀值参数限制。这些热点代码会被编译器缓存起来,这个缓存大小在JVM中也是有限制的,通过参数-XX:ReservedCodeCacheSize
调整大小,如果缓存的大小不够的话,JVM只会解释执行,这样性能就会下降一个级别,而JVM还会契而不舍的尝试及时编译,这有导致CPU占用上升。
我们通过java -XX:+PrintFlagsFinal –version
来查询一下。
热点探测
JVM通过热点探测来判断那些代码是热点代码。在Hotspot中热点探测是基于计数器来实现,通过记录每个方法被调用的次数是否超过阀值来判断是否为热点代码。
计数器分为两类:
-
方法计数器(Invocation Counter):
用于统计方法被调用的次数,方法调用计数器的默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次,我们用的都是服务端,这个可以通过java –version
查询,方法为热点代码的调用的阀值可以可通过-XX: CompileThreshold
来设定。
-
回边计数器(Back Edge Counter):
用于统计一个方法在循环体中的调用次数。(在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)),该值用于计算是否触发 C1 编译的阀值, 在不开启分层编译的情况下,在服务端模式下是 10700。回边计数器阀值值 =方法调用计数器阈值(CompileThreshold)×(OSR 比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage) / 100
我们通过java -XX:+PrintFlagsFinal –version
查证一下,OSR比率默认值是140,解释器监控比率默认值是33,那回边计数器阀值= 1000 ×(140 -33)/ 100 = 10700。
可通过-XX: OnStackReplacePercentage
来设置。建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈 值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。
JVM 的运用
在 Java7 之前,需要根据程序的特性来选择对应的 JIT, 虚拟机默认采用解释器和其中一个编译器配合工作。
Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数 -client / server
强制指定虚拟机的即时编译模式。
分层编译
在启用分层编译的时候-XX: CompileThreshold
指定的阀值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边 计数器之和超过方法计数器阀值时,就会触发 JIT 编译器。
而在分层编译的情况下, -XX: OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
在 Java8 中,默认开启分层编译。
通过java -version
命令行可以直接查看到当前系统使用的编译模式(默认分层编译)。
-Xint
参数强制虚拟机运行于只有解释器的编译模式下。
-Xcomp
强制虚拟机运行于只有 JIT 的编译模式下。
JVM 的执行状态分为了 5 个层次
- 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译。
- 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling。
- 第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译。
- 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译。
- 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进 优化。
及时编译器的优化技术
方法内联
方法内联指的是JIT为了避免真实的方法调用,直接将被调用方法代码复制到调用方法中。这样就可以减少方法的出栈入栈,加快程序执行效率。
不过它也有限制,如果这个方法体太大了,JVM 将不执行内联操作,而方法体的大小阀值也可以通过参数-XX:FreqInlineSize
设置来优化:热点方法,默认情况下,方法体大小小于 325 字节的都会进行内联。
下面我通过代码来解释一下方法内联
/**
* @author Abfeathers
* @date 2021/3/26
* @Description 方法内联
* -XX:+PrintCompilation 在控制台打印编译过程信息
* -XX:+UnlockDiagnosticVMOptions 解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
* -XX:+PrintInlining 将内联方法打印出来
*
*/
public class CompDemo {
private int add1(int x1, int x2, int x3, int x4) {
return add2(x1, x2) + add2(x3, x4);
}
private int add2(int x1, int x2) {
return x1 + x2;
}
private int add(int x1, int x2, int x3, int x4) {
return x1 + x2+ x3 + x4;
}
public static void main(String[] args) {
CompDemo compDemo = new CompDemo();
//方法调用计数器的默认阈值10000次,我们循环遍历超过需要阈值
for(int i=0; i<1000000; i++) {
compDemo.add1(1,2,3,4);
}
}
}
上面的方法调用最后会被优化成
我们加上这几个VM参数来演示一下内联
-XX:+PrintCompilation 在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions 解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
-XX:+PrintInlining 将内联方法打印出来
我们把循环调用调小到100,让它不能变成热点代码。
提供方法内联的几种方式:
- 通过设置 JVM 参数来减小热点阀值或增加方法体阀值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存,所以我们还要相应的调整热点代码缓存区的大小;
- 在编程中,避免在一个方法中写大量代码,习惯使用小方法体,大的方法不会被内联;
- 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。
锁消除
这个其实挺有意思的,一般代码规范都会告诉我们不要在非线程安全的情况下,使用线程安全容器,比如StringBuffer。 由于 StringBuffer 中的 append 方法被 Synchronized 关键字修饰,会使用到锁,从而导致性能下降。
但是如果你这段是热点代码的话,JIT在判断后觉得你的锁可以被消除之后,就会将你的锁消除,这样就能提升代码执行效率。
下面这段代码由于只会被当前线程访问,不会被其他线程访问,那这个锁就可以被消除了,因为没有其他线程来竞争。
/**
* @author Abfeathers
* @date 2021/3/26
* @Description
* -XX:+EliminateLocks开启锁消除(jdk1.8默认开启)
* -XX:-EliminateLocks 关闭锁消除
*/
public class UnLock {
public static void main(String[] args) {
long timeStart1 = System.currentTimeMillis();
for(int i=0; i<10000000; i++) {
BufferString("aaa","bbb");
}
long timeEnd1 = System.currentTimeMillis();
System.out.println("StringBuffer花费的时间" + (timeEnd1 - timeStart1));
long timeStart2 = System.currentTimeMillis();
for(int i=0; i<10000000; i++) {
BuilderString("ccc","ddd");
}
long timeEnd2 = System.currentTimeMillis();
System.out.println("StringBuilder花费的时间" + (timeEnd2 - timeStart2));
}
public static String BufferString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
public static String BuilderString(String s1, String s2) {
StringBuilder sd = new StringBuilder();
sd.append(s1);
sd.append(s2);
return sd.toString();
}
}
运行结果,这个差异是很小的。
由于JDK1.8默认是开启锁消除的,我这里通过设置-XX:-EliminateLocks
将锁消除关闭,在运行一下。这个差距一下子就出来了。
标量替换
标量替换需要依赖逃逸分析,逃逸分析我们都知道,当JVM发现对象不会出当前方法,就只能活在当前方法这个作用域内。如果这个对象可以被拆分的话,在程序运行的时候就不会创建这个对象,而是创建这个对象的各个成员变量,这样这些成员变量就可以直接放在栈或者寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换(前提是需要开启逃逸分析)。
/**
* @author Abfeathers
* @date 2021/3/26
* @Description 标量替换
* -XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
* -XX:-DoEscapeAnalysis 关闭逃逸分析
*
* -XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
* -XX:-EliminateAllocations 关闭标量替换
*/
public class VariableDemo {
public void foo() {
Person person = new Person();
person.name = "abfeathers";
person.age = 18;
//to do something
}
public void foo1() {
String name = "abfeathers";
int age = 18;
//to do something
}
}
class Person{
String name;
String sexType;
int age;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getSexType() {
return sexType;
}
public void setSexType(String sexType) {
this.sexType = sexType;
}
public int getAge() {
return age;
}
public void setAge(int age) {
this.age = age;
}
}
这个不太好演示,我这里直接说明一下,在运行的时候这一段代码
会被直接替换成
上一篇: 直接内存与JVM源码分析