1 JIT基本介绍
1.1 概念
Java将源代码编译成字节码后,并不能直接在机器上执行,而是由JVM内置解释器,在程序运行时将字节码翻译成机器码,然后再执行。因为执行方式是一边翻译一边执行,所以执行效率比较低。为了提高运行时的性能,HotSpot引入了JIT(Just-In-Time)优化技术,有了该优化技术,JVM还是通过解释器进行解释执行,但是当JVM发现某个方法或者代码块运行时执行的特别频繁,就会认为这是热点代码,会把它翻译成本地机器相关的机器码,并进行优化缓存起来,以备下次使用,避免重复翻译,以此提高执行性能。
1.2 Client模式与Server模式
在JVM中,JIT编译主要由两种即时编译器组成:Client Compiler(C1编译器)和Server Compiler(C2编译器),目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。可以使用“-client”或“-server”参数去指定解释器与具体的某个编译器配合工作。
1.2.1 Client Compiler(C1编译器)
C1编译器的编译流程是:
1、预准备工作:基于字节码完成部分优化,如方法内联。
2、构造HIR:将字节码构造成一种高级代码表示,使用静态单分配的形式来代表代码值。通过HIR实现冗余代码消除、死代码删除等编译优化工作。
3、构造LIR:在优化过HIR后,将其转换成低级中间表示,并执行寄存器分配、窥孔优化等操作,最终生成机器代码。
它的主要特点是:
1、启动速度快,C1编译器的主要目标是优化启动速度,它执行的优化较为简单和快速。
2、C1编译器主要关注局部代码的优化,例如方法内联。
1.2.2 Server Compiler(C2编译器)
C2编译器几乎会执行所有经典的优化工作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等。此外,它还会执行与Java语言紧密相关的优化技术,如范围检查消除、空值检查消除等。C2编译器还会根据程序运行时收集到的信息进行不可靠的激进优化。C2编译器的特点是:
1、性能优先:C2编译器主要关注全局优化和运行时性能,它会根据程序运行时的信息执行更复杂的优化。
2、启动时间较长:由于C2编译器执行的优化更为复杂和耗时,因此它的启动时间相对较长。
1.2.3 分层编译
为了平衡编译速度和执行效率,从Java7开始,JVM引入了分层编译的概念,并默认开启分层编译。在分层编译中,JVM会根据代码的执行频率和重要性选择使用C1或C2编译器进行编译。通常,对于频繁执行的热点代码,JVM会使用C2编译器进行编译以获得更好的性能;而对于不太频繁执行的代码,JVM则可能使用C1编译器进行编译以节省编译时间。
分层编译的级别包括:
0级(解释执行):不开启性能监控功能,采用解释执行。
1级(简单的C1编译):使用C1编译器进行简单可靠的优化,不开启性能监控功能。
2级(有限的C1编译):使用C1编译器进行更多的优化编译,并开启性能监控功能。
3级(完全C1编译):完全使用C1编译器的所有功能,并完全开启性能监控功能。
4级(C2编译):使用C2编译器进行编译,进行完全的优化。
但分层编译只能在Server Compiler模式下启用。在实际应用中,JVM会根据程序的运行情况和性能需求自动选择合适的编译级别。
1.3 热点检测
热点代码需要通过热点检测识别出来,主要的识别方式有以下两种:
1、基于采样的方式探测:
周期性检测各个线程的栈顶,发现某个方法经常出现在栈顶,那么就会判定为热点方法。这种检测方式的好处就是简单,但是无法精确确认方法的热度,容易受线程阻塞或其他原因的影响。
2、基于计数器的方式探测:
JVM会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,某个方法超过阈值就会被认为是热点方法,触发JIT编译。
在HotSpot虚拟机中使用的是计数器的方式来探测热点代码,并且为每个方法准备了两个计数器:
方法计数器:记录一个方法被调用的次数
回边计数器:记录方法中的for或者while的运行次数的计数器
2 JIT优化
JIT优化的内容有很多,包括:逃逸分析、锁消除、标量替换、栈上分配、方法内联、类型检测消除、公共子表达式消除等等,本文选择几个重点优化内容做介绍
2.1 逃逸分析
逃逸分析是Java HotSpot Server编译器中JIT优化的一个重要步骤,用于判断对象是否会在方法外部被访问到,即是否“逃出”了方法的作用域。通过逃逸分析,JIT编译器能够确定哪些对象可以被限制在方法内部使用,而不会逃逸到外部,进而对这些对象进行优化。对象基于逃逸分析有三种状态:全局逃逸、参数逃逸、无逃逸。
2.1.1 全局逃逸
全局逃逸是指对象的作用范围超出了当前方法或当前线程的情况。全局逃逸通常发生在以下几种场景中:
1)对象被赋值给静态变量:当一个对象被赋值给一个静态变量时,由于静态变量是类级别的,它的生命周期与类相同,因此该对象的作用范围就超出了当前方法或线程,导致全局逃逸。
2)对象作为方法的返回值:当一个方法返回一个对象的引用时,这个对象可能会被调用该方法的代码所访问,因此其作用范围也超出了当前方法,造成全局逃逸。
3)对象被包含在其他已经逃逸的对象中:如果一个对象被包含在其他已经发生全局逃逸的对象中,例如作为该对象的成员变量,那么它也会随着其宿主对象的全局逃逸而逃逸。
4)复写了finalize()方法的类的对象:在Java中,当一个对象没有引用指向它时,它可能会被垃圾回收器回收。但在回收之前,如果该对象所属的类重写了finalize()方法,则该对象会被标记为需要执行finalize()方法的对象,并且一定会被放在堆内存中,从而导致了全局逃逸。
public class GlobalEscapeExample
{
// 对象被赋值给静态字段,因此是全局逃逸的
private static Object obj = new Object();
public static StringBuilder createStringBuilder()
{
StringBuilder builder = new StringBuilder();
builder.append("123");
// builder对象被返回,因此是全局逃逸的
return builder;
}
}
2.1.2 参数逃逸
参数逃逸是指对象被作为参数传递或被参数引用,但在方法调用期间不会发生全局逃逸。
public class ArgEscapeExample
{
public void methodA()
{
Object obj = new Object();
// obj作为参数传递,但不会从methodB中逃逸
methodB(obj);
}
public void methodB(Object obj)
{
// 使用obj对象
}
}
2.1.3 无逃逸
无逃逸是指方法中的对象没有发生逃逸,这意味着可以不将该对象分配在堆上。
public class NoEscapeExample
{
public String get()
{
// builder未发生逃逸,对象本身没有作为参数传递,也没有当做方法的返回值或复制给静态变量
StringBuilder builder = new StringBuilder();
builder.append("123");
return builder.toString();
}
}
2.2 锁消除
在动态编译同步块的时候,JIT编译器可以通过逃逸分析来判断同步块所使用的锁对象是否只被一个线程访问,不会被发布到其他线程。如果同步块所使用的锁对象通过分析被证实只能被一个线程访问,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步,这个取消同步的过程叫同步省略,也叫锁消除。
public void method()
{
Object obj = new Object();
synchronized(obj)
{
System.out.println(obj);
}
}
代码中对obj对象加锁,但obj对象的生命周期只在method方法中,并不会被其他线程所访问到,所以在JIT编译阶段就会被优化掉,优化成以下逻辑:
public void method()
{
Object obj = new Object();
System.out.println(obj);
}
通过锁消除可以减少不必要的锁操作,减少系统的开销,提高程序性能。
2.3 标量替换&栈上分配
标量替换是一种针对对象成员变量的优化技术。标量是指一个无法再分解成更小的数据的数据,Java中的原始数据类型就是标量。相对的,那些还可以再分解的数据叫做聚合量,Java中的对象就是聚合量,因为它可以分解成其他聚合量和标量。当JIT编译器通过逃逸分析发现一个对象不会被外界访问的话,就会把这个对象拆解成若干个成员变量来代替,从而减少内存占用和访问开销,这个过程就叫做标量替换。
public void replaceMethod()
{
Replace replace = new Replace(1, 2);
System.out.println(replace.a + ", " + replace.b);
}
class Replace
{
private int a;
private int b;
public Replace(int a, int b)
{
this.a = a;
this.b = b;
}
}
以上代码中,replace对象并没有逃逸出replaceMethod方法,并且replace对象是可以拆解成标量的,所以JIT就不会直接创建replace对象,而是直接使用两个标量a、b来替代replace对象,经过标量替换后,会变成:
public void replaceMethod()
{
int a = 1;
int b = 2;
System.out.println(a + ", " + b);
}
标量替换为栈上分配提供了很好的基础,当通过逃逸分析发现对象无逃逸时,就可以不用在堆上分配内存,改为在栈上分配。栈上分配的对象会随着方法栈帧的销毁而自动销毁,因此不需要进行垃圾回收,从而提高了内存的使用效率。
2.4 方法内联
方法内联是指在编译过程中,当遇到方法调用时,将目标方法的方法体纳入编译范围,并取代原方法调用的优化手段。换句话说,编译器会将一个方法的调用替换为方法体的实际内容,以减少方法调用的开销,常用于高频使用的函数,特别是当函数体很小且频繁的函数跳转影响系统性能的时候。
public int add (int a, int b)
{
return a + b;
}
public void addTest()
{
int result = add(1, 2);
// do something
}
在以上代码中,add方法的逻辑很简答,只是做一个相加操作,JIT编译器可能会选择将add方法直接内联在addTest中,避免对add方法的实际调用,这样也不需要有方法栈帧的出栈和入栈,减少了调用开销,提高了执行速度,内联的效果如下:
public void addTest()
{
int result = 1 + 2;
// do something
}
但方法内联有一定的条件,通常以下情况会做方法内联:
1、自动拆箱操作通常会被内联。
2、使用-XX:CompileCommand中的inline指令指定的方法。
3、使用@ForceInline注解的方法。
有一些场景不会触发方法内联,主要包括以下几个:
1、Throwable类的方法不能被其他类中的方法内联。
2、使用-XX:CompileCommand中的dotinline或exclude指令指定的方法。
3、使用@Dontinline注解的方法。
4、调用字节码对应的符号引用未被解析。
5、目标方法所在的类未被初始化。
6、目标方法是native方法。
7、C2编译器不支持内联超过9层的调用或1层的直接递归调用。
方法内联具有它的优点,但同时也有一些潜在的缺点:
1、代码膨胀:当方法被内联后,它的代码体将直接插入到每个调用点中,这可能导致编译后的代码量显著增加,会导致用更多的内存空间来存储编译后的代码。
2、编译时间增加:对于被内联的方法,编译器需要将其代码体插入到每个调用点,这可能导致编译时间增加。特别是在大型项目中,这可能会成为一个问题。
3、递归和内联的冲突:对于递归方法,内联可能会导致栈溢出的问题,因为每次递归调用都会被内联,导致栈帧的数量迅速增加。虽然现代JVM会对递归调用进行特殊处理,但在某些情况下,内联递归方法仍然可能是一个问题。
3 参考文献
https://blog.csdn.net/A_art_xiang/article/details/134856066
https://www.bilibili.com/read/cv15656241/
https://www.jianshu.com/p/22d2cac9c512
-- End --
点击下方的公众号入口,关注「技术对话」微信公众号,可查看历史文章,投稿请在公众号后台回复:投稿




