前言
一款优秀的Java程序除了体现在代码编写之外,还体现在编译器和虚拟机的优化上,编译器在编译Java代码时,会自动的对代码的运行逻辑进行一定的处理,当将Class文件加载到虚拟机时,虚拟机也会在类加载和运行的各个阶段对程序进行一定的优化,最后的效果就反应在了用户体验上。
编译优化
Javac编译器不像虚拟机那样使用C++语言(部分使用了C语言)实现,它是由Java语言实现的。虚拟机规范虽然详细的规定了Class文件的格式,但是并没有对编译器的编译过程进行严格的定义,这样就会产生分歧的情况,同一份Java代码可以在一个编译器上编译通过,但是另一个编译器可能就会被拒绝编译。下面列举的是Sun Javac的编译过程:
编译过程
编译的过程可以分为:解析与填充符号表、插入式注解处理器的注解处理和分析与字节码生成三个过程,如下图:
解析与填充符号表
解析过程又可以分为编译原理中的词法分析和语法分析。
1. 词法、语法分析
在编译过程中,标记是最小的元素,关键字、变量名、字面量、运算符等都可以算作一个标记。词法分析就是将源代码的字符流转变为标记集合。例如:
int a = b + 2;
在这句代码中,一共有“int”、“a”、“=”、“b”、“+”和“2”六个标记。语法分析则是将标记集合构造抽象语法树的过程,在构造完抽象语法树后,编译器将不再理会源代码文件,而是直接对语法树进行操作。
2. 填充符号表
符号表是由一组符号地址和符号信息构成的表格,类似于K-V键值对的结构。符号表在编译的不同阶段都需要用到,在语义分析中,表中登记的内容将用于语义检查和产生中间代码;在目标代码生成阶段,符号表是对符号名进行地址分配的依据。
注解处理器
在JDK 1.5之后,Java提供了对注解的支持,但都是在运行期间发挥作用的。在JDK 1.6之后提供了一个插入式注解处理器,可以在编译期间对注解进行处理,从而影响抽象语法树里的元素。如果处理器对语法树进行了修改,编译器会重新对代码进行解析与填充符号表,直到处理器没有再对语法树进行修改为止,每一次的循环称为一个Round。
语义分析与字节码生成
在语法分析之后,编译器生成了抽象语法树,语法树能表示一个结构正确的源程序抽象,但是不能保证源程序符合逻辑。因此语义分析就要对结构上正确的源程序进行上下文有关性质的审查。例如
int a = 1;
boolean b = false;
char c = 2;
int d = a + c; //(1)
int d = b + c; //(2)
char d = a + c; //(3)
上面a、b、c变量的定义,在之后的运算中,只有第1种运算是可以通过语义分析的,其他两种会被拒绝编译。
1. 标注检查
标注检查的内容主要包括变量使用前是否已经被声明、变量与赋值之间的数据类型是否能够匹配、常量折叠等。其中常量折叠是例如:
int a = 1 + 2;
在语法树上仍能看到字面量“1”、“2”以及“+”,但是经过常量折叠后会变为一个“3”,所以无论是上面那种写法还是
int a = 3;
都不会增加运行期的CPU指令的运算量。
2. 数据及控制流分析
这一步是对程序上下文逻辑更进一步的验证,可以检查出例如局部变量在使用前是否被赋值、方法的每条路径是否都有返回值、是否受查异常都被正确处理等问题。例如:
//方法一带有final变量
public void foo(final int arg){
final int var = 0;
}
//方法二不带final变量
public void foo(int arg){
int var = 0;
}
这两个方法在编译出来的Class文件中并没有不同,因为对局部变量声明final字段对运行期没有影响,只是在编译期由编译器来控制变量的不变性。
3. 解语法糖
语法糖是一种增加程序可读性的编码方法,Java中的语法糖包括泛型、自动装箱/拆箱等,虚拟机在运行时并不支持这种语法,所以解语法糖则是编译器在遇到这些代码语句时,还原成简单的基础语法结构,好让虚拟机可以识别。
4. 字节码生成
字节码生成是编译的最后一个阶段,在这个阶段不仅仅要把前面各个步骤生成的信息转化成字节码写入到文件中,还进行了少量的代码添加、转换工作。例如将实例构造器和类构造器添加到语法树中、把字符串的加操作替换为StringBuffer或者StringBuilder的append()操作等。在完成了对语法树的遍历后,编译器会将符号表输出成字节码最终生成Class文件。
Java语法糖
几乎各种语言都会提供一些语法糖来简化代码开发工作,虽然语法糖不能提供实质性的功能改进,但是能提高开发效率或者提升语法的严谨性,减少代码出错的机会。下面列举几个常用的Java语法糖:
1. 泛型与泛型擦除
JDK 1.5提供了对泛型的支持,可以指定所操作的数据类型,泛型可以应用在类、方法和接口中。虽然C#中也存在泛型,但是Java和C#的泛型技术在实现上有着很大的不同。C#中的泛型无论是在源码中或者编译后都是真实存在的,有着自己的数据类型,称为类型膨胀。而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?"));
}
在经过类型擦除之后会变为:
public static void main(String[] args){
Map map = new HashMap();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you?"));
}
类型擦除还会导致方法无法重载,例如:
public void method(List<String> list){}
public void method(List<Integer> list){}
这两个方法就无法重载,因为在类型擦除后,参数都会变为List类型,最后导致方法的特征签名一模一样,无法重载。但是如果修改一下方法的返回值变为:
public int method(List<String> list){
return 0;
}
public boolean method(List<Integer> list){
return true;
}
就可以通过编译并且能够执行,虽然这与返回值不包含在特征签名中概念相违背,但是特征签名不包含返回值只是针对代码层面的,在字节码中,不仅包含了返回值,还包含了受查异常表,因此添加了返回值就可以编译成功。
2. 自动装箱、拆箱与遍历循环
举个例子:
public static void main(String[] args){
List<Integer> list = Arrays.asList(1, 2, 3, 4);
int sum = 0;
for(int i : list){
sum += i;
}
System.out.printlin(sum);
}
经过解语法糖编译后变为:
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.printlin(sum);
}
上面的例子一共包含了泛型、自动装箱、自动拆箱、遍历循环和变长参数5种语法糖。遍历循环需要被遍历的类实现Iterable接口,变长参数编译后会变为一个数组类型的参数。自动装箱/拆箱在编译后会转化为对应的包装和还原方法,还需注意的是,”==”运算在没有遇到算数运算的情况下不会自动拆箱,因此不在-128 ~ 127之间的数比较就会为false,例如:
Integer a = 128;
Integer b = 128;
System.out.println(a == b); //false
System.out.println(a == (b + 0)); //true
还有就是equals()方法不处理数据转型的问题,例如:
Integer a = 10;
Integer b = 10;
System.out.println(a.equals(b)); //true
System.out.println(a.equals(b + 0L)); //false
3. 条件编译
Java中条件编译的方法就是使用条件为常量的if语句,例如:
public static void main(String[] args){
if (true){
System.out.printlin("a");
} else {
System.out.printlin("b");
}
}
在编译时就会被编译器“运行”,生成的字节码就只包含System.out.printlin("a");
而不会进入到else语句中。
运行优化
运行期的优化主要是指Java中的即时编译技术,通常虚拟机是通过解释器解释执行Java程序,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高执行效率,虚拟机会在运行时将这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。
即时编译器
在虚拟机运行时完成上述任务的编译器叫做即时编译器。并不是所有的虚拟机都采用“解释器”和“编译器”并存的架构,因为Java虚拟机规范并没有规定虚拟机内必须有即时编译器,更没有指导如何实现即时编译器。
有两类代码会被标记为“热点代码”:
- 被多次调用的方法
- 被多次执行的循环体
目前主要的判断“热点代码”的方式有两种:
- 基于采样的热点探测:虚拟机周期性的检查各个线程的栈顶,如果发现某个方法经常出现在栈顶说明这个方法是“热点代码”。缺点是如果线程被阻塞会降低探测的精确度。
- 基于计数器的热点探测:为每个方法建立一个计数器,如果计数器的值超过了阈值则说明这个方法是“热点代码”。缺点是比较麻烦,需要建立和维护计数器。
Hotspot虚拟机采用了第二种方式来探测“热点代码”,它为每个方法建立了两类计数器:方法调用计数器和回边计数器。两类计数器在运行时都会先去检查是否存在已编译版本,如果存在就执行编译后的代码,如果不存在则计数器加1,再判断练两类计数器只和是否超过了阈值,没超过就以解释器方式执行,如果超过了阈值则向编译器提交编译请求,再以解释器方式执行,然后在下一次进入到该方法时就可以执行编译后的代码。两类计数器的区别是,方法调用计数器如果一段时间内调用次数不足以提交给即时编译器,那计数器的值会减少一般,称为热度衰减。
编译优化技术
虚拟机的设计团队几乎把所有的代码优化措施都集中在了即时编译器之中,因此以编译方式执行本地代码比解释执行方法更快。下面简单介绍几种代码优化技术:
公共子表达式消除
公共子表达式消除的含义是:如果一个表达式E已经被计算过了并且值没有变化过,那么再次出现时可以直接代替。例如
int d = (c * b) * 12 + a + (a + b * c);
这段代码经过Javac编译后不会进行任何优化,生成常规的字节码指令。但是这段字节码指令进入到虚拟机即时编译器后将会优化成下面这样:
int d = E * 12 + a + (a + E);
这个E就代表了b * c
或c * b
,表达式变换后计算起来就可以节省一些时间。
数组边界检查消除
在访问数组时都需要进行一次判断下标是否越界的检查,这将会耗费很多时间,在编译期即时编译器会根据数据流分析来确定数组的长度,当常量下标没有超过数组长度时,执行本地机器码就无需再执行判断操作了。
方法内联
方法内联可以消除方法调用的成本。例如:
public static void foo(Object obj){
if (obj != null) {
System.out.println("a");
}
}
public static void testInline(String[] args){
Object obj = null;
foo(obj);
}
testInline()方法中都是无用的代码,永远不会执行。对于非虚方法,在编译期就被解析过了可以直接内联,而虚方法需要在运行时才能进行多态选择,因此无法在编译期内联。虚拟机在遇到虚方法时,会向CHA查询此方法在当前程序下是否有多个目标版本可供选择:
- 如果只有一个版本就可以进行内联,在之后的执行过程中如果都没有加载到令这个方法的接收者的继承关系发生变化的类,就可以一直使用这个版本。但是如果加载了这样的一个类,就需要抛弃已经编译的代码退回到解释执行或者重新编译。
- 如果CHA查到有多个版本的目标方法,还会进行一次内联缓存,在第一次调用时记录下方法接收者的版本信息,如果之后的调用信息一致就可以使用缓存的内联,否则就需要取消内联查找虚方法表进行分派。
逃逸分析
逃逸分析就是分析对象的动态作用域,不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。当一个对象在方法中被定义后,它可能被外部方法引用,称为方法逃逸;或者被其他线程引用,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或线程之外,就可以进行一些代码优化,例如:
- 栈上分配:虚拟机中大多会在堆上分配对象内存,对象可以被各个线程引用到。但是如果一个对象不会逃逸到方法体外,就可以把对象分配在栈上,在方法退出时就可以随着栈帧一起销毁,减少了垃圾收集的性能消耗。
- 同步消除:线程同步需要消耗CPU的性能,如果一个变量不会逃逸出线程,就可以消除掉对这个变量的同步措施。
- 标量替换:标量是指一个数据无法再分解成更小的数据来表示了,如果一个对象不会被外部访问,并且这个对象能被拆散,虚拟机在运行时可能不会创建这个对象,而是创建该对象诺干个被使用的成员。这样除了可以在栈上分配内存外,还能为后续的优化手段创建条件。
总结
在平时的代码编写时,我们都会注重代码质量,避免出现无用的代码或者死循环,但其实虚拟机在运行时也会进行一些代码优化,并不是我们写了什么,虚拟机就一成不变的执行。