文章目录
- 深入JVM:详解JIT即时编译器
- 一、序言
- 二、基础概念
- 1、何为JIT即时编译
- 2、热点代码
- 三、HotSpot内置的即时编译器
- 1、C1编译器
- 2、C2编译器
- 3、分层编译
- 3.1 协作流程
- 四、常见JIT优化技术
- 1、方法内联
- 2、逃逸分析
- (1)同步锁消除
- (2)栈上分配
- (3)标量替换
- 五、后记
深入JVM:详解JIT即时编译器
一、序言
对于Java工程师而言,深入理解JVM(Java虚拟机)不仅是掌握Java程序运行机制的基础,也是提升系统性能、优化应用和解决复杂问题能力的重要一步,更是Java进阶之路的重中之重。
本文小豪将带大家认识JIT即时编译器,介绍主流HotSpot虚拟机内置的几款即时编译器,同时结合代码实例,着重讲解JIT优化手段,话不多说,我们直接进入正题。
二、基础概念
在之前,网上可能经常说Java语言的运行速度不如C或C++,Java运行速度慢主要是因为它是解释执行的,而C或C++是编译执行的,解释执行需要通过JVM虚拟机将字节码实时翻译成机器码(边翻译边执行),才能运行在操作系统上,这个过程会比编译执行慢。
但现在再说这个结论就不太对了,随着JIT即时编译技术的发展,性能差距正在逐步缩小,甚至在某些情况下,执行速度是优于C或C++的。
1、何为JIT即时编译
在Java程序执行过程中,主要依靠字节码指令来进行。这些字节码指令按照顺序逐行被实时翻译成机器码,以便于在操作系统上运行。当某些方法或代码块(它们都对应特定的字节码)被频繁调用时,这部分代码就被视为热点代码。
JVM虚拟机会针对性的对这部分热点代码进行优化编译,将它们从字节码转换为本地机器码,然后将优化后的本地机器码缓存起来,后续再执行时可以直接从缓存中获取并运行,无需再次编译。
完成这个过程的编译器,就称为JIT即时编译器(Just In Time Compiler)。JIT即时编译器显著提升了Java的性能,缩小了与其它编译型语言运行速度的差距。
2、热点代码
热点代码是指在运行过程中被频繁执行的代码,被即时编译的热点代码有两种,分别是:
- 被多次调用的方法
- 被多次执行的循环体
而热点代码由热点探测进行发现,热点探测基于计数器,JVM虚拟机会为每个方法建立对应的计数器,统计方法的执行次数、方法内的循环次数等,如果计数器超过指定阈值,则标识其为热点代码。
方法调用计数器:统计方法被调用的次数
回边计数器:统计方法内循环体代码执行的次数
三、HotSpot内置的即时编译器
主流的HotSpot虚拟机内置了两个JIT编译器:C1(Client Compiler)编译器和C2(Server Compiler)编译器,在常用的JDK 8版本中,C1和C2是相互协同工作的。
1、C1编译器
C1编译器注重启动时间和编译时间,编译速度较快,但优化程度相对较低。
C1的优化策略相对简单,也比较轻量级,比如方法内联、公共子表达式消除,C1编译器编译的代码的执行速度通常比C2编译器慢。
2、C2编译器
C2编译器侧重于深度优化,与C1正好相反,C2编译器的编译时间较长,但优化的程度较高。
C2的优化策略比较深度,会进行更高级的优化,比如逃逸分析等,C2编译器编译的代码的执行速度通常比C1编译器快。
C2编译器由于深度优化代码过于复杂,已经很难维护了,从JDK 10开始,Graal编译器已经代替了C2编译器,与C1编译器协同工作
3、分层编译
由于C1和C2编译器在优化方面有不同的侧重点:C1侧重编译速度,C2侧重深度优化。
从JDK 7开始,采用分层编译的方式,C1和C2相互协同,共同发挥作用,HotSpot虚拟机根据代码的运行性能动态的选择具体采用哪个编译器完成优化。
在分层编译中,整个优化过程分为五个层次,对应着不同的优化策略:
- 第0层:解释器执行,开启
Profiling
性能监控功能 - 第1层:C1编译器执行,不开启
Profiling
,执行不带Profiling
功能的C1编译代码 - 第2层:C1编译器执行,开启
Profiling
,仅执行带部分Profiling
功能的C1编译代码(方法调用次数和循环次数) - 第3层:C1编译器执行,开启
Profiling
,执行带全部Profiling
功能的C1编译代码 - 第4层:C2编译器执行,执行C2编译代码
Profiling
性能监控是指在程序执行过程中,收集程序执行的各项数据,包括方法调用次数、循环次数、类型转换等等,收集的数据越多,额外的性能开销越大。第1层到第3层都是由C1编译器执行的,显然,第1层由于不开启
Profiling
,执行性能会高于第2层,同时第2层只收集少量数据,第2层的执行性能也会高于第3层(总体性能:第4层 > 第1层 > 第2层 > 第3层 > 第0层)
3.1 协作流程
在分层编译中,C1和C2编译器是相互协作的,它们都由各自独立的线程来处理编译任务,线程内部维护着任务队列,用来存放待编译的热点代码,具体协作流程如下:
- 默认情况下,热点方法首先会被第3层的C1编译器执行,当
Profiling
性能监控收集到的数值达到阈值,JVM会评估C1和C2的优化性能(字节码数较少等)- 若判定C1和C2的执行效率相当:交由第1层的C1编译器进行优化,停止
Profiling
监控,不再收集运行信息 - 若判定C2执行效率相当优于C1:交由第4层的C2编译器进行深度优化
- 若判定C1和C2的执行效率相当:交由第1层的C1编译器进行优化,停止
- C1线程忙碌时,会直接交由第4层的C2编译器进行深度优化
- C2线程忙碌时,会先交由第2层的C1编译器执行优化,进行信息的初步收集,待C2线程空闲时,再交由第3层的C1编译器执行
四、常见JIT优化技术
JIT即使编译器主要是通过方法内联和逃逸分析两种技术来实现代码的优化。
1、方法内联
方法内联其实就是将被调用方法的字节码直接插入到调用该方法的地方,调用时减少了栈帧的创建销毁开销。
举个例子:
实际上JIT修改的是字节码指令,为便于理解,本文使用代码举例
// 方法内联
public double methodInlining() {
double num1 = Math.random();
double num2 = Math.random();
// 等价于 -> double sum = num1 + num2
double sum = add(num1, num2);
return sum;
}
public double add(double num1, double num2) {
return num1 + num1;
}
在代码中,方法内联会将其中的add(num1, num2)
方法转换为实际的num1 + num1
,直接进行计算操作,避免了方法调用。
这里我们简单测试一下:
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
JitTest demo = new JitTest();
for (int i = 0; i < 1000000; i++) {
demo.methodInlining();
}
long endTime = System.currentTimeMillis();
System.out.println("执行耗时:" + (endTime - startTime));
}
测试代码也比较简单,循环调用百万次,统计总耗时。
首先我们测试不启用JIT优化下这段代码的执行耗时,禁用JIT优化只需要添加JVM参数-Xint
即可,禁用后,控制台输出:
执行耗时:881
取消掉禁用参数-Xint
,启用JIT优化,重新执行这段测试代码:
执行耗时:47
我们进一步验证一下add
方法是否进行了方法内联优化,添加三个JVM参数-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
,打印JVM优化内容:
- -XX:+PrintCompilation:输出被JIT编译的方法信息
- -XX:+UnlockDiagnosticVMOptions:解锁用于诊断JVM的选项,默认关闭
- -XX:+PrintInlining:打印内联方法
如图,大量方法都被内联,其中也包括我们自己写的methodInlining
和add
方法,并且同时打印出了方法对应的字节大小,我们发现这些被内联的方法字节数都比较小。
其实这是由于方法内联也有一点的限制,即字节数过大的方法体不会进行内联,具体限制如下:
-
非热点代码:默认限制字节数小于
35
,可通过参数调整限制// 非热点代码的内联字节大小最大阈值 -XX:MaxInlineSize=n
-
热点代码:默认限制字节数小于
325
,可通过参数调整限制// 热点代码的内联字节大小最大阈值 -XX:FreqInlineSize=n
-
接口实现限制:若接口的实现方法数量超过3个,则不会被内联
2、逃逸分析
逃逸分析核心思想是判断方法内创建的对象是否会被外部所引用,当JIT编译器判断到对象在方法外部不会被引用,即该对象的生命周期被限定在当前方法执行期间,编译器就能采取多种优化措施,比如锁消除、标量替换和栈上分配。
(1)同步锁消除
同步锁消除旨在消除代码中的同步操作,若某个对象不会逃逸出当前方法,只在方法内部使用,则不存在线程安全问题,因为每个方法的栈都是私有的,JIT编译器就会消除该同步锁操作,提高程序的执行效率。
举个例子:
public void syncLockEliminate() {
// 等价于 -> 不执行该同步锁
synchronized (JitTest.class) {
// 业务代码逻辑
}
}
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
JitTest demo = new JitTest();
for (int i = 0; i < 1000000; i++) {
demo.syncLockEliminate();
}
long endTime = System.currentTimeMillis();
System.out.println("执行耗时:" + (endTime - startTime));
}
这里启用和禁用JIT锁消除(禁用锁消除参数:-XX:-EliminateLocks
)对应控制台输出的执行耗时分别为:
// 启用锁消除
执行耗时:143
// 禁用锁消除
执行耗时:203
(2)栈上分配
栈上分配是将被创建的对象内存分配在栈上,而不是堆上,降低GC垃圾回收的频率。
举个例子:
static class UserInfo {
// 姓名
private String name;
// 年龄
private int age;
public UserInfo() {
}
public UserInfo(String name, int age) {
this.name = name;
this.age = age;
}
}
// 栈上分配,不赋值对象属性
public static void createUser() {
UserInfo user = new UserInfo();
}
public static void main(String[] args) throws Exception {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
createUser();
}
long endTime = System.currentTimeMillis();
System.out.println("执行耗时:" + (endTime - startTime));
}
这里我们验证一下这个结论,我们在JVM参数上添加-XX:+PrintGC
,输出GC日志信息,同时将堆内存空间设小一些:
-Xms10m -Xmn10m -XX:+PrintGC
首先默认启用JIT逃逸分析优化,控制台输出:
执行耗时:72
接着禁用逃逸分析优化,添加参数-XX:-DoEscapeAnalysis
关闭逃逸分析,控制台输出:
[GC (Allocation Failure) 7679K->1041K(9216K), 0.0010975 secs]
[GC (Allocation Failure) 8721K->904K(9216K), 0.0006550 secs]
[GC (Allocation Failure) 8584K->840K(9216K), 0.0007822 secs]
执行耗时:103
关闭逃逸分析优化后,JVM执行了几次的GC垃圾回收。
(3)标量替换
标量替换是将需要被创建的对象拆解成独立的标量(对象内部的基本数据类型),方法执行时不创建该对象实例,而是直接创建它的成员变量代替它,这些成员变量作为该方法的局部变量被分配在栈上。
标量替换是针对栈上分配更进一步的优化技术,栈上分配只是将对象从堆上分配到栈上,而标量替换是将对象进行拆解,作为方法的局部变量被分配在栈上。
举个例子:
// 标量替换,赋值对象属性
public static void createUser() {
// 等价于 -> String name = user.name = xiaohao;
// int age = user.name = 24
UserInfo user = new UserInfo("xiaohao", 24);
String userInfo = user.name + ":" + user.age;
}
public static void main(String[] args) throws Exception {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
createUser();
}
long endTime = System.currentTimeMillis();
System.out.println("执行耗时:" + (endTime - startTime));
}
若JIT判断到UserInfo
对象不会逃逸出当前方法,则会将UserInfo
对象的name
和age
属性替换为两个局部变量,不进行创建UserInfo
对象,避免在堆上分配对象实例。
控制台输出的执行耗时分别为:
// 启用逃逸分析
执行耗时:162
// 禁用逃逸分析
执行耗时:203
五、后记
本文从JIT即时编译器基础概念开始介绍,着重讲解了常见的JIT优化技术,包括方法内联与逃逸分析,额外拓展了HotSpot虚拟机内置的C1、C2编译器及分层编译协作流程。
JIT即时编译器在运行时对Java字节码进行优化,极大地提升了Java程序的执行效率。不过也要求我们日常编写代码要注意一定的规范,让JIT发挥最大效果。比如控制方法的代码行数,避免写过长的代码方法,可以将复杂的逻辑拆分成多个小方法,提升方法内联的生效几率;另外也可以控制接口的实现数量,尽量不超过两个,降低方法内联的复杂度;以及注意在方法内创建对象时,尽量不让对象逃逸。
如果大家觉得内容有价值,不妨考虑点点赞,关注关注小豪,后续小豪将会继续更新其它Java相关文章,大家共同进步~