JIT编译器的优化方式

Posted by KANG's BLOG on Tuesday, March 15, 2022

一、编译模式

Sun JDK在执行过程中,对执行频率高到代码进行编译,对执行不频繁的代码则继续采用解释的方式。

在编译上,Sun JDK提供两种模式:client compiler(-client)和server compiler(-server)

Client compiler

又称C1,较为轻量级,主要包括以下几方面:

1. 方法内联

编译器所做最重要的优化是方法内联

遵循面向对象设计,属性访问通常通过setter/getter方法而非直接调用,而此类方法调用的开销很大,特别是相对方法的代码量而言。

现在的JVM通常都会用内联代码的方式执行这些方法,举个例子:

Order o = new Order();
o.setTotalAmount(o.getOrderAmount() * o.getCount());

而编译后的代码本质上执行的是:

Order o = new Order();
o.orderAmount = o.orderAmount * o.count;

内联默认是开启的,可通过-XX:-Inline关闭,然而由于它对性能影响巨大,并不建议关闭。

方法是否内联取决于它有多热以及它的大小

2. 去虚拟化

如发现类中的方法只提供了一个实现类,那么对于调用了此方法的代码,将进行方法内联

public interface Animal {
  void eat();
}

public class Cat implements Animal {
  @Override
  public void eat() {
    System.out.println("Cat eat !");
  }
}

public class Demo {
  public void execute(Animal animal){
    animal.eat();
  }
}

如果JVM中只有Cat类实现了Animal接口,execute()方法被编译时,会演变成类似如下结构:

public void execute() {
  System.out.println("Cat eat !");
}

execute()方法直接内联了Cat类中eat()方法的内部逻辑。

3. 冗余消除

冗余消除指在编译时,根据运行情况进行代码折叠或者消除

例如:

private static final boolean isDebug = false;

public void execute() {
  if(isDebug) {
    log.debug("do execute.");
  }
  System.out.println("done");
}

在执行C1编译后,会演变成如下结构:

public void execute() {
  System.out.println("done");
}

这就是为什么,通常不建议直接调用log.debug(),而要先判断的原因。

Server complier

又称C2,较C1更为重量级,C2更多在于全局优化,而非代码块的优化。

逃逸分析

逃逸分析指的是根据运行状况来判断方法中变量是否会被方法外部读取,如果被外部读取,则认为是逃逸的。

如果通过命令-XX:+DoEscapeAnalysis(默认为true)开启逃逸分析,server编译器会执行较为激进的优化措施。

1. 标量替换

Point point = new Point(1, 2);
System.out.println("point.x = " + point.x + "; point.y" + point.y);

当point对象在后面执行过程中未被使用到时,代码经过编译会演变为如下结构:

int x = 1, y = 2;
System.out.println("point.x = " + x + "; point.y" + y);

2. 栈上分配

在上面的例子中,如果point没有逃逸,那么C2会选择在栈上直接创建point对象,而非堆上。

在栈上分配的好处一方面是对象创建更加快速,另一方面是回收时随着方法的结束,对象也被回收了。

3. 同步削除

Point point = new Point(1, 2);
synchronized(point) {
  System.out.println("point.x = " + point.x);
}

经过分析如果发现point未逃逸,则代码会在编译后变成如下结构:

Point point = new Point(1, 2);
System.out.println("point.x = " + point.x);

OSR(On Stack Replace,栈上替换)

OSR和C1、C2主要不同在于,OSR仅仅替换循环代码体的入口,而C1、C2替换的是方法调用的入口。

因此在OSR编译后会出现的现象是,方法的整段代码被编译了,但只有在循环代码体部分才执行编译后的机器码,而其他部分仍然是解释执行方式。

如果对方法进行编译优化,等JVM在某个方法中发现这个方法很热,需要编译,那么只有下次调用这个方法才能享受到被优化后的代码,而本次调用依旧使用优化前的代码。OSR主要就是解决这个问题,比如JVM发现方法中这个循环过热,那么仅仅编译这个循环体就好了,执行引擎也会在进入下一个循环时跳转到新编译的代码中去。

Sun JDK之所以未选择在启动时即编译,有以下几方面原因:

  1. 静态编译不能根据运行情况进行优化
  2. 解释执行比编译执行更节省内存
  3. 启动时,解释执行比编译后再启动更快

二、使用编译执行的时机

Sun JDK主要根据方法上的上个计数器来计算是否超过阈值,如果超过则采用编译执行的方式

1. 调用计数器

记录方法调用次数,在client模式下默认为1500次,在server模式下默认为10000次,可通过-XX:CompileThreshold=10000来设置

2. 回边计数器

循环执行部分代码的执行次数,默认在client模式时为933,在server模式下为140,可通过-XX:OnStackReplacePercentage=140来设置