文章目录
一、JIT 产生的背景
我们知道,将高级语言转换成计算机可识别的机器语言有两种方式,即编译和解释。尽管在Java中,代码需要编译成字节码才能执行,但字节码本身并不能直接在机器上执行。
因此,JVM内置了解释器(interpreter),在运行时对字节码进行解释,将其翻译成机器码,然后执行。
解释器的执行方式是边翻译边执行,因此执行效率较低。为了解决这个低效问题,HotSpot引入了JIT技术(即时编译)。
有了JIT技术之后,JVM仍然使用解释器进行解释执行。但是,当JVM发现某个方法或代码块在运行时频繁执行时,会将其标记为"热点代码"。然后JIT将部分热点代码翻译成本地机器相关的机器码,并进行优化,再将翻译后的机器码缓存起来供下次使用。
二、HotSpot虚拟机内置JIT编译器
HotSpot虚拟机内置了两个JIT编译器:Client Compiler和Server Compiler, 分别用于客户端和服务器端。在当前主流的HotSpot虚拟机中,默认采用解释器与其中一个编译器直接配合的方式工作。
当JVM执行代码时,并不会立即开始编译代码。首先,如果代码只会被执行一次,那么编译代码相对于将代码翻译成Java字节码来说是一种浪费。因为将代码翻译成字节码的过程比编译和执行代码的过程要快很多。其次,JVM在编译代码时会进行优化。当某个方法或循环被执行的次数越多,JVM就会对代码结构有更深入的了解,并在编译代码时进行相应的优化。
1. Client Compiler
Client Compiler(也称为C1编译器或Client JIT)主要优化启动速度和内存占用。它会在程序运行初期进行编译,以快速生成可执行代码,但对于性能优化的程度较低。
2. Server Compiler
Server Compiler(也称为C2编译器或Server JIT)则注重在运行时对代码进行更深层次的优化,以提高程序的执行效率。它会在程序运行过程中,通过动态分析和优化来生成高性能的机器码。
3. 查看本地编译器模式
如果想要查看机器上安装的JDK中JIT采用的是哪种模式,可以执行"java -version"命令。这个命令将显示JDK的版本信息,其中也包括了JIT编译器的模式。
java -version
图中显示的是自己本地机器上安装的JDK 1.8,并且 JIT 编译器的模式是Server Compiler。 然而,需要指出的是,无论是Client Compiler还是Server Compiler,解释器与编译器都是以混合模式配合使用的,即图中显示的是mixed mode。
三、常见热点探测技术
为了触发JIT编译,首先需要识别出热点代码。目前,主要使用热点探测(Hot Spot Detection)来实现热点代码的识别,其中有两种常见的方式。
1. 基于计数器的热点探测
其中一种是基于计数器的热点探测,通过对方法的调用次数进行计数,当达到一定阈值时,将该方法标记为热点代码。这种方式简单直接,适用于识别一些简单的热点场景。
2. 基于采样的热点探测
HotSpot虚拟机使用周期性检测各个线程的栈顶的方法来判断热点方法。如果某个方法经常出现在栈顶,就被认为是热点方法。这种方法的好处在于简单易懂,但缺点是无法精确确定一个方法的热度。此外,它容易受到线程阻塞或其他原因的干扰,从而影响热点探测的准确性。
在HotSpot虚拟机中,使用的是基于计数器的热点探测方法,因此为每个方法准备了两个计数器:方法调用计数器和回边计数器。
2.1 方法调用计数器
方法调用计数器,顾名思义,是用来记录一个方法被调用的次数的计数器。它会统计方法的调用次数,并当达到一定阈值时将该方法标记为热点代码。
2.2 回边计数器
回边计数器则是用来记录方法中的循环结构(如for或while循环)的运行次数的计数器。它会统计循环结构的迭代次数,通过迭代次数的多少来判断循环是否是热点代码。
这两个计数器的作用是为了帮助HotSpot虚拟机识别热点代码,从而触发JIT编译进行优化。方法调用计数器用于识别频繁调用的方法,回边计数器用于识别运行次数较多的循环结构。通过对这些热点代码的识别,可以提高程序的执行效率。
总的来说,HotSpot虚拟机使用方法调用计数器和回边计数器作为基于计数器的热点探测方法,以识别热点代码并进行优化,从而提高Java应用程序的性能。
四、常见JIT优化手段
1. 公共子表达式消除
公共子表达式消除是JVM JIT编译器的一种优化技术,用于减少重复计算,提高程序的执行效率。
公共子表达式是指在一个程序中多次出现的计算表达式,通过公共子表达式消除优化,可以将重复的计算合并为一次计算,减少不必要的计算开销。
以下是一个简单的示例代码,展示了公共子表达式的消除优化:
public class CommonSubexpressionEliminationDemo {
public static void main(String[] args) {
int a = 5;
int b = 3;
int c = a * b + 2; // 公共子表达式 a * b
int d = a * b + 2; // 公共子表达式 a * b
System.out.println(c);
System.out.println(d);
}
}
在上述代码中,变量c和d都进行了相同的计算表达式 a * b + 2,通过公共子表达式消除优化,JVM JIT编译器会将重复的计算合并为一次计算。
总结:
公共子表达式消除可以减少重复计算,提高程序的执行效率。
JVM JIT编译器会通过识别重复的计算表达式,并将其优化为一次计算。
使用公共子表达式消除优化可以减少不必要的计算开销,特别在循环中使用同样的表达式时效果更为明显。
2. 方法内联
方法内联是JVM JIT编译器的一种优化技术,用于减少方法调用的开销,提高程序的执行效率。
方法内联是指将某个方法的代码直接插入到调用该方法的地方,而不是通过方法调用的方式执行。这样可以减少方法调用的开销,包括栈帧的创建和销毁、参数传递等操作。
以下是一个简单的示例代码,展示了方法内联的优化:
public class MethodInliningDemo {
public static void main(String[] args) {
int a = 5;
int b = 3;
int c = add(a, b); // 方法调用
int d = a + b; // 方法内联
System.out.println(c);
System.out.println(d);
}
public static int add(int a, int b) {
return a + b;
}
}
在上述代码中,变量c通过方法调用的方式计算结果,而变量d直接将方法的代码内联到调用处进行计算。
总结:
方法内联可以减少方法调用的开销,提高程序的执行效率。
JVM JIT编译器会通过识别适合内联的方法,并将其优化为直接插入到调用处执行。
使用方法内联优化可以减少方法调用的开销,特别是在频繁调用的方法中效果更为明显。
需要注意的是,过多的方法内联可能会导致代码膨胀,增加编译时间和内存消耗。因此,在使用方法内联时需要权衡代码的大小和性能的提升。
3. 逃逸分析
逃逸分析是JVM JIT编译器的一种优化技术,用于分析对象的作用域,确定对象是否会逃逸出方法的范围,从而对对象的内存分配进行优化。
逃逸分析的目的是找出那些不会逃逸出方法的对象,将它们分配在栈上而不是堆上,以减少垃圾回收的开销。
以下是一个简单的示例代码,展示了逃逸分析的优化:
public class EscapeAnalysisDemo {
public static void main(String[] args) {
User user = createUser("Alice"); // 对象逃逸
System.out.println(user.getName());
}
public static User createUser(String name) {
return new User(name); // 对象逃逸
}
static class User {
private String name;
public User(String name) {
this.name = name;
}
public String getName() {
return name;
}
}
}
在上述代码中,createUser方法中创建的User对象会逃逸出方法的范围,即被外部引用所使用。
总结:
-
逃逸分析是JVM JIT编译器的一种优化技术,用于分析对象的作用域,确定对象是否会逃逸出方法的范围。
-
逃逸分析的目的是找出那些不会逃逸出方法的对象,将它们分配在栈上而不是堆上,以减少垃圾回收的开销。
-
逃逸分析可以减少堆的分配和垃圾回收的开销,提高程序的执行效率。
-
需要注意的是,逃逸分析并不是一项绝对有效的优化技术,它只能在特定的场景下才能生效,而且对于大部分应用程序来说,堆的分配和垃圾回收开销并不是性能瓶颈,因此逃逸分析的作用有限。
-
逃逸分析在JVM中是通过参数来进行控制的。在JDK7及以后的版本中,默认情况下逃逸分析是开启的。
以下是一些与逃逸分析相关的JVM参数:
- -XX:+DoEscapeAnalysis:启用逃逸分析。默认情况下是开启的。
- -XX:-DoEscapeAnalysis:禁用逃逸分析。
- -XX:+PrintEscapeAnalysis:打印逃逸分析的相关信息。
-XX:+EliminateLocks:通过逃逸分析来消除不必要的锁。 - 需要注意的是,逃逸分析的效果是与具体的JVM实现相关的,不同的JVM可能对逃逸分析的支持和优化程度有所不同。因此,对于一些特定的场景,可能需要根据实际情况进行适当的调整和优化。
3.1 逃逸分析之标量替换
标量替换是逃逸分析的一种优化技术,它将对象拆解成独立的标量(单个的基本类型或对象引用),并将这些标量分别分配在栈上或寄存器中,从而避免了对象的创建和访问操作。
下面是一个简单的示例代码,用于演示标量替换的效果:
public class ScalarReplacementDemo {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
Point point = new Point(i, i); // 创建一个Point对象
int sum = point.x + point.y; // 使用Point对象的属性进行计算
}
long endTime = System.currentTimeMillis();
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
static class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
}
在上述代码中,我们循环创建了10000000个Point对象,并对每个对象的属性进行了相加操作。如果逃逸分析开启且标量替换生效,JVM会将Point对象的属性x和y分别替换为两个独立的局部变量,并将它们分配在栈上,从而避免了对Point对象的创建和访问。
总结:
- 标量替换是逃逸分析的一种优化技术,将对象拆解成独立的标量并在栈上或寄存器中分配。
- 标量替换能够避免对象的创建和访问操作,从而提高程序的性能。
- 要启用标量替换,需要确保逃逸分析开启,并且JVM在运行时会自动进行标量替换的优化。
- 在编写代码时,可以通过适当的代码设计来帮助JVM进行标量替换优化,例如使用不可变对象或局部变量等。
3.2 逃逸分析之栈上分配
栈上分配是逃逸分析的另一种优化技术,它将某些对象的内存分配在栈上而不是堆上。栈上分配可以减少对象在堆上的分配和回收的开销,提高程序的性能。
下面是一个简单的示例代码,用于演示栈上分配的效果:
public class StackAllocationDemo {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
Point point = createPoint(i, i); // 创建一个Point对象,并返回其引用
int sum = point.x + point.y; // 使用Point对象的属性进行计算
}
long endTime = System.currentTimeMillis();
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
static Point createPoint(int x, int y) {
return new Point(x, y);
}
static class Point {
int x;
int y;
public Point(int x, int y) {
this.x = x;
this.y = y;
}
}
}
在上述代码中,我们循环创建了10000000个Point对象,并对每个对象的属性进行了相加操作。如果逃逸分析开启且栈上分配生效,JVM会将Point对象的内存分配在栈上而不是堆上,从而减少了堆上对象的分配和回收的开销。
总结:
- 栈上分配是逃逸分析的一种优化技术,将某些对象的内存分配在栈上而不是堆上。
- 栈上分配能够减少对象在堆上的分配和回收的开销,提高程序的性能。
- 要启用栈上分配,需要确保逃逸分析开启,并且JVM在运行时会自动进行栈上分配的优化。
- 在编写代码时,可以通过适当的代码设计来帮助JVM进行栈上分配优化,例如将对象的作用域限制在方法内部、使用局部变量等。
3.3 逃逸分析之同步消除
同步消除是逃逸分析的另一种优化技术,它通过分析代码中的同步操作,判断是否可以消除这些同步操作从而提高程序的性能。
下面是一个简单的示例代码,用于演示同步消除的效果:
public class SynchronizationEliminationDemo {
public static void main(String[] args) {
long startTime = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
synchronizedMethod();
}
long endTime = System.currentTimeMillis();
System.out.println("Time taken: " + (endTime - startTime) + "ms");
}
static void synchronizedMethod() {
synchronized (SynchronizationEliminationDemo.class) {
// 同步块中的代码
}
}
}
在上述代码中,我们循环调用了10000000次synchronizedMethod方法,该方法包含了一个同步块。如果逃逸分析开启且同步消除生效,JVM会判断到synchronizedMethod方法没有逃逸到其他线程中,因此可以消除同步操作,从而提高程序的性能。
总结:
- 同步消除是逃逸分析的一种优化技术,通过分析代码中的同步操作,判断是否可以消除这些同步操作从而提高程序的性能。
- 同步消除的前提是逃逸分析开启,并且JVM能够确定同步操作没有逃逸到其他线程中。
- 同步消除可以减少线程间的同步开销,提高程序的并发性能。
在编写代码时,可以通过避免不必要的同步操作、合理设计对象的作用域等方式帮助JVM进行同步消除优化。
五、JIT优化可能引发的问题
一旦我们理解了JIT编译的原理,就会明白JIT优化是在运行时进行的,并且并非在Java进程刚启动时就能立即进行优化的。它需要一定的执行时间来确定哪些代码是热点代码。
因此,在JIT优化开始之前,所有的请求都需要经过解释执行,这个过程相对较慢。尤其是在应用的请求量较大时,这个问题会更加明显。在应用启动过程中,大量的请求涌入会导致解释器持续努力工作。
如果解释器对CPU资源占用较高,就会间接导致CPU和负载等指标飙升,进而降低应用的性能。这也是为什么在应用发布过程中,刚刚重启好的应用会出现大量超时问题的原因。
随着请求不断增加,JIT优化会被触发,这使得后续的热点请求不再需要解释执行,而是直接运行JIT优化后缓存的机器码。
✨主要有两种解决思路:✨
1. 提升JIT优化的效率
一种方法是借鉴阿里研发的JDK Dragonwell,它相比于OpenJDK提供了一些专有特性,其中包括JwarmUp技术。该技术通过记录上一次Java应用运行时的编译信息到文件中,在下次应用启动时读取该文件,实现提前完成类加载、初始化和方法编译,跳过解释阶段,直接执行编译好的机器码。
2. 降低瞬时请求量
在应用刚启动时,通过调节负载均衡,逐渐增加流量,让应用在小流量下触发JIT优化,等优化完成后再逐渐增加流量。
这种方法类似于缓存预热的思想。在应用刚启动时,不要立即将大量流量分发给它,而是先分配一小部分流量,通过这部分流量触发JIT优化。等优化完成后,再逐渐增加流量。