JVM----编译优化

一.简介

Java 语言的编译期大致可以分为三种:

  • 前端编译器,把.java 文件编译成class 文件
  • 后端运行期编译器 JIT 编译器,把字节码转变为机器码的过程
  • 静态提前编译器 AOT ,直接把 .java 文件编译成 机器码

不同的编译时期,为了提高代码的运行效率,JVM 会进行一定的编译优化。

二.早期优化

早期编译主要是第一种编译,即把 .java 文件编译成 class 文件,这个过程的编译可以分为三个部分;

  • 解析与填充符号表.
  • 插入式注解处理器的注解处理过程.
  • 分析与字节码生成过程

1.解析与填充符号表

解析分为词法解析,和语法解析

(1).词法解析

将源代码中的字符流转变为标记集合,一个标记就是一个关键字,变量名,字面量,或者运算符. 比如

1
int a = 1 + b;

这部分的标记集合的元素就是:int , a , = ,1 , + ,b

(2).语法分析

将标记集合构造成抽象语法树的过程,抽象语法树就是程序代码语法结构的树形表示,每个节点都是一种语法的结构。

(3)填充符号表

符号表,是一组符号地址和符号信息构成的表格.

2.插入式注解处理器的注解处理过程

注解处理器可以读取,修改,添加抽象语法树种的任何元素,如果修改了编译器就回到解析过程重新开始处理直到没有注解处理器对语法树进行修改,每次循环称为一个 Round,通过注解处理器就可以干涉编译器的行为.

3.分析与字节码生成过程

分析主要是程序的语义分析,对抽象语法树中进行上下文性质的审查,如类型审查等。包括下面三种;

  • 标注检查
  • 数据及控制流分析
  • 解语法糖

(1)标注检查

检查变量是否被声明,赋值之间的数据类型等,还有变量折叠 比如 int a = 1 + 2 ; 就会折叠成 int a = 3;

(2)数据及控制流分析

对程序上下文逻辑进行更进一步的验证,使用前是否赋值,方法是否有返回值等.

(3)解语法糖

语法糖,也称糖衣语法.语法糖虽然不会提供实际性的功能改进,但是可以提高效率,提升语法的严谨性,减少编码出错的机会.虚拟机运行时不支持这些语法在编译阶段会还原简单的基础语法结构,并在相应的地方插入强制类型转换.Java 中的语法糖常见的有泛型,集合的遍历,变长参数,自动装箱和拆箱,条件编译等。

  • 泛型,java 语言中泛型实现方法称为类型擦除,这种泛型称为伪泛型,因此对于 List 和 List 是同一个类。
  • 自动装箱拆箱,转换为对应的包装方法和还原方法。
  • 遍历循环,则把代码还原成了迭代器的实现。
  • 条件编译,对于 if while 等条件分支中不成立的代码块消除掉。

(4)字节码生成

这一步就是把前面的步骤生成的信息转化为字节码写到磁盘中并进行少量的代码添加和转换工作,比如实力构造器 和类构造器

三.晚期优化

晚期优化主要是对一些热点代码进行优化。当虚拟机发现某个方法或代码块运行特别频繁时就会把这些代码认定为热点代码。在运行时候虚拟机会将这些代码编译成与本地平台相关的机器码,这个编译器叫即时编译器。而对于之前的一般的编译是通过解释器进行解释执行。两个各有区别,但也会相互配合工作。

1.解释器和即时编译器

当程序需要迅速启动和执行的时候,解释器可以省去编译时间,立即执行,解释执行可以节约内存。
在程序运行的时候,编译器可以把越来越多的代码编译成本地代码,提高执行效率。

解释器和编译器可以配合工作,可以从解释器转到即时编译器,
也可以从编译器逆优化到解释器。程序的执行一般先是解释器执行编译,在某些情况下解释器可启动即时编译器来进行一些优化编译,通常会为即时编译设置一个“逃生门”,这个逃生门的作用就是一旦一些优化不可执行编译的时候,即时编可以退回到解释器编译器,也叫逆优化。

1.即时编译器

即时编译器 (JIT 编译器),可以分为 Client 编译器和 Server 编译器,简称 C1 编译器和 C2 编译器。JVM 会根据不同的平台选取不同的编译器,以实现效率的提高。

2.分层编译

为了让解释器和即时编译器能够实现效率最大化,一般将代码分为几个层次进行编译。

  • 第 0 层,程序解释执行。
  • 第 1 层 C1 编译将字节码编译为本地代码,进行简单的优化。
  • 第 2 层或以上 称 C2 编译,将字节码编译为 本地代码,会启用一些编译时较长的优化。

3.热点代码

热点代码通常由两种;

  • 被多次调用的方法,编译时以整个方法做为编译对象,属于 JIT 编译方式。
  • 被多次执行的循环体,以循环体所在方法为对象,因为发生在方法中,所以称为栈上替换编译,OSP 编译。

判断是否为热点代码也有两种方式:

  • 基于采样的热点探测,周期性检查各个线程的栈顶
  • 基于计数器热点探测,使用方法调用计数器和回边计数器,当超过一定值时就出发 JIT 编译。

方法调用计数器,计数不是方法调用的绝对值,而是一段时间中的次数,
且这个次数没有超过阈值的时候会衰减。

回边计数器(循环体执行次数),计数的是执行的绝对次数。

这个是否进行热点代码优化判断过程如图:
image.png

提交编译请求后,解释方式会继续执行,而编译器的执行会在一个子线程中进行。

对于 C1,编译的过程可分为三个阶段:

  • 第一阶段,将字节码狗造成高级中间代码 HIR,以静态分配的形式存在
    SSA,进行一个方法内联,常量传播优化。
  • 第二阶段,从 HIR 产生低级中间代码 LIR。
  • 最后使用线性算法在 LIR 上分配寄存器,并在 LIR 上做窥孔优化,后产生机器代码。

2.编译优化

对于这部分的优化实际上有很多方式,经典的有有如下几种:

(1).公共子表达式消除

如果一个表达式 A 已经计算过了,并且从先前的计算到现在的 A 中所有的变量的值都没有变化,那么这个 A 就可以成为子表达式。比如;

1
2
3
4
5
int a = b * c + (3 + b * c );
//优化
int a = A + (3 + A);
//优化
int a = 2 * A + 3;

(2)数组边界检查消除

当访问一个数组的时候,经常会对数组的边界进行检查。而可以采取的优化就是在数据流分析时确定对数组的访问不会超过 数组范围就不再每次进行数组越界检查。

(3)方法内联

就是将一些没用的代码剔除,或者对于没有必要的方法跳转,将目标方法中的代码 “复制”到发起调用的方法之中,避免真实的方法调用。因为 Java 中的多态性的存在因此,内联有时不能确定目标方法,对应的情况如下:

  • 如果是非虚方法,直接进行内联。
  • 如果是虚方法
    • 只有一个版本,进行守护内联属于激进优化,要设置”逃生门“,没有变化时继续内联,若是加载了一个有变化的新类就直接抛弃退回解释执行。
    • 如果有多个版本,就做内联缓存,在未发生内联的时候,缓存为空,第一次调用方法时,记录方法的调用者,后面每次调用就进行判断,一致就继续进行,不一致就取消内联。

(3).逃逸分析

分析对象的动态作用域,当一个对象在方法中被定义后可能被外部方法所引用,例如传参,这称为方法逃逸,还有被其他线程访问的线程逃逸。

如果一个对象不会逃逸那么可以进行如下优化;

  • 栈上分配,在栈上分配内存.
  • 同步消除,消除没有必要的线程同步。
  • 标量替换,标量是指一个数据不能再分解,比如数据类型,而聚合量就是由多个标量组成的,如果一个对象可以被分解,且不会逃逸,就直接使用标量代替对对象的成员变量,而不直接创建这个对象。
0%