JVM 第一篇 - Java 程序的编译与优化

Java 存在三类编译过程:

  • 前端编译:前端编译器把.java 文件转变为.class 文件的过程
  • 即时编译:Java 虚拟机 的即时编译器在运行期把字节码转变为本地机器码的过程
  • 提前编译:提前编译器直接把程序编译为与目标机器指令集相关的二进制代码的过程

这三类编译过程分别对应下面三类编译器:

  • 前端编译器: JDK 的 javac等。
  • 即时编译器:也叫 JIT 编译器(Just In Time),比如 HotSpot 虚拟机的 C1、C2编译器,Graal 编译器。
  • 提前编译器:也称 AOT(Ahead Of Time)编译器,比如 JDK 的 Jaotc 等。

三类编译过程做的事情:

  • 前端编译器:降低编码复杂度,提高编码效率
  • 即时编译器:优化程序执行效率

1. 前端编译与优化

javac 编译器是一个由 Java 语言编写的程序。

在 jdk6 之前,javac 不属于 Java SE API 的一部分;jdk6 之后 javac 源码位于/langtools/src/share/classes/com/sun/tools/javac 中;jdk9 之后 javac 源码位于/src/jdk.compiler/share/classes/com/sun/tools/javac 中。

javac 的编译过程分为 1 个准备过程和 3 个处理过程:

(1)准备:初始化插入时注解处理器;

(2)解析与填充符号表过程,包括词法、语法分析和填充符号表;

  • 词法分析:将源代码的字符流转变为标记(token)集合的过程
  • 语法分析:根据 token 序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表示方式
  • 填充符号表:符号表是由一组符号地址和符号信息构成的数据结构

(3)插入式注解处理器的注解处理过程;

  • 插入式注解处理器:允许读取、修改、添加抽象语法树中的任意元素。如果插入式注解处理器在处理注解期间对语法树进行过修改,编译器将回到解析与填充符号表的过程重新处理。比如 lombok 通过注解来生成 getter/setter 方法。

(4)语义分析与字节码生成过程,包括标注检查、数据流及控制流分析、解语法糖、字节码生成。

  • 标注检查:检查包括变量使用前是否已被声明、变量与赋值之间的数据类型是否匹配等
  • 数据流及控制流分析:检查包括局部变量在使用前是否赋值、方法的每条路径是否都有返回值、是否处理了所有的受查异常
  • 解语法糖:java 虚拟机运行时不支持泛型、变长参数、自动装箱拆箱等语法糖语法,这些语法糖在编译阶段被还原为原始的基础语法结构;
  • 字节码生成:不仅将前面各个阶段生成的信息(语法树、符号表)转化为字节码指令写入到磁盘中,还有少量代码添加(添加实例构造器()和类构造器()方法到语法树中)和转换工作(比如把字符串的加操作替换为 StringBuffer 或 StringBuilder 的 append 操作)。

2. 即时编译器

2.1 解释器与编译器

Java 程序在经过 javac 编译成为字节码之后,解释器一边将字节码解释为机器语言一边执行。为了提高代码的执行效率,对于执行次数比较频繁的代码块,虚拟机会把这些热点代码编译为机器码,当再次执行到这些热点代码的时候,就不需要解释器进行解释而可以直接执行了。

目前主流的 Java 虚拟机都采取的是这种解释器和即时编译器并存的运行架构。

解释器的优点:当程序启动的时候,可以立即运行。

即时编译器的优点:当程序启动后,随着时间的推移,越来越多的热点代码被编译为机器码,执行效率更高。

HotSpot 虚拟机中有两个即时编译器,分别为客户端编译器( Client Compiler)和服务端编译器(Server Compiler),又分别称为 C1 编译器和 C2 编译器。

在分层编译的工作模式出现之前,虚拟机通常采用解释器与一个编译器搭配的方式工作,这种模式称为“混合模式”,与之对应的有“解释模式”(只使用解释器)和“编译模式”(只使用编译器)。

为了在程序启动响应速度和运行效率之间达到平衡,虚拟机在编译子系统中加入了分层编译的功能,并划分了不同的编译层次:

  • 第 0 层:使用解释器执行,解释器不开启性能监控;
  • 第 1 层:使用客户端编译器执行,不开启性能监控;
  • 第 2 层:使用客户端编译器执行,只开启方法及回边次数统计等有限的性能监控;
  • 第 3 层:使用客户端编译器执行,开启全部性能监控;
  • 第 4 层:使用服务端编译器执行。

使用分层编译后,解释器、客户端编译器和服务端编译器可以协同工作。用客户端编译器获取更快的编译速度,用服务端编译器获取更好的编译质量,而解释执行的时候也无需承担收集性能监控信息的任务。

2.2 热点代码

即时编译器编译的目标是“热点代码”,热点代码主要有两类:

  • 被多次调用的方法
  • 被多次执行的循环体

对于上述两类热点代码,编译器进行编译的目标都是整个方法体,而不是单独的循环体。

虚拟机采取“热点探测”来决定某段代码是不是“热点代码”,需不需要触发即时编译。主流的热点探测方法有两种:

  • 基于采样的热点探测:虚拟机周期性检查各个线程的调用栈顶,出现次数多的方法就是热点方法
  • 基于计数器的热点探测:虚拟机为每个方法建立计数器,统计方法的执行次数,超过一定阈值的就是热点方法

HotSpot 虚拟机采取第二种基于计数器的热点探测方法,并且为每个方法准备了两类计数器:方法调用计数器和回边计数器。

2.3 编译过程

当探测到存在热点方法时,执行线程会向虚拟机提交编译请求,编译动作默认在后台的编译线程中进行。在编译完成前,热点方法仍然按照解释方法继续执行。

对于客户端编译器,编译过程相对简单快速,更加关注局部性的优化。而服务端编译器更加关注全局的高复杂度的优化,会执行大部分经典的优化动作,比如无用代码消除、循环展开、消除公共子表达式等。

3. 提前编译器

提前编译的发展方向有两条路:

  • 与传统的 C/C++编译器类似,在程序运行之前吧程序代码编译成为机器码的静态编译工作;
  • 把原本即时编译器在运行时要做的编译工作提前做好,运行时直接加载使用。

方向一是为了解决即时编译的最大弱点:即时编译要占用程序运行时间和运算资源。

方向二的本质是给即时编译器做缓存加速,改善 Java 程序的启动时间,以及需要一段时间预热后才能到达最高性能的问题。

即时编译相比提前编译的优势:

  • 解释器或者客户端编译器在运行过程中会不断收集性能监控信息,即时编译器可以基于性能监控信息进行动态优化;
  • 静态优化必须保证优化前后程序等效,而即时编译可以根据性能监控信息作出一些激进的优化,如果优化有问题还可以退回到低级编译器甚至解释器上去执行
  • Java 语言天生就是动态链接的,一个个 Class 文件在运行期被加载到虚拟机内存中,然后在即时编译器中产生优化后的本地代码

4. 编译器优化技术

编译器的目标是实现将程序代码翻译为本地机器码的工作,但是其难点在于翻译过程中的代码优化,是否能输出经过优化的高质量的代码。下面介绍几种最经典的优化方法:

4.1 方法内联

最重要的优化技术,除了消除方法调用的成本(建立栈帧)外,还是其他优化方法的基础。

4.2 逃逸分析

方法逃逸:方法中定义的对象可以作为调用参数传递到其他方法中。

线程逃逸:线程中定义的对象赋值给可以在其他线程中访问的实例变量,从而可以被其他线程所访问。

根据对象的动态作用域,逃逸程度可以分为:不逃逸、方法逃逸、线程逃逸。对于不同逃逸程度的对象,可以采取不同的优化:

(1)栈上分配:对于不逃逸和方法逃逸的对象可以在栈上分配内存

(2)同步消除:对于不会出现线程逃逸的变量,可以消除该变量的同步措施

4.3 公共子表达式消除

如果一个表达式之前已经被计算过了,并且从先前的计算到现在表达式中所有变量的值都没有发生变化,那么该表达式就称为公共子表达式。对于这种表达式,直接使用前面的计算结果即可。

4.4 数组边界检查消除

对于虚拟机子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作。如果在编译期可以确定数组表示下标的循环变量不会超过数组范围,那么在循环中就可以把整个数组的上下界检查消除掉。

你可能感兴趣的:(JVM,java,jvm,开发语言)