JIT(即时编译)深入理解
JIT(即时编译)深入理解
Java是一门解释型语言,通过编译器(javac)将源代码编译成平台无关的Java字节码文件(.class)。然后JVM解释执行这些字节码文件,实现平台无关性。
但是,解释执行的速度相对较慢。为了提高执行速度,引入了JIT技术。JIT是JUST IN TIME的缩写,意味着即时编译。
热点探测技术
当虚拟机发现某个方法或代码块运行特别频繁时,就会将这些代码标记为热点代码(Hot Spot Code)。热点探测的目的是识别出这些热点代码,以便进行即时编译(JIT)优化。
这种探测方法周期性地检查各个线程的虚拟机栈的栈顶。如果某个方法经常出现在栈顶,就认为这个方法是热点方法。基于采样的热点探测是一种常见的热点代码判定方式。
对于热点方法,JIT会触发标准编译,将其编译成机器码,以提高执行效率。对于循环体,JIT可能触发OSR(On-Stack
Replacement)栈上替换,直接切换到本地代码执行。
热点探测技术的触发,只需要满足以下一个条件即可:
- 方法调用计数器
- 方法计数器用于记录方法被调用的次数。当某个方法被多次调用时,方法计数器的值会逐渐增加
- 在Client模式下,默认的方法计数器阈值是1500次。
- 在Server模式下,默认的方法计数器阈值是10000次。
- 回边计数器
- 回边计数器用于判断循环是否热点。当循环体被多次执行时,回边计数器的值会增加。
- 在Client模式下,回边计数器的阈值计算公式为:OSRP = 方法调用计数器阈值 * 933 / 100。
- 在Server模式下,回边计数器的阈值计算公式为:OSRP = (方法调用计数器阈值 * 140 - 监控解释器比率) / 100。
public class HotSpotDetectionExample {
public static void main(String[] args) {
// Simulate some method calls
for (int i = 0; i < 15000; i++) {
hotMethod();
}
}
// A "hot" method (called frequently)
private static void hotMethod() {a
System.out.println("Hot method called!");
}
}
在这个示例中定义了hotMethod()方法。hotMethod()
被频繁调用,VM会根据方法调用计数器来判断哪些方法是热点方法。在这里,hotMethod()会被标记为热点方法,因为它被调用的次数较多。
方法内联
方法内联的优化行为是把目标方法的代码复制到发起调用的方法之中,避免发生真是的调用,以此来避免方法调用带来的栈帧生成、参数压入、栈帧弹出等开销。
方法内联的触发需要满足以下条件:
- 触发热点探测技术:方法被频繁调用,达到一定次数(通常是1500次或10000次)。
- 方法体大小合适:过大的方法不适合内联(方法体大小小于325字节,可通过 -XX:FreqInlineSize=N 来设置),因为会影响代码缓存(CodeCache)的使用。
- 不是经常执行的方法,默认情况下方法大小小于35字节才会进行内联(可通过 -XX:MaxInlineSize=N 来重置)
public class MethodInliningExample {
public static boolean flag = true;
public static int value0 = 0;
public static int value1 = 1;
public static int foo(int value) {
int result = bar(flag);
if (result != 0) {
return result;
} else {
return value;
}
}
public static int bar(boolean flag) {
return flag ? value0 : value1;
}
public static void main(String[] args) {
for (int i = 0; i < 20000; i++) {
foo(i);
}
}
}
在这个示例中,foo方法会调用bar方法。我们期望编译器能够内联这两个方法,从而减少方法调用的开销。执行上述代码时,你会发现foo方法被触发了即时编译(JIT),并且bar方法成功内联到了foo方法中。
锁消除
通过对运行上下文的扫描,JVM可以去除那些不可能存在共享资源竞争的锁。这种优化可以节省毫无意义的请求锁时间。
锁消除主要是为了处理那些在代码上要求同步,但实际上并不存在共享数据竞争情况的锁。例如,如果局部变量在运行过程中没有出现逃逸,那么就可以认为这段代码是线程安全的,无需加锁。
锁消除的触发条件主要是基于逃逸分析。如果JVM判断到一段代码中,堆上的数据不会逃逸出当前线程,那么就可以认为这段代码是线程安全的,无需加锁。
public class LockClearTest {
public static void main(String[] args) {
LockClearTest test = new LockClearTest();
for (int i = 0; i < 100000; i++) {
test.append("aaa", "bbb");
}
}
public void append(String str1, String str2) {
StringBuffer stringBuffer = new StringBuffer();
stringBuffer.append(str1).append(str2);
}
}
在这个例子中,StringBuffer的append是一个同步方法,但是在LockClearTest中的StringBuffer是一个局部变量,不可能从该方法中逃逸出去(即stringBuffer的引用没有传递到该方法外,不会被其他线程引用),因此其实这个过程是线程安全的,可以将锁消除。
锁粗化
在一系列连续的操作中,如果对同一个对象反复加锁和解锁,即使没有出现线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
锁粗化主要是为了解决频繁的加锁、解锁带来的性能损耗问题。在一些情况下,锁的粒度粗一些反而更好,两次加锁解锁之间,间隙非常小,此时,不如就直接一次大锁搞定得了。
锁粗化的触发条件是,如果JVM检测到有一连串零碎的操作都是对同一对象的加锁,将会扩大加锁同步的范围(即锁粗化)到整个操作序列的外部。
public class StringBufferTest {
StringBuffer stringBuffer = new StringBuffer();
public void append(){
stringBuffer.append("a").append("b").append("c");
}
}
在这个示例中,每次调用 stringBuffer.append 方法都需要加锁和解锁,如果JVM检测到有一连串的对同一个对象加锁和解锁的操作,就会将其合并成一次范围更大的加锁和解锁操作,即在第一次append 方法时进行加锁,最后一次 append 方法结束后进行解锁。这样就避免了频繁用户态到内核态的状态转换,从而提高了性能。
逃逸分析技术
逃逸分析是编译程序优化理论中的一种方法,用于确定指针动态范围,即分析在程序的哪些地方可以访问到指针。当一个变量(或者对象)在方法中被分配后,其指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称作指针(或者引用)的逃逸。
逃逸分析的主要作用是帮助Java Hotspot编译器分析出一个新的对象的引用的使用范围,从而决定是否需要将这个对象分配到堆上。逃逸分析可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。
逃逸分析的优化技术包括:
- 栈上分配: 如果一个对象不会在方法体内,或线程内发生逃逸,那么可以使用栈上的空间,那么大量的对象将随方法的结束而销毁,减轻了GC压力2。
- 同步消除: 如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行2。
- 标量替换: 如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替2。
逃逸的方式主要有两种:
- 方法逃逸: 在一个方法体内,定义一个局部变量,而它可能被外部方法引用,比如作为调用参数传递给方法,或作为对象直接返回。
- 线程逃逸: 这个对象被其他线程访问到,比如赋值给了实例变量,并被其他线程访问到了。