后端编译与优化

即时编译器

解释器与编译器

在整个Java虚拟机执行架构里,解释器与编译器经常是相辅相成地配合工作

stateDiagram-v2  state 编译器 {    ClientCompiler    ServerCompiler  }  解释器 --> 编译器: 即时编译  编译器 --> 解释器: 逆优化

HotSpot在JDK10之前存在两个编译器:

在JDK10之后 出现了一个Graal编译器 目的是取代C2

无论采用的编译器是C1还是C2,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”

可以使用参数“-Xint”强制虚拟机运行于“解释模式

使用参数“-Xcomp”强制虚拟机运行于“编译模式”

分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,虚拟机会根据实际情况在不同层级之间转换:

编译对象与触发条件

多次调用的方法以及多次执行的循环体被称为热点代码 这些热点代码会被进行编译

这两种情况编译的目标都会是方法体 第二种情况的编译方式因为编译发生在方法执行的过程中,因此被很形象地称为“栈上替换”

JVM 对于热点代码的判断称之为热点检测,目前有两种方法:

  1. 基于采样:采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”
  2. 基于计数器:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”

HotSpot 采用的是计数器方式 可以通过虚拟机参数-XX:CompileThreshold来人为设定这个计数器的阈值

默认设置下 HotSpot的计数器并不是绝对计数,而是一段时间内的相对计数,当这段时间一过,该方法的调用计数器就会被减少一半,称之为半衰周期,可以使用虚拟机参数-XX:-UseCounterDecay来关闭热度衰减

flowchart TD    A[Java 方法入口] --> B{是否存在已编译版本}    B -- 是 --> C[执行编译后的本地代码版本]    B -- 否 --> D[方法调用计数器值加1]    D --> E{两计数器值之和是否超过阈值}    E -- 是 --> F[向编译器提交编译请求]    E -- 否 --> G[以解释方式执行方法]    F --> G    G --> H[Java 方法返回]    C --> H

与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数

flowchart TD    A[遇到回边指令] --> B{是否存在已编译版本}    B -- 是 --> C[执行编译后的本地代码版本]    B -- 否 --> D[回边计数器值加1]    D --> E{两计数器值之和是否超过阈值}    E -- 是 --> F[向编译器提交 OSR 编译请求]    F --> G[调整回边计数器值]    G --> H[以解释方式继续执行]    E -- 否 --> H    H --> I[Java 方法返回]    C --> I

编译过程

在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-LevelIntermediate Representation,HIR,即与目标机器指令集无关的中间表示)

在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示)

最后的阶段是在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码

intrinsic

针对特定平台架构的一种优化手段,虚拟机将为标注了@IntrinsicCandidate注解的方法额外维护一套高效实现

提前编译器

GCJ(GNU Compiler for Java)出现后提前编译基本没有什么太大进展,因为这与Java一次编译到处运行的理念相悖,直到Android 的ART的出现,也震撼到了 Java 世界

提前编译的得与失

  1. 传统提前编译打破的就是即时编译占用了原本给程序运行时的资源
  2. 提前编译的第二条路就是在给即时编译做加速

但即使编译又有以下优势:

  1. 性能分析制导优化(Profile-Guided Optimization,PGO),可以在运行阶段收集监控信息 从而更好地进行优化
  2. 激进预测性优化(Aggressive Speculative Optimization),即使编译激进优化可以做出一些假设 如果假设错了 大不了就回退到解释模式 但是提前编译不能做这些假设 它必须保证程序的外部影响优化前优化后都是一样的
  3. 链接时优化(Link-Time Optimization,LTO)

jaotc 提前编译

javac Main.classjaotc --output Main.so Main.class # 将class编译为静态链接文件java -XX:+UnlockExperimentalVMOptions -XX:AOTLibrary=./Main.so Main # 运行它

JNI

在 Java 代码中定义 native 方法之后,可以通过 javac -h命令,自动生成包含符合命名规范的 C 函数的头文件,另外一种则是可以在 C 代码中主动链接

// 主动链接// 注:Object类的registerNatives方法的实现位于java.base模块里的C代码中static JNINativeMethod methods[] = {    {"hashCode",    "()I",                    (void *)&JVM_IHashCode},    {"wait",        "(J)V",                   (void *)&JVM_MonitorWait},    {"notify",      "()V",                    (void *)&JVM_MonitorNotify},    {"notifyAll",   "()V",                    (void *)&JVM_MonitorNotifyAll},    {"clone",       "()Ljava/lang/Object;",   (void *)&JVM_Clone},};JNIEXPORT void JNICALLJava_java_lang_Object_registerNatives(JNIEnv *env, jclass cls){    (*env)->RegisterNatives(env, cls,                            methods, sizeof(methods)/sizeof(methods[0]));}

链接完成之后就能通过 gcc 命令将其编译成为动态链接库,随后使用 System.loadLibrary 方法载入动态库

Graal

为了让 Java 虚拟机与 Graal 解耦合,我们引入了Java 虚拟机编译器接口(JVM Compiler Interface,JVMCI)

Graal 是一 Java 编写的即使编译器,其采取了一些激进的优化手段来使得编译出来的代码可以取得比 C2 更高的执行性能

编译器优化技术

方法内联

被戏称为优化之母 除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础

在 Java 中,基本所有对象的方法都是虚方法,也就是这些方法直到运行时才能确定被调用的是哪一个,这为方法内联带来了困难

为了解决虚方法的内联问题,Java虚拟机首先引入了一种名为类型继承关系分析(Class HierarchyAnalysis,CHA)的技术用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等

如果CHA也判断方法有多个版本 那么会进入最后一次内联缓存优化尝试

总而言之,多数情况下Java虚拟机进行的方法内联都是一种激进优化

逃逸分析

是为其他优化措施提供依据的分析技术

分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部所引用 逃逸级别从低到高:

根据这些逃逸级别,可以采取不同的优化手段:

公共子表达式消除

如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替E

字段访问优化

循环优化

向量化

CPU 提供的 SIMD 指令只能被 @IntrinsicCandidate 的方法使用,自动向量化能够针对展开的计数循环,进行向量化优化

数组边界检查消除

使用隐式异常来优化如数组边界检查以及NPE判断等每次操作的成本,用Java伪代码代表如下:

try {  return a[i]} catch(Exception e){  // 使用进程级别的Segment Fault信号的异常处理器处理这个异常}