JVM 即时编译器

Posted by Yano on December 12, 2020

前言

Java 中有 2 种编译:

  • 前端编译:.java 文件 → .class 文件
  • 运行时编译:运行时将字节码→机器码,通过 JIT解释器

本文分析运行时编译器,如何实现对 Java 代码的优化。

类编译加载执行过程

Java 从编译到运行的过程如下:

类编译

就是前面所说的 .java 文件 → .class 文件,可以通过 javac 命令完成。

类加载

当一个类被创建实例或者被其它对象引用时,虚拟机在没有加载过该类的情况下,会通过类加载器将字节码文件加载到内存中。

类连接

验证

验证类符合Java规范和JVM规范,在保证符合规范的前提下,避免危害虚拟机安全。

准备

为类的静态变量分配内存,初始化为系统的初始值。对于final static修饰的变量,直接赋值为用户的定义值。例如,private final static int value=123,会在准备阶段分配内存,并初始化值为123,而如果是 private static int value=123,这个阶段value的值仍然为0

解析

将符号引用转为直接引用的过程。在编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等。如果要使用这些类和方法,就需要把它们转化为JVM可以直接获取的内存地址或指针,即直接引用。

类初始化

VM首先将执行构造器<clinit>方法,编译器会在将 .java 文件编译成 .class 文件时,收集所有类初始化代码,包括静态变量赋值语句静态代码块静态方法,收集在一起成为<clinit>() 方法。

即时编译

类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译——即时编译。

在执行时,虚拟机首先会由解释器完成编译,当发现热点代码后,即时编译器(JIT)会把这些代码编译成本地平台相关的机器码,并进行各个层次的优化,保存到内存中。

即时编译器类型

Java 8 有2个:

  • C1 编译器(Client Compiler):简单快速的编译器,主要在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序。
  • C2 编译器(Server Compiler):适用于执行时间较长的程序。

Java 9 引入了 AOT 编译器,可以在程序运行前进行静态编译,可以避免运行时的编译消耗和内存消耗,class 文件可以直接编译成 so 二进制文件。

Java 10 引入了JIT编译器Graal,C1 编译器和 C2 编译器都是用 C++ 实现的,Graal 是使用 Java 实现的。

热点探测

是 JIT 优化的条件,是基于计数器的,为每个方法建立计数器统计方法的执行次数,超过一定阈值就认为是热点方法

有 2 种计数器:

  • 方法调用计数器(Invocation Counter):统计方法被调用的次数
  • 回边计数器(Back Edge Counter):统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge).目的是为了触发OSR(On StackReplacement)编译,即栈上编译,将热点代码编译成机器语言并缓存。

编译优化技术

方法内联

调用一个方法通常要经历压栈和出栈。调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置。

这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。 方法调用会产生一定的时间和空间方面的开销。

对于那些方法体代码不大,又频繁调用的方法,可以使用方法内联,把目标方法的代码复制到发起调用的方法中,避免发生真实的方法调用。

逃逸分析

逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。

  • 栈上分配:对象默认是分配在堆中的,对象不再使用时,需要垃圾回收。如果逃逸分析一个对象仅在方法中使用,可以将对象分配在栈上,该对象可以随着方法的调用结束自动回收内存。Java 8 并没有实现栈上分配。
  • 锁消除
  • 标量替换:如果对象不被外部访问,且这个对象可以被拆分,则可以不创建这个对象,而是直接创建它的成员变量来替代。对象拆分后,对象的成员变量在栈或寄存器上,原对象无需内存空间。