JVM执行引擎深入理解
前端编译与后端编译
java程序的编译分为两个阶段:
- java文件编译成class文件,这个过程称为前端编译
- class文件由JVM编译成操作系统识别的机器指令,这个过程称为后端编译
前端编译:是由编译器来做编译,是在JVM之外。前端编译一般不会做很多性能的优化,否则编译器的实现就会复杂。
字节码指令转换成机器指令
解释执行与编译执行
执行引擎要做的就是,根据操作系统的不同,将字节码指令转换成相应机器能执行的机器指令。
解释执行:一条字节码指令翻译成一条机器指令,无脑翻译。早期JVM执行引擎就是这样做的。
缺点:上层语言经过两层转换才能执行,相比C、C++效率低,执行速度慢。
编译执行:JVM维护⼀个缓存CodeCache,将那些字节码指令,提前编译出来,放到缓存⾥。到执⾏的时候,直接从缓存中查出来就好了。 先编译后执行。
问题:将哪些字节码放到缓存呢?
U-91KQD9WX-1828:~ qj$ java -version
java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, mixed mode) // 混合模式(默认)
U-91KQD9WX-1828:~ qj$ java -Xint -version
java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, interpreted mode) // 解释执行模式
U-91KQD9WX-1828:~ qj$ java -Xcomp -version
java version "1.8.0_291"
Java(TM) SE Runtime Environment (build 1.8.0_291-b10)
Java HotSpot(TM) 64-Bit Server VM (build 25.291-b10, compiled mode) // 编译执行模式
即时编译器JIT(Just In Time Compiler):将那些运⾏频率最⾼的热点代码提前编译出来,放到缓存⾥。完成这个任务的编译器叫JIT。
问题:怎么识别热点代码呢?
热点代码识别
热点探测(Hot Spot Code Detection): 探测哪些是热点代码。
HotSpot 虚拟机中采⽤的是⼀种基于计数器的热点探测⽅法,HotSpot 为每个⽅法准备了两类计数器: ⽅法调⽤计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
1. ⽅法调⽤计数器(Invocation Counter):
统计⽅法被调⽤的次数。每次调⽤⼀个⽅法时,就记录⼀次这个⽅法的执⾏次数。当他的执⾏次数⾮常多,超过了某⼀个阈值,那么这个⽅法就可以认为是热点⽅法。这个⽅法对应的代码,⾃然也就是热点代码了。这时就可以向JIT提交⼀个针对该⽅法的代码编译请求了。
-XX:CompileThreshold : 设置⽅法调用计数器的阈值, 默认10000次
java -XX:+PrintFlagsInitial -version | grep 'CompileThreshold' : 查询⽅法调用计数器的阈值java -XX:+PrintFlagsInitial -version : JVM执行过程中的参数
2. 回边计数器(Back Edge Counter):
统计⼀个⽅法中循环体代码执⾏的次数,在字节码中遇到控制流向后跳转的指令(回边指令)就称为 “回边(Back Edge)”。对应上层语言的循环体。
很显然建⽴回边计数器统计的⽬的是为了发现⼀个⽅法内部频繁的循环调⽤。回边计数器在服务端模式下默认的阈值是 10700。
回边指令示例:goto指令
OSR编译:
热点代码的编译
在HotSpot虚拟机中,热点代码在被JIT实时编译的过程中,JIT编译器会运⽤很多经典的编译优化技术来实现对字节码指令的优化,让编译出来的记过运⾏效率更⾼。
HotSpot虚拟机中内置了两个即时编译器,其中前两个编译器存在已久,分别被称为“客户端编译器”(ClientCompiler)和“服务端编译器”(Server Compiler),简称为C1编译器和C2编译器(也叫Opto编译器)
客户端编译(C1)(即时编译器JIT)
初级翻译。C1会对字节码进⾏简单和可靠的优化,耗时短,以达到更快的编译速度。启动快,占⽤内存⼩。但是翻译出来的机器码优化程度不太⾼。⽐较适合于⼀些⼩巧的桌⾯应⽤。
服务端编译(C2)(即时编译器JIT)
高级翻译。C2会对字节码进⾏更激进的优化,优化后的佮代码执⾏效率更⾼。但是相应的,⼯作量也变得更⼤了。C2的启动更慢,占⽤内存也更多。进⾏耗时较⻓的优化,以及激进优化,但优化的代码执⾏效率更⾼。启动慢,占⽤内存多,执⾏效率⾼。⽐较适合于⼀些资源充裕的服务级应⽤。
为了在程序启动响应速度与运⾏效率之间达到最佳平衡,HotSpot虚拟机在编译⼦系统中加⼊了分层编译的功能,根据编译器编译、优化的规模与耗时,划分出不同的编译层次。
-XX:TieredStopAtLevel=1 : 指定使⽤哪⼀层编译模型
补充:JITWatch工具可以查看具体是使用的哪一层
后端编译优化(热点代码)
即使程序员写出很烂的代码,通过JVM对代码做出一些优化,也能有不错的执行效率,这就是编译有优化技术。
编译优化有很多策略,大部分具体实现都需要在汇编层面实现。下面了解几个具体的优化机制。
1. 方法内联Inline
方法内联:把目标方法代码复制到发起调用的方法中。这样可以减少频繁创建栈帧的性能开销。
方法内联前提:发⽣⽅法内联的前提是要让这个⽅法循环⾜够的次数,成为热点代码。
方法内联看起来比较简单,实际上,Java 虚拟机中的内联过程却远没有想象中那么容易。⽅法内联往往还是很多后续优化⼿段的基础。
// 加入以下参数可以看到执行日志
-XX:+PrintGC 打印GC日志
-XX:+PrintCompilation
-XX:+UnlockDiagnosticVMOptions
-XX:+PrintInlining 打印内联决策,通过这个指令可以看到哪些⽅法进⾏了内联。默认是关闭的。另外,需要配合-XX:+UnlockDiagnosticVMOptions 使⽤。
-XX:+Inline 启⽤⽅法内联。默认开启。
-XX:InlineSmallCode=size ⽤来判断是否需要对⽅法进⾏内联优化。如果⼀个⽅法编译后的字节码⼤⼩ > 这个值,就⽆法进⾏内联。默认值是1000bytes。
-XX:MaxInlineSize=size 设定内联⽅法的最⼤字节数。如果⼀个⽅法编译后的字节码 > 这个值,则⽆法进⾏内联。默认值是35bytes。
-XX:FreqInlineSize=size 设定热点⽅法进⾏内联的最⼤字节数。如果⼀个热点⽅法编译后的字节码⼤于这个值,则⽆法进⾏内联。默认值是325bytes。
-XX:MaxTrivialSize=size 设定要进⾏内联的琐碎⽅法的最⼤字节数(Trivial Method:通常指那些只包含⼀两⾏语句,并且逻辑⾮常简单的⽅法。默认值是6bytes。
上层代码优化逻辑建议:
1. 在编程中,尽量多写⼩⽅法,避免写⼤⽅法。⽅法太⼤不光会导致⽅法⽆法内联,另外,成为热点⽅法后,还会占⽤更多的CodeCache。
2 . 在内存不紧张的情况下,可以通过调整JVM参数,减少热点阈值或增加⽅法体阈值,让更多的⽅法可以进⾏内联。
3 . 尽量使⽤final, private,static关键字修饰⽅法(编译时确定代码的执行)。⽅法如果需要继承(也就是需要使⽤invokevirtual指令调⽤),那么具体调⽤的⽅法,就只能在运⾏这⼀⾏代码时才能确定,编译器很难在编译时得出绝对正确的结论,也就加⼤了编译执⾏的难度。
2. 逃逸分析
如果能证明⼀个对象不会逃逸到⽅法或线程之外,那么 JIT 就可以为这个对象实例采取后续⼀系列的优化措施。第一个是标量替换,第二个是栈上分配
3. 锁消除 lock elision
这是在逃逸分析的基础上可以做的优化措施。这个优化措施主要是针对 synchronized 关键字。当 JVM 检测到⼀个锁的代码不存在多线程竞争时,会对这个对象的锁进⾏锁消除。
(synchronized 关键字在Class⽂件中添加了monitorenter和monitorexit两个字节码指令)
注意:多线程并发资源竞争是⼀个很复杂的场景,所以通常要检测是否存在多线程竞争是⾮常麻烦的。但是有⼀种情况很简单,如果⼀个⽅法没有发⽣逃逸,那么他内部的锁都是不存在竞争的。
public class LockElisionDemo {public static String BufferString(String s1, String s2) {// StringBuffer是线程安全的StringBuffer sb = new StringBuffer();sb.append(s1);sb.append(s2);return sb.toString();}public static String BuilderString(String s1, String s2) {// StringBuilder是非线程安全的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));}
}
BufferString ⽅法只是在main这⼀个线程⾥调⽤,不存在线程竞争,所有这个synchronized 同步锁是没有作⽤的,因此,在触发了 JIT 后,JVM 会在编译时就会将这个⽆⽤的锁消除掉。
默认会做锁消除,可以手动配置-XX:-EliminateLocks( 主动关闭锁清除), 观察执行耗时,发现在关闭锁消除时,耗时明显BufferString耗时更长。