早期优化
Java编译器可能是指一个前端编译器(其实叫“编译器的前端”更准备一些),把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把*.java文件编译成本地机器代码的过程。例如:
前端编译器:Sun的javac,Eclipse JDT中的增量式编译器(ECJ);
JIT编译器:HotSpot VM的C1、C2编译器;
AOT编译器:GUN Complier for the Java(GCJ),Excelsior JET;
本章我们主要针对javac编译器。
需要注意的是javac这类编译器对代码的运行效率几乎没有任何优化措施。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由javac产生的Class文件也同样能享受到编译器优化所带来的好处。
但是javac做了许多针对java语言编码过程的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持,可以说,Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。
javac编译器
javac编译器本身就是一个由Java语言编写的程序。虽然Java虚拟机规范有专门的一章“Compiling for the Java Virtual Machine”,但都是以举例的形式描述,并没有对如何把Java源码文件转变为Class文件的编译过程进行十分严格的定义,这导致Class文件编译在某种程度上是与具体JDK实现相关的,在一些极端情况,可能出现一段代码javac编译器可以编译,但是ECJ编译器就不可以编译的问题。从Sun Javac的代码来看,编译过程大致可以分为3个过程:
解析与填充符号表过程
插入式注解处理器的注解处理过程
分析与字节码生成过程
解析与填充符号表
词法语法分析
词法分析是将源代码的字符流转变为标记(Token)集合,每个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记。如“int a=b+2”这句代码包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但是它只是一个Token,不可在拆分。
语法分析是根据Token序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以使一个语法结构。
填充符号表
完成了语法分析和词法分析之后,下一步就是填充符号表的过程。
符号表(Symbol Table)是由一组符号地址和符号信息构成的表格。
注解处理器
在JDK 1.5之后,Java语言提供了对注解(Annotation) 的支持,这些注解与普通的Java代码一样,是在运行期间发挥作用的。在JDK 1.6 中实现了JSR-269 规范,提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,我们可以把它看做是一-组编译器的插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插人式注解处理器都没有再对语法树进行修改为止,每--次循环称为一个Round,也就是图10-4中的回环过程。有了编译器注解处理的标准API后,我们的代码才有可能千涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以通过插人式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够的创意,程序员可以使用插人式注解处理器来实现许多原本只能在编码中完成的事情,本章最后会给出-一个使用插人式注解处理器的简单实战。在Javac源码中,插入式注解处理器的初始化过程是在itPorcessAnnotations()方法中完成的,而它的执行过程则是在peoessnnotations()方法中完成的,这个方法判断是否还有新的注解处理器需要执行,如果有的话,通过com. sn.tools javac processing.JavacProcessingEnvironment类的doProessng0方法生成一个新的JavaCompiler对象对编译的后续步骤进行处理。
语义分析
标注检查
标注检查步骤检查的内容包括诸如变量使用前是否被声明、变量与赋值之间的数据类型是否能够匹配等。
数据及控制流分析
数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
java语法糖
几乎各种语言或多或少都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。不过也有一种观点认为语法糖不一定都是有益的,大量添加和使用“含糖”的语法,容易让程序员产生依赖,无法看清语法糖的糖衣背后,程序代码的真实面目。
总而言之,语法糖可以看做是编译期实现的一些“小把戏”,这些“小把戏”可能会使得效率“大提升”,但我们也应该去了解这些“小把戏”背后的真实世界,那样才能利用好它们,而不是被它们所迷惑。
泛型与类型擦除
泛型的本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。
Java只在程序源码中存在,在编译后的字节码文件中就已经替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码,因此,对于运行期的Java语言来说,ArrayList
另外,从Signature属性的出现还可以得出结果,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。
直接看个实例:自动装箱、拆箱与遍历循环
一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数5中语法糖
package org.github.lujiango.javac;
import java.util.Arrays;
import java.util.List;
public class Test {
public static void main(String[] args) {
List list = Arrays.asList(1,2,3,4);
int sum = 0;
for (int i : list) {
sum += i;
}
System.out.println(sum);
}
}
自动装箱、拆箱和遍历循环
package org.github.lujiango.javac;
import java.io.PrintStream;
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
public class Test
{
public static void main(String[] args)
{
List list = Arrays.asList(new Integer[] { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) });
int sum = 0;
for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) { int i = ((Integer)localIterator.next()).intValue();
sum += i;
}
System.out.println(sum);
}
}
自动装箱、拆箱在编译之后被转换成了对应的包装和还原方法(Integer.valueOf(),Integer.intValue()),而遍历循环则把代码还原成立了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因,变长参数在调用的时候变长了一个数组类型的参数。
条件编译
Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这--工作将在编译器解除语法糖阶段(com.: sun.toolsjavac. comp.Lower类中)完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此它只能实现语句基本块(Bleck)级别的条件编译,而没有办法实现根据条件调整整个Java类的结构。
字节码生成
字节码生成是javac编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤锁生成的信息(语法树、符号表)转换成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。
晚期优化
概述
在部分商用虚拟机(Sun HotSpot、IBM J9)中,案卷程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文中简称JIT编译器)。
即时编译器并不是虚拟机必须的部分,Java虚拟机规范并没有规定Java虚拟机内必须要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。
由于Java虚拟机规范没有具体的约束规则去限制即时编译器应该如何实现,所以这部分功能完全是与虚拟机具体实现相关的内容。本章提及的编译器、即时编译器都是指HotSpot虚拟机内的即时编译器,虚拟机也是特指HotSpot虚拟机。
虚拟机内的即时编译器
解释器与编译器
许多主流的商用虚拟机,如HotSpot,J9等,都同时包含解释器与编译器。
解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler和Server Compiler,或简称为C1编译器和C2编译器(也叫Opto编译器)。目前主流的HotSpot虚拟机中,默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式,HotSpot虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用-client或-server参数去强制指定虚拟机运行在Client模式或Server模式。
无论采用的编译器是Client Compiler还是Server Compiler,解释器与编译器搭配使用的方式在虚拟机中称为“混合模式”(Mixed Mode),用户可以使用参数-Xint强制虚拟机运行于解释模式(Interpreted Mode),这是编译器完全不介入工作,全部代码都使用解释方式执行。也可以使用参数-Xcomp强制迅疾运行于编译模式(Compiled Mode,已废弃)。
可以通过虚拟机的-version命令的输出结果显示出3种模式(Mixed Mode,Interpreted Mode,Compiled Mode):
由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启动分层编译(Tiered Compilation)的策略。
分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:
第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译;
第1层,也称为C1编译,将字节码编译为本地代码,进行监督、可靠的优化,如有必要将加入性能监控的逻辑;
第2层(或2层以上),也称为C2编译,也是讲字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。
编译对象和触发条件
在运行过程中会被即时编译器编译的“热点代码”有两类,即:
被多次调用的方法;
被多次执行的循环体;
对于第一种情况,由于是方法调用触发的编译,因此编译器理所当然会以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方式,而对于后一种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行过程中,因此形象地称为栈上替换(On Stack Replacement,简称为OSR编译,即方法栈帧还在栈上,方法就被替换了)。
判断一段代码是不是热点代码,是不是需要出发即时编译,这样的行为称为热点探测(Hot Spot Detection),其实进行热点探测并不一定要知道方法具体被调用多少次,目前主要的热点探测判定方式有两种,分别如下:
基于采样的热点探测(Sample Based Hot Spot Detection)
基于计数器的热点探测(Counter Based Hot Spot Detection)
在HotSpot中使用了基于计数器的热点探测方法,因此为每个方法准备两类计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值,就会触发JIT编译。
编译过程
在默认情况下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过参数-XX:-BackgroundComilation来禁止后台编译,在禁止后台编译后,一旦达到JIT的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,知道编译过程完成后再开始执行编译器输出的本地代码。
后台执行编译的过程中,编译器做了什么事情呢?
Server Compiler和Client Compiler两个编译器的编译过程是不一样的。
Client Complier
对于ClientCompiler来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
第一个阶段,一个平台独立的前端将字节码构造成一种高级中间代码表示(HIR),在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成
第二个阶段,一个平台相关的后端从高级中间代码表示(HIR)中产生低级中间代码表示(LIR),在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等。
第三个阶段,平台相关的后端使用线下扫描算法,在LIR上分配寄存器,并在LIR上做窥空优化,然后产生机器代码
Server Complier
Server Compiler是专门面向服务端的典型应用并未服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎达到GUN C++编译器使用-O2参数时的优化强度,它会执行所有经典的优化动作,如无用代码消除,循环展开,循环表达式外提,消除公共子表达式,常量传播,基本块重排序等。还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除,空值检查消除。
编译优化技术
以编译方式执行本地代码比解释方式更快,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机几乎把对代码的所有优化措施都集中在了即时编译器之中。因此一般来说,即时编译器产生的本地代码会比javac产生的字节码更加优秀。
优化技术主要分为如下几类:(1)编译器策略(2)基于性能监控的优化技术(3)基于证据的优化技术(4)数据流敏感重写(5)语言相关的优化技术(6)内存及代码位置变换(7)循环变换(8)全局代码调整(9)控制流图变换