JVM编译过程和优化措施

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

一、前端编译

前端编译就是将Java源码文件编译成Class文件的过程,编译过程分为4步:

1 准备

初始化插入式注解处理器(Annotation Processing Tool)。

2 解析与填充符号表

将源代码的字符流转变为标记(Token)集合,构造出抽象语法树(AST)

抽象语法树每个节点都代表着程序代码中的一个语法结构,包含包、类型、修饰符、运算符、接口、返回值、代码注释等内容。

编译器的后续行为都是基于抽象语法树来进行。

符号表可以理解为一个K-V结构的集合,存储了以下信息:

  • 变量名和常量
  • 过程和函数名称
  • 文字常量和字符串
  • 编译器生成的临时文件
  • 源语言中的标签

编译器在运行过程中会通过符号表来方便查找所有标识。

3 注解处理器

注解处理器可以看做是一组编译器的插件,用来读写抽象语法树中任意元素。

简单来说,注解处理器的作用就是让编译器对特定注解执行特定逻辑,一般用来生成代码,比如常用的lombok和mapstruct都是基于此。

如果在这期间语法树被修改了,编译器将回到“解析与填充符号表”的过程重新处理,这个循环被称作“轮次(Round)”。

这是开发人员唯一能控制编译器行为的方式。

4 分析与字节码生成

前置步骤可以成功生成一个结构正确的语法树,语义分析则是校验语法树是否符合逻辑。

语义分析又分为四步:

4.1 标注检查

标注检查主要用来检查表量是否被声明、变量与赋值是否匹配等等。

在这个阶段,还会进行被称作“常量折叠”的优化,比如Java代码int a = 1 + 2;,实际编译后会被折叠为int a = 3

4.2 数据及控制流分析

数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。

4.3 解语法糖

Java中存在非常多的语法糖用来简化代码实现,比如自动的装箱拆箱、泛型、变长参数等等。这些语法糖会在编译器被还原为基础语法结构,这个过程被称为解语法糖。

4.4 字节码生成

这是javac编译过程的最终阶段,编译器会在这个阶段把前面生成的抽象语法树、符号表生成为class文件,还进行了少量的代码添加和转换。

二、运行时编译

运行时编译的主要目的是为了将代码编译成本地代码,从而节省解释执行的时间。

但是JVM并不是启动后立刻开始执行编译,而是为了执行效率先进行解释执行。等到程序运行过程中,根据热点探测,找出热点代码后,对其进行针对性的编译来逐渐代替解释执行。所以HotSpot JVM采用的是解释器和即时编译器并存的架构。

1 使用编译执行的时机

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

  • 调用计数器

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

  • 回边计数器

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

2 编译模式

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

2.1 Client compiler

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

2.1.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.1.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()方法的内部逻辑。

2.1.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(),而要先判断的原因。

2.2 Server complier

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

逃逸分析

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

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

2.2.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.2.2 栈上分配

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

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

2.2.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);

2.3 OSR(On Stack Replace,栈上替换)

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

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

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