【JVM】详解 编译器原理与优化技术
前端编译器Javac
编译过程
1):准备过程:初始化插入式注解处理器。
2):解析与填充符号表过程
- 词法、语法分析。将源代码的字符流转变为标记集合,构造出抽象语法树。
- 填充符号表。产生符号地址和符号信息。
3):插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段
4):分析与字节码生成过程
- 标注检查。对语法的静态信息进行检查。
- 数据流及控制流分析。对程序动态运行过程进行检查。
- 解语法糖。将简化代码编写的语法糖还原为原有的形式。
- 字节码生成。将前面各个步骤所生成的信息转化成字节码。
解析与填充符号表
1):词法、语法分析
词法分析就是将源代码的字符流转为Token集合。
如“int a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成
语法分析是根据标记序列构造抽象语法树(AbstractSyntax Tree,AST)。
抽象语法树的每一个节点都代表着程序代码中的一个语法结构 (Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构
这个阶段之后,编译器就只会对AST进行操作了,不会再操作源代码了。
2):填充符号表
符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构。完成了语法分析和词法分析之后,下一个阶段是对符号表进行填充的过程。
插入式注解处理器的注解处理
插入式注解处理器可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。
如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止。

语义分析与字节码生成
语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等。
1):标注检查
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配。
在这个阶段还会进行常量折叠优化。经过优化,a = 1 + 2和 a = 3程序的工作量是一样的。
2):数据及控制流分析
数据及控制流分析主要负责检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
3):解语法糖
Java中最常见的语法糖包括了泛型、变长参数、自动装箱拆箱,等等,Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。
4):字节码生成
字节码生成把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器同时还进行了少量的代码添加和转换工作。
语法糖
1):泛型
Java泛型在编译的时候会发生泛型擦除,如List<Integer>和List<String>在编译后都会变味List<Object>。这么做的目的是为了二进制向后兼容性。
2):自动装箱拆箱
3):条件编译
Java进行条件编译的方法就是使用条件为常量的if语句。
public static void main(String[] args) {if (true) {System.out.println("block 1");} else {System.out.println("block 2");}
}这段代码在编译后只会剩下判断为true的语句,else后的语句则会被省略。
后端编译器
后端编译器包含解释器和编译器,二者相辅相成,共同发挥作用。

解释器
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。
解释器还可以作为编译器激进优化时后备的“逃生门”,让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段。
当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行
即时编译器
HotSpot虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为C1编译器和C2编译器),第三个是在JDK 10时才出现的、长期目标是代替C2的Graal编译器。
分层
分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
- 第0层。使用解释器执行,并且解释器不开启性能监控功能(Profiling)。
- 第1层。使用C1编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
- 第2层。使用C1编译器执行,仅开启方法及回边次数统计等有限的性能监控功能。
- 第3层。使用C1编译器执行,开启全部性能监控,除了第2层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
- 第4层。使用C2编译器将字节码编译为本地代码,相比起C1编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

编译对象与触发条件
会被即时编译器的代码为热点代码,什么代码会被判断为热点代码?被多次调用的方法和被多次执行的循环体。
这两种方法编译的对象都是整个方法体,第一个不用多说。被多次执行的循环体编译过程会发生栈上替换,即编译发生在方法执行的过程中。
如何判断热点代码?
- 基于采样的热点探测(Sample Based Hot Spot Code Detection)。如果发现某个方法经常出现在栈顶,那这个方法就是热点方法。
- 基于计数器的热点探测(Counter Based Hot Spot Code Detection)。为每个方法建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法。
编译过程
在第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。
在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
最后的阶段是在平台相关的后端使用线性扫描算法(Linear ScanRegister Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。

代码优化
方法内联
方法内联是将被调用方法的代码 “嵌入” 到调用者方法中,从而消除方法调用的开销并为后续优化创造条件。
虚方法内联问题:假如有ParentB和SubB是两个具有继承关系的父子类型,并且子类重写了父类的get()方法,那么b.get()是执行父类的get()方法还是子类的get()方法,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。
JVM引入了类型继承关系分析(Class Hierarchy Analysis,CHA)的技术来解决这个问题。如果遇到虚方法,则会向CHA查询此方法在当前程序状态下是否真的有多个目标版本可供选择。
逃逸分析
逃逸分析:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸。
栈上分配:如果逃逸分析确定一个对象只会在当前线程之内运行,那么就会将这个对象分配到当前方法对应的栈上,对象所占用的内存空间就可以随栈帧出栈而销毁。
标量替换:如果逃逸分析一个对象不会被外部访问,并且这个对象可以被拆散,那么把一个Java对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。
同步消除:逃逸分析确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施(如synchronized)也就可以安全地消除掉。
公共子表达式消除
如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就称为公共子表达式。那么这个E就不会被多次重复计算,它的结果会被程序后续复用。
数组边界检查消除
如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那么在循环中就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。
