JIT编译器优化


虚拟机针对JIT编译器做了很多优化,其中有几项比较重要且有代表性的优化措施

公共子表达式消除

公共子表达式消除就是对一个表达式中重复计算的子表达式进行缓存替换。

例如一个表达式:

int d = (c * b) * 12 + a + (a + b * c)

观察这个表达式中,c * b出现了2次,且计算过程中b和c的值不变,c * b的值也就不变。此时可以将c * b替换为表达式E:

int d = E * 12 + a + (a + E)

此处的E就是公共子表达式,经过优化之后E的值只会被计算一次,这就是公共子表达式消除

有些虚拟机还会对表达式做代数简化

int d = E * 13 + a * 2

数组边界检查消除

有我们访问一个数组A[i]时,JVM会对数组下标i做一次越界检查,如果越界将抛出异常。

但如果数组很大,每次访问都进行越界检查的话无疑也是不小的性能负担,但越界检查又不能不做。

因此JIT在编译时可以根据上下文分析判断是否可以省略越界检查步骤。

比如数组长度如果固定是10,那么访问A[5]时,在编译期间就可以分析出肯定是不会越界的,那么在编译后的机器码中就省去了越界检查这一步骤。

再比如使用for循环访问数组:

for (int i = 0; i < A.length; i++) {
    System.out.println(A[i]);
}

显然上面这段代码时肯定不会越界的,在编译时也可以省去越界检查。

除了数组边界检查消除,还有别的相同思想的优化手段,比如以下代码:

if(obj != null){
    obj.value;
}

这段代码每次都进行了非空的判断,JIT可以进行如下优化:

try{
    obj.value;
}catch(xxx){
    xxxx
}

优化之后调用obj.value时就节省了一次非空判断。但是由于异常捕获的效率比较低,所以如果obj经常为null的话,这次优化就起了反作用,还不如不优化。

好在HotSpot虚拟机比较聪明,会在运行期间收集信息自动选择最优优化方案

方法内联

比如方法a()中调用了方法b(),那么可以将b()对应的代码块直接替换到方法a()中,这就是方法内联。

比如以下代码:

public int a() {
    int left = 1;
    int right = 2;
    return b(left, right);
}

public int b(int left, int right) {
    return left * right;
}

方法内联优化后:

public int a() {
    int left = 1;
    int right = 2;
    return left * right;
}

为什么这么做呢?要知道我们调用一个方法时,要先到方法区找到该方法对应的字节码,然后在虚拟机栈上生成一个栈帧进行入栈出栈。经过方法内联之后,在方法调用过程中就可以减少了一次方法区寻址和栈帧出入栈的过程。

逃逸分析

逃逸分析是一种比较前沿的优化手段,但它不直接优化代码,而是为其他优化手段提供依据。

当一个对象在方法内被定义后,它如果可以在方法外被使用,就称为方法逃逸

比如方法内new了一个对象a并return a,那么对象a就可以在该方法之外被使用,这就是方法逃逸。

逃逸分析大致有以下几种情况:

全局变量赋值逃逸/线程逃逸

// 其他线程或其他方法中都可以使用该对象
public static Object object;

public void a(){
    a = new Object();
}

方法返回值逃逸

// a方法的调用者可使用此处创建的object对象
public void a(){
    return new Object();
}

实例引用发生逃逸

public void a(){
    Object obj = null;
    newInstance(obj);
    // 这里只可以使用newInstance()中new的对象
}

public void newInstance(Object obj){
    obj = new Object();
}

JDK1.7开始默认开启了逃逸分析,我们也可以使用:

  • -XX:+DoEscapeAnalysis参数手动开启。
  • -XX:+PrintEscapeAnalysis参数查看分析结果

栈上分配

基于逃逸分析,可以针对Java对象做栈上分配优化。所谓栈上分配就是将对象的空间分配在栈上,而不是堆上。

我们都知道Java对象的内存空间正常是分配在堆上的,但是如果该对象没有发生方法逃逸,如:

public void test(){
    Object o = new Object();
    System.out.println(o);
}

从这段代码可以看出,对象o没有逃逸,随着test方法结束,栈帧出栈后,o对象也就没用了。

所以可以将对象o的内存空间分配在栈上,那么随着test方法出栈,对象o自然也就被销毁了,可以大大减少垃圾回收器的工作量

标量替换

int、long这种无法继续拆解成更小粒度的数据可以称之为标量,而对象可以拆解成更小的粒度,这种称之为聚合量

如果一个对象确认不会逃逸,那么可以将其拆解成更小的标量,在栈上进行读写,而不用在堆中创建对象。

因此标量替换可以算是栈上分配的一种特例。

  • -XX:+EliminateAllocations参数开启标量替换
  • -XX:+PrintEliminateAllocations查看替换情况

线程同步锁消除

如果确认对象不会线程逃逸,也就是不会有多个线程使用同一个对象,那么相关的synchronized同步锁可以将其消除。

举个例子:

public String append(String str1, String str2){
    StringBuffer sb = new StringBuffer();
    sb.append(str1);
    sb.append(str2);
    return sb.toString();
}

这个例子中,sb.toString()产生了一个新的字符串对象,因此StringBuffer对象不会发生逃逸。而sb.append()方法中使用了synchronized:

public synchronized StringBuffer append(String str) {
    toStringCache = null;
    super.append(str);
    return this;
}

这种情况下就可以消除synchronized同步锁

  • +XX:+EliminateLocks参数开启同步锁消除
文章作者: 周君
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 周君 !
评论