1. JVM的语言无关性
跨语言(语言无关性):JVM只识别字节码,所以JVM其实跟语言是解耦的,也就是没有直接关联。JVM运行不是翻译Java文件,而是识别Class文件,这个一般称之为字节码。还有像Groovy 、Kotlin、Scala等等语言,它们其实也是编译成字节码,所以它们也可以在JVM上面跑,这个就是JVM的跨语言特征。Java的跨语言性一定程度上奠定了非常强大的java语言生态圈。
2. 解释执行与JIT
2.1. 解释执行
Java程序在运行的时候,主要就是执行字节码指令(程序计数器记录下条字节码指令的地址),一般这些指令会按照顺序解释执行。
模拟JVM中的解释执行如下
package com.liu.jit;
import java.util.List;
/**
*
* 模拟JVM中的解释执行(JVM中的是C++)
*/
public class JavaExecutor {
public static void main(String[] args) {
}
//模拟JVM中的解释器
public void executor_method(List listBytecode) {//字节码列表
for (int i=0;i<listBytecode.size();i++) {
String code =listBytecode.get(i).toString();
switch (code){
case "new": //字节码new指令
//调用硬编码 0110101 汇编码
break;
case "iconst_1"://字节码iconst指令
//调用硬编码
break;
case "istore_1"://字节码istore指令
//调用硬编码
break;
case "iload_1"://字节码iload指令
//调用硬编码
break;
case "return":
//调用硬编码
break;
//................
}
}
}
}
2.2. 热点代码
那些被频繁调用的代码。比如调用次数很高或者在 for 循环里的那些代码,就是热点代码。
如果按照解释执行,效率是非常低的。(这个就是Java以前被C、C++开发者吐槽慢的原因),提高这些热点代码执行效率。就引入JIT编译器进行优化。
2.3. JIT 编译器
为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,不需要再通过解释器解释字节码指令翻译成机器码。
完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称 JIT 编译器。
Spring启动也应该用到即时编译器,不然代码编译后字节码通过解释器一条条解释就很慢。
这些再次编译后的机器码会被缓存在 CodeCache 里,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。
查看JVM所有-XX的参数,可以找到这个CodeCache缓存大小。JVM有三类参数-,-x,-xx。
java -XX: +PrintFlagsFinal –version
JVM提供了一个参数,用来限制 CodeCache 的大小。
-XX:ReservedCodeCacheSize
如果这个空间不足,JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。
3. C1、C2与Graal编译器
3.1. 介绍
在JDK10之前HotSpot 虚拟机中,内置了两个 JIT编译器,分别为 C1 编译器和 C2 编译器。
JDK10开始,内置两个 JIT,分别为 C1 编译器和 Graal 编译器(跟GraalVM知识相关,替代C2编译原来C2编译器的太难维护)。
JDK7之前运行程序指定虚拟机使用的JIT编译器比如是C1还是C2。
3.2. C1编译器
C1编译器只做编译直接将Class文件变成机器码,几乎不会对代码进行优化。
C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求,C1也被称为 Client Compiler。
3.3. C2编译器
C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这种即时编译也被称为Server Compiler。
但是C2代码已超级复杂,无人能维护!所以才会开发Java编写的Graal编译器取代C2(JDK10开始)。
使用C2要做性能优化,会让程序启动时候运行变慢。所以引入分层编译结合C1和C2特点,启动时候先用C1不做代码优化让启动不慢,启动一段时间运行后,要让代码变快,可以再用C2优化。
3.4. 分层编译
3.4.1. 介绍
在 JDK7之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。
JDK7及以后引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,也可以通过参数强制指定虚拟机的即时编译模式。
在 JDK8 中,默认开启分层编译。
通过 java -version 命令行可以直接查看到当前系统使用的编译模式(默认分层编译)。
使用-Xint参数强制虚拟机运行于只有解释器的编译模式,就不会使用JIT的编译器。
使用-Xcomp强制虚拟机运行于只有 JIT 的编译模式下,启动时候项目的字节码会被JIT完全优化为本地的机器码。项目启动过程很慢,但是执行速度很快。因为不需要使用解释器解释字节码指令。
3.4.2. 分层编译的JVM 的执行状态分为了 5 个层次:(不重要、了解即可)
第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;
第 2 层:也称为 C1 编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;
第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;
第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
4. JIT触发条件和优化的技术
4.1. 触发JIT的条件,热点探测技术
在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件。在 HotSpot中字节码是解释执行还是走JIT编译,满足热点探测条件的方法就会使用JIT编译器编译代码进行优化。
热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法
虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当其中一个计数器超过阈值溢出了,就会触发 JIT 编译,就会对这个方法进行优化。
满足热点方法,至少会被C1编译器编译为机器码缓存起来,下次运行会加快速度。
4.1.1. 方法调用计数器
用于统计方法被调用的次数,方法调用计数器的默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次。
查看是客户端模式还是服务端模式,可通过下面命令。
java –version
查看方法计数器的值,可通过下面命令。
java -XX:+PrintFlagsFinal –version
可在程序启动时候设置JVM参数,设置方法计数器的值,
-XX: CompileThreshold=N
4.1.2. 回边计数器
用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),回边在JAVA代码中可以简单理解for循环体中代码执行完一次循环,又从for循环固定某行代码开始执行(这就是个回边)。该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,在服务端模式下是10700。
计算公式(有兴趣可了解)
回边计数器阈值 =方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100。
可通过下面命令,查询相关计算参数的值
java -XX:+PrintFlagsFinal –version
其中OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700。即回边计数器阈值 =10000×(140-33)=10700。
4.2 JIT编译的优化技术
JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。
4.2.1. 方法内联
方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用,减少方法产生的栈帧消耗,一般都是由C2编辑器做优化。
例如以下方法
最终会被优化为
4.2.1.1. 触发方法内联条件
1. 满足热点方法
前面说了满足热点探测技术条件的方法,就是热点方法,JVM会对它们使用方法内联进行优化。这种情况下使用C2编译器优化方法的代码。
可以通过 -XX:CompileThreshold=N 来设置热点方法调用的阈值。
2. 方法体大小满足阈值
热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。
经常执行的方法(内部判断),默认情况下,方法体大小小于 325 字节的都会进行内联,可以通过 -XX:FreqInlineSize=N 来设置大小值。
不是经常执行的方法(内部判断),默认情况下,方法大小小于 35 字节才会进行内联,我可以通过 -XX:MaxInlineSize=N 来重置大小值。
代码演示
设置 JVM 参数:
-XX:+PrintCompilation //在控制台打印编译过程信息
-XX:+UnlockDiagnosticVMOptions //解锁对JVM进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对JVM进行诊断
-XX:+PrintInlining //将内联方法打印出来
启动下面代码
/**
*
* 方法内联
* -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<1000; i++) {
compDemo.add1(1,2,3,4);
}
}
}
结果如下
减少循环次数太少,则不会触发方法内联
4.1.1.2. 提高方法内联触发方式
1. 通过设置 JVM 参数来减小热点方法阈值(调用方法次数阈值)或增加方法体大小阈值。以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存,给注意CodeCahe大小。
2. 在编程中,避免在一个方法中写大量代码,习惯使用小方法体。
3. 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。
4.2.2. 锁消除
在特定情况下,JIT 编译会对代码中的方法锁进行锁消除,不需要方法是热点方法。
例如
在非线程安全的情况下,尽量不要使用线程安全容器,比如 StringBuffer。由于 StringBuffer 中的 append 方法被 Synchronized 关键字修饰,会使用到锁,从而导致性能下降。
但实际上,在以下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。
把锁消除关闭,测试发现性能差别有点大。
-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试).。
-XX:-EliminateLocks 关闭锁消除。
4.2.3. 栈上分配
4.2.3.1. 原理
当一个方法是热点方法,方法中创建对象过程中,根据逃逸分析,分析该对象作用域,是没有逃逸。如果JDK开启了标量替换技术。则创建对象时候会对该对象拆分,分配对象的成员变量在到栈中(虚拟机的栈在硬件上实现就是CPU里头的高速缓存或者寄存器),或者另一种说法分配到栈帧中和寄存器中。
逃逸分析技术和标量替换都属于JIT的优化技术。
触发栈上分配流程
4.2.3.2. 对象逃逸分析
逃逸分析技术就是一个对象在方法中定义后,其作用域不会离开该方法,该对象就是没有逃逸。
根据对象的作用域,逃逸分为下面几种
方法逃逸:一个方法中定义的对象通过传参传递到其它方法中。
线程逃逸:一个方法中定义的对象,可能被外部线程访问到,比如在这个方法中创建一个新的线程,将定义的局部变量赋值给它访问。
从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。
逃逸分析技术属于JIT的优化技术,所以必须要符合热点代码,JIT才会优化。
4.2.3.3. 标量替换
一个方法,满足热点方法,且其中创建的对象没有逃逸。JDK开启标量替换情况下,则会创建对象进行拆分,分配对象的成员变量在栈帧或者寄存器中。原本的对象就无需在堆中分配空间,方法结束后释放栈帧,这些数据就会被清理,加快运行效率。这种编译优化就叫做标量替换。
参数
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
-XX:-DoEscapeAnalysis 关闭逃逸分析
-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
-XX:-EliminateAllocations 关闭标量替换
标量替换效果
比如下面foo方法中创建Teacher对象,标量替换后效果如同foo1方法那样。
注意
1. 如果对象太大,就不会进行标量替换分配到栈中,只能分配到堆中。
4.2.3.4. 栈上分配例子分析
该例子在JDK8下运行。
开启逃逸分析和标量替换进行栈上分配。
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
package com.liu.jit;
/**
* @author King老师
* 逃逸分析-栈上分配
* -XX:+DoEscapeAnalysis 开启逃逸分析
* -XX:+EliminateAllocations 开启标量替换
* -XX:+PrintGC 打印GC日志查看是否有垃圾回收,说明是不是栈上分配
* -XX:+PrintFlagsFinal 表示打印出所有参数选项在运行程序时生效的值
*/
public class EscapeAnalysisTest {
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
// 满足热点探测技术,触发jit编译进行逃逸分析
for (int i = 0; i < 500000000; i++) {//5000万次---5000万个对象
allocate();
}
System.out.println((System.currentTimeMillis() - start) + " ms");
Thread.sleep(600000);
}
static void allocate() {//逃逸分析(不会逃逸出方法)
//这个myObject引用没有出去,也没有其他方法使用
MyObject myObject = new MyObject(2020, 2020.6);
}
static class MyObject {
int a;
double b;
MyObject(int a, double b) {
this.a = a;
this.b = b;
}
}
}
这段代码调用的方法allocate满足热点方法的阈值。开启逃逸分析下,方法中创建的Myboject对象属于不可逃逸。开启标量替换下。创建Myboject对象过程满足栈上分配条件,每次运行完allocate方法就释放栈帧就会释放Myboject对象占用的空间,运行速度非常快。
关闭逃逸分析运行,速度变慢。
-XX:-DoEscapeAnalysis
不关闭逃逸分析,关闭标量替换,速度变慢。
-XX:-EliminateAllocations
结论
1. 方法中创建对象进行栈上分配,方法调用完释放栈帧,就会释放创建对象占用的资源,如果是频繁的调用此方法则可以得到很大的性能提高。
2. 方法中创建对象没有栈上分配,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢。
代码验证
开启GC打印日志
-XX:+PrintGC
进行栈上分配下,看到没有GC日志
关闭逃逸分析
-XX:-DoEscapeAnalysis
可以看到关闭了逃逸分析,JVM在频繁的进行垃圾回收(GC),正是这一块的操作导致性能有较大的差别。
注意
1. 测试时候不要用Debug模式运行,可能不会触发栈上分配,我这边用IDEA测试运行代码,用正常运行才看到效果。