深入理解JVM执行引擎
JVM 执行引擎(Execution Engine)是 Java 虚拟机中最核心的组件之一,它的作用是负责将字节码解释或编译为本地机器指令并执行。
前端编译与后端编译
编译类型 | 作用 | 示例 |
---|---|---|
前端编译(Front-End) | 将高级语言 → 中间表示(IR / 字节码) | javac 把 Java 编译为 .class |
后端编译(Back-End) | 将中间表示 → 目标代码(机器码) | JVM 的 JIT 把字节码编译为机器码 |
这种方式让jvm可以支持更多语言,只要能通过前端编译编译出满足 JVM 规范的 Class 文件,就可以提交到 JVM 虚拟机执行。
字节码指令是如何执行的
解释执行与编译执行
-
解释执行
JVM 解释器逐条解释执行字节码。操作数栈和局部变量表在内存中模拟执行环境。启动速度快,但是这种方式需要在上层语言和机器码之间经过中间一层JVM字节码的转换,执行效率较低。 -
编译执行(Compilation / JIT)
JIT 编译器把热点字节码(一块一块的指令)编译成本地机器码。机器码缓存起来,后续执行更快。需要更多启动时间和内存,但长期运行性能高。
java -version就可以看到当前使用的是哪种执行模式。默认是混合模式
-Xint 纯解释执行模式, -Xcomp纯编译模式
热点代码识别
JIT 即时编译的前提就是需要识别出热点代码。要知道某段代码是不是热点代码,是不是需要触发即时编译,这个行为称为**“热点探测”(Hot Spot Code Detection)。**
在 HotSpot 虚拟机中采用的是一种基于计数器的热点探测方法。HotSpot 为每个方法准备了两类计数器: **方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。**当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。
- 方法调用计数器
方法调用技术器的默认阈值是10000次,这个阈值可以通过虚拟机参数**-XX:CompileThreshold**来设定。当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将该方法的调用计数器值加一,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。一旦已超过阈值的话,将会向即时编译器提交一个该方法的代码编译请求。整体流程如下图:
- 回边计数器
回边计数器,它的作用是统计一个方法中循环体代码执行的次数。在字节码中遇到控制流向后跳转的指令就称为“回边(Back Edge)”,很显然建立回边计数器统计的目的是为了发现一个方法内部频繁的循环调用。回边计数器在服务端模式下默认的阈值是 10700
回边计数器阈值 =方法调用计数器阈值(CompileThreshold)×(OSR比率(OnStackReplacePercentage)-解释器监控比率(InterpreterProfilePercentage)/100
OnStackReplacePercentage默认值为140,InterpreterProfilePercentage默认值为33,如果都取默认值,那Server模式虚拟机回边计数器的阈值为10700。回边计数器阈值 =10000×(140-33)=10700
两种 JIT 编译器模式:C1客户端编译器与C2服务端编译器
-
C1客户端编译器
C1就相当于是一个初级翻译。编译过程中,C1会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。启动快,占用内存小。但是翻译出来的机器码优化程度不太高。比较适合于一些小巧的桌面应用,因此也称为客户端编译器 -
C2服务端编译器
C2就相当于是一个高级翻译。编译过程中,C2会对字节码进行更激进的优化,优化后的佮代码执行效率更高。但是相应的,工作量也变得更大了。C2的启动更慢,占用内存也更多。进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高。启动慢,占用内存多,执行效率高。比较适合于一些资源充裕的服务级应用,因此也称为服务端编译器。
分层编译
由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能,分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次。
等级 | 描述 | 性能 |
---|---|---|
0 | 程序纯解释执行,并且解释器不开启性能监控功能(Profiling) | 1 |
1 | 使用C1编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化。不开启性能监控功能。 | 4 |
2 | 仍然使用C1编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。 | 3 |
3 | 仍然使用C1编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。 | 2 |
4 | 使用C2编译器将字节码编译为本地代码,相比起C1编译器,C2编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。 | 5 |
参数 -XX:TieredStopAtLevel=1
可以指定使用哪一层编译模型。
后端编译优化技术
方法内联 Inline
方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。消除调用跳转,利于后续优化(如常量传播、逃逸分析、死代码消除)。过大方法或递归调用通常不内联。
发生方法内联的前提是要让这个方法循环足够的次数,成为热点代码。
JVM 参数:-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions可以看到执行日志
JDK8中,提供了多个跟Inline内联相关的参数,可以用来干预内联行为。
- -XX:+Inline 启用方法内联。默认开启。
- -XX:InlineSmallCode=size 用来判断是否需要对方法进行内联优化。如果一个方法编译后的字节码大小大于这个值,就无法进行内联。默认值是1000bytes。
- -XX:MaxInlineSize=size 设定内联方法的最大字节数。如果一个方法编译后的字节码大于这个值,则无法进行内联。默认值是35byt
- -XX:FreqInlineSize=size 设定热点方法进行内联的最大字节数。如果一个热点方法编译后的字节码大于这个值,则无法进行内联。默认值是325bytes。
- -XX:MaxTrivialSize=size 控制“简单方法”的最大字节码大小,主要影响 哪些方法被视为“简单方法”,从而可以进行更激进的内联和优化。默认值是6bytes。
- -XX:+PrintInlining 打印内联决策,通过这个指令可以看到哪些方法进行了内联。默认是关闭的。另外,这个参数需要配合-XX:+UnlockDiagnosticVMOptions 参数使用。
逃逸分析 Escape Analysis
当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。
左侧的代码中,t对象,不会被外部引用,只会在方法中使用,所以不会发生逃逸。而右侧的代码中,t对象就很明显被其他方法使用了,这就会产生逃逸。JDK8 中默认开启了逃逸分析,可以添加参数-XX:-DoEscapeAnalysis
主动关闭逃逸分析。
如果能证明一个对象不会逃逸到方法或线程之外,那么 JIT 就可以为这个对象实例采取后续一系列的优化措施。
标量替换(Scalar Replacement)和栈上分配( Stack Allocations)。把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。
JDK8 中默认开启了标量替换,可以通过添加参数
-XX:-EliminateAllocations
主动关闭标量替换。
一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型及reference类型等)都不能再进一步分解了,那么这些数据就可以被称为标量。
堆内存是对应整个Java进程的。如果发生了线程逃逸,那么堆中的同一个对象,可能隶属于多个线程,这时要将堆中的对象挪到虚拟机栈中,那就必须扫描所有的虚拟机栈,看看在这个虚拟机栈对应的线程中是否引用了这个对象。这个性能开销是难以接受的。
栈是一个非常小的内存结构,他也不可能像堆中那么豪横的使用内存空间,所以,也必须要对对象进行最大程度的瘦身,才能放到栈中。而瘦身的方式,就是去掉对象的markWord中的补充信息,拆分成最精简的标量。所以,要开启栈上分配,标量替换也是不可或缺的。
也就是说调用了
hashCode()
就不会被标量替换了。可以通过打印gc日志,并且调小堆空间来验证。
锁消除 lock elision
这个优化措施主要是针对 synchronized 关键字。当 JVM 检测到一个锁的代码不存在多线程竞争时,会对这个对象的锁进行锁消除。
多线程并发资源竞争是一个很复杂的场景,所以通常要检测是否存在多线程竞争是非常麻烦的。
但是有一种情况很简单,如果一个方法没有发生逃逸,那么他内部的锁都是不存在竞争的。
public class LockElisionDemo {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 sb = new StringBuilder();sb.append(s1);sb.append(s2);return sb.toString();}public static void main(String[] args) {long startTime = System.currentTimeMillis();for (int i = 0; i < 100000000; i++) {BufferString("aaaaa","bbbbbb");}System.out.println("StringBuffer耗时:"+(System.currentTimeMillis()-startTime));long startTime2 = System.currentTimeMillis();for (int i = 0; i < 100000000; i++) {BuilderString("aaaaa","bbbbbb");}System.out.println("StringBuilder耗时:"+(System.currentTimeMillis()-startTime2));}
}
StringBuffer 是线程安全的,他的append和toString都是加了 synchronized 同步锁的,而 StringBuilder 则没有加。synchronized 关键字其实是在Class文件中添加了monitorenter和monitorexit两个字节码指令的,所以,StringBuffer显然要比 StringBuilder 更慢。
默认进行的锁消除使得两者执行耗时差不多:
StringBuffer耗时:1521
StringBuilder耗时:1039
添加一个JVM 参数:-XX:-EliminateLocks
主动关闭锁消除后耗时差距就明显更大了
StringBuffer耗时:2461
StringBuilder耗时:1049
编译器友好编程
- 在编程中,尽量多写小方法,避免写大方法。方法太大不光会导致方法无法内联,另外,成为热点方法后,还会占用更多的CodeCache。
- 在内存不紧张的情况下,可以通过调整JVM参数,减少热点阈值或增加方法体阈值,让更多的方法可以进行内联。
- 尽量使用final, private,static关键字修饰方法。方法如果需要继承(也就是需要使用invokevirtual指令调用),那么具体调用的方法,就只能在运行这一行代码时才能确定,编译器很难在编译时得出绝对正确的结论,也就加大了编译执行的难度。final 方法和类的调用更容易被优化。
- 尽量避免逃逸。尽量让对象局部使用,不传递给外部,帮助逃逸分析做标量替换。避免把对象存入全局变量、静态字段等。
- 合理使用基本类型。基本类型操作更快,避免不必要的装箱拆箱。标量替换会把对象拆成基本类型变量,提高性能
- 尽量避免反射和动态代理。这些机制难以静态分析,影响内联和逃逸分析。