早期(编译期)优化
编译完成了从程序到抽象语法树或中间字节码的生成
编译器种类:
- 前端编译器 如:javac
- JIT编译器 如:HotSpot VM的C1、C2
- AOT编译器
javac编译器
把*.java文件转变为*.class文件的过程,对代码运行效率几乎没有优化,相当多新生的Java语法特性都是靠编译器的语法糖来实现的,由java语言编写的程序。
编译过程大致可以分为3个过程:
- 解析与填充符号表过程
- 插入式注解处理器的注解处理过程
- 分析与字节码生成过程
这3个步骤之间的关系与交互顺序如下:
解析与符号填充表
解析步骤由
parseFiles()
方法完成,解析步骤包括了词法分析和语法分析两个过程
1、词法分析与语法分析
词法分析:将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记,在Javac的源码中,词法分析过程由com.sun.tools.javac.parser.Scanner类来实现。
语法分析是根据Token序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表述方式。语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、接口、返回值甚至代码注释都可以是一个语法结构。语法分析过程由com.sun.tools.javac.parser.Parser类实现,这个阶段产出的抽象语法树由com.sun.tools.javac.tree.JCTree类表示,经过这个步骤之后,编译器就基本不会再对源码文件进行操作了,后续的操作都是建立在抽象语法树之上的
2、填充符号表
完成抽象语法树之后,下一步就是填充符号表的过程,即
enterTrees()
方法。符号表是由一组符号地址和符号信息构成的表格,类似于哈希表中K-V值对的形式。符号表中所登记的信息在编译的不同阶段都要用到。当对符号名进行地址分配时,符号表是地址分配的依据。填充过程由com.sun.tools.javac.comp.Enter类实现
注解处理器
JDK1.5之后,Java提供了对注解的支持,这些注解与普通的Java代码一样,在运行期间发挥作用。 有了编译器注解处理的标准API后,我们的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件之中访问到,所以使用插入式注解处理器在功能上有很大的发挥空间
语义分析与字节码生成
语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查
1、标注检查
Javac的编译过程中,语义分析过程分为标注检查以及数据及控制流分析两个步骤,分别是
attribute()
和
flow()
方法
标准检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。在标准检查步骤中,还有一个重要的动作称为常量折叠
int
a =
1
+
2
;
语法树上仍然能看到字面量“1”、“2”以及操作符“+”,但是在经过常量折叠以后,它们将会被折叠为字面量“3”。由于编译期间进行了常量折叠,所以在代码里面定义“a=1+2”比起直接定义“a=3”,并不会增加程序运行期哪怕仅仅一个CPU指令的运算量
标注检查步骤在Javac源码中的实现类是com.xun.tools.javac.comp.Attr和com.sun.tools.javac.comp.Check类
2、数据及控制流分析
数据及控制流分析可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题
局部变量与字段(实例变量、类变量)是有区别的,它在常量池中没有CONSTANT_Fielddref_info的符号引用,自然就没有访问标志的信息,因此,将局部变量声明为final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障。在Javac的源码中,数据及控制流分析的入口是flow()方法,具体操作由com.sun.tools.javac.comp.Flow类来完成
3、解析语法糖
4、字节码生成
Java语法糖
泛型与类型擦除
泛型是JDK1.5的一项新增特性,它的本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法
Java语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型了,并且在相应的地方插入了强制转型代码,因此,对于运行期的Java语言来说,ArrayList<int>与ArrayList<String>就是同一个类,所以泛型技术实际上是Java语言的一颗语法糖,Java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛型
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}
把这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,代码如下:
public static void main(String[] paramArrayOfString)
{
HashMap localHashMap = new HashMap();
localHashMap.put("hello", "你好");
localHashMap.put("how are you?", "吃了没?");
System.out.println((String)localHashMap.get("hello"));
System.out.println((String)localHashMap.get("how are you?"));
}
当泛型遇到重载:
public static String method(List<String> list) {
System.out.println("invoke method(List<String> list)");
}
public static int method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
}
这段代码是不能被编译的,因此参数List<String>和List<Integer>编译之后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两种方法的特征签名变得一模一样
自动装箱、拆箱与遍历循环
自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因,变长参数在调用的时候变成了一个数组类型的参数
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
System.out.println(c == d);// true
System.out.println(e == f);// false
System.out.println(c == (a + b));// true
System.out.println(c.equals(a + b));// true
System.out.println(g == (a + b));// true
System.out.println(g.equals(a + b));// false
}
包装类的“==”运算在不遇到算术运算的情况下不会自动拆箱,以及它们equals()方法不处理数据转型的关系。
为什么c==d 返回true, 而 e == f 返回false?
因为装箱类型会使用缓存,变量共用一个实例。例如:
public static Integer valueOf(int i) {
if(i >= -128 && i <= IntegerCache.high)
return IntegerCache.cache[i + 128]; //一定范围内的对象,是共享的。
else
return new Integer(i);
}
条件编译
Java语言使用条件为常量的if语句,此代码中的if语句不同于其他Java代码,它在编译阶段就会被运行,生成的字节码之中只包含条件正确的部分
public static void main(String[] args) {
if (true) {
System.out.println("block 1");
} else {
System.out.println("block 2");
}
}
Java语言中条件编译的实现,也是Java语言的一颗语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这是在解语法糖阶段实现的
Java语言中还有不少的其他语言糖,如内部类、枚举类、断言语句、对枚举和字符串的switch支持、try语句中定义和关闭资源等等。
晚期(运行期)优化
在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java 程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码” (Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,下文中简称 JIT 编译器)。
即时编译器并不是虚拟机必需的部分,Java 虚拟机规范并没有规定 Java 虚拟机内必需要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。
HotSpot 虚拟机内的即时编译器
在本节中,我们将要了解 HotSpot 虚拟机内的即时编译器的运作过程,同时,还要解决以下几个问题:
- 为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?
- 为何 HotSpot 虚拟机要实现两个不同的即时编译器?
- 程序何时使用解释器执行?何时使用编译器执行?
- 哪些程序代码会被编译为本地代码?如何编译为本地代码?
- 如何从外部观察即时编译器的编译过程和编译结果?
解释器与编译器
编译对象与触发条件
上文中提到过,在运行过程中会被即时编译器编译的 “热点代码” 有两类,即:
- 被多次调用的方法。
- 被多次执行的循环体。
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),其实进行热点探测并不一定要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种(注:还有其他热点代码的探测方式,如基于“踪迹”(Trace)的热点探测再最近相当流行,像 Firefox 中的 TraceMonkey 和 Dalvik 中新的 JIT 编译器都用了这种热点探测方式),分别如下。
- 基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是 “热点方法”。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。
- 基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。
在 HotSpot 虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
我们首先来看看方法调用计数器。顾名思义,这个计数器就用于统计方法被调用的次数,它的默认阈值在 Client 模式下是 1500 此,在 Server 模式下是 10 000 次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否查过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。
如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法调用入口地址就会被系统自动改成新的,下一次调用该方法时就会使用已编译的版本。
……
……
……