前言
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 并没有实现栈上分配。
- 锁消除
- 标量替换:如果对象不被外部访问,且这个对象可以被拆分,则可以不创建这个对象,而是直接创建它的成员变量来替代。对象拆分后,对象的成员变量在栈或寄存器上,原对象无需内存空间。