目录:
java中有两种编译,第一种是由java代码编译成字节码(使用javac编译器),第二种则是由字节码编译成对应平台的机器码(使用即使编译器JIT)。但并不是所有的字节码都会被编译,大部分代码都是直接解释执行(使用解释器),只有热点代码才会被编译成机器码。
编译期优化(javac编译器)
编译过程
-
解析和填充符号
解析的过程分为词法语法解析和填充符号,词法解析是把源代码解析成最小元素,如“int a=b+2, int,a,=,b,+,2都是一个元素。语法解析就是根据词法解析的元素,构建一个抽象语法树。抽象语法树的每个节点代表一个语法结构,如包,修饰符,运算符甚至是注释。
符号表是一个键值对形式的结构,key是符号地址,value是符号信息,符号表在后续的操作中都有使用,如进行语义检查时,检查一个名字的使用和原先的说明是否一致(类型等)。 -
注解处理器
注解其实就是在编译阶段对抽象语法树进行了修改,为类方法等打上标记。 -
语义分析与字节码合成
语义分析的作用时保证源代码符合逻辑,比如类型审查等。
在这个阶段有一个值得注意的是一个编译优化:常量折叠。
int a = 1+2;
//在编译时会被优化成
int a = 3;
//同样的,字符串也会有这样的优化
String s = "a"+"b";
//和下面的代码在编译后时一致的,所以常量池中只有“ab”而没有“a”和“b”
String s = "ab";
字节码合成则是把抽象语法树转换为字节码,当然还做了其他的一些操作,比如把静态代码块和静态字段的赋值合并成一个()函数。
解语法糖
在语义分析的过程中,还会对代码进行解语法糖的操作
泛型与类型的擦除
在java中,可以在代码中编写泛型代码,但是在编译后,泛型中的类型却被擦除了,使用强制转换替代。所以实际上java的泛型只是一种语法糖。在java中List<String>和List<Integer>是同一个类,因为在编译后,其类型被擦除了,而在其他语义如C#中,List<int>和List<string>是两个不同的类。所以如果你编写以下的java代码,会出现编译错误。
public class GenericTypes {
public static void method(List<String> list) {
System.out.println("invoke method(List<String>list)");
}
public static void method(List<Integer> list) {
System.out.println("invoke method(List<Integer>list)");
}
}
自动装箱拆箱和循环
List<Integer> list= Arrays.asList(1,2,3,4);
//编译后自动装箱代码
List list = Arrays.asList(new Integer[]{
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4)});
for (int index:list){ }
//编译后循环代码
for(Iterator localIterator=list.iterator();localIterator.hasNext();){
int i=((Integer)localIterator.next()).intValue();
}
自动装箱拆箱的陷阱
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);
System.out.println(e==f);
System.out.println(c==(a+b));
System.out.println(c.equals(a+b));
System.out.println(g==(a+b));
System.out.println(g.equals(a+b));
System.out.println(new Long(1).equals(1));
上面打印出来的结果是什么?
true
false
true
true
true
false
false
- ==号比较的是对象的地址,equals比较的是对象的内容。
- c==d为true:是因为-128~127的int数据,已经被缓存在常量池中了,所以并不会重新创建,3指向的都是常量池中的对象。
- e==f为false:和上面的原因一样,由于并没有在常量池的范围内,所以各种创建了新的对象。因此地址是不一样的。
- c==(a+b)为true,a+b运算的结果为3,这个运算的结果也是在常量池中,因此和第一条的原因一致。
- c.equals(a+b)为true:比较的是值,当然是相等的。
- g==(a+b)为true,这个的原因就是,这里会发生装箱,实际上该运算中,(a+b)会被提升成Long类型,而3L也在常量池中,因此他们的地址也是一致的。
- g.equals(a+b)为false:不同类型的比较内容,当然是false
- new Long(1).equals(1)为false:equals括号中的1是int类型,而new Long(1)是Long类型,也是不同的,必须使用new Long(1).equals(1L),标记equals括号中的1是Long类型
条件编译
对于无法到达的代码,字节码中是不存在的。
int a=0;
if(true){
a=1;
}
else{
a=2;
}
//编译后
int a = 1;
运行期优化
javac编译器是将源码编译成字节码,而运行期则会把代码编译成对应平台的机器码。我们知道jvm在运行时,解释器和编译器是同时存在的。解释器把代码直接解释后执行,而编译器则是先编译后执行。但是思考一个问题,不管是解释还是编译,不都是转换成机器码码?为什么不直接保存解释后的机器码呢?
实际上,编译器不仅仅是是对代码进行编译,还会对代码进行优化,提高运行效率。而且保存代码也需要耗费空间,对于不常用的代码,这种耗费是没有必要的。
即时编译器(JIT)
- c1和c2
我们都知道JIT,但是不知道JIT实际上有两类,分别是C1和C2,C1的作用是对方法进行快速的编译,这时会使用一些代码优化的技术,但是不会使用比较激进的优化,所以编译速度比较快。而C2会执行一些激进的代码优化,编译慢,但是提升的效率高。不过过于激进的优化,也有可能出现“罕见缺陷”,这时候会退化成解释器执行。 - 分层编译
jvm会收集性能信息,让代码使用不同层次的编译。 - 0层:解释执行
- 1层:使用C1编译器进行简单,可靠的优化
- 2层:使用C2编译器,启用耗时较长的优化,使用一些激进的优化技术
编译对象和触发条件
- 那么什么时候会触发编译呢?依据是什么?
编译的最小对象是方法,被编译的对象是被多次调用的方法和多次执行的循环体(对应得方法) - 那么什么才算是多次呢?
jvm使用基于计数器的热点探测技术:方法为每一个方法设置方法调用计数器和回边计数器(用于统计循环的执行次数),虚拟机会判断两个计数器之和是否大于阈值,如果大于阈值就会被标记为热点代码。在Client模式下阈值是1500,在server模式下是10000次。可通过-XX:CompileThreshold虚拟机参数修改。
另外,方法计数器并不是只增不减,如果一个方法一段时间没有被调用,那么就会减少一半的数值(半衰期),但是回边计数器则不会减少,计算的是绝对调用次数。
虚拟机参数:-XX:CounterHalfLifeTime可设置半衰期时间(秒)
编译过程
- 阻塞或者后台编译
默认编译是后台编译,在编译完成前,仍然使用解释执行,可通过-XX:-BackgroundCompilation来禁止后台编译,变为阻塞。
Client编译器(C1编译器)编译过程
- 一阶段: 编译为高级中间代码,进行基础优化,如方法内联
- 二阶段: 编译为低级中间代码,做一些优化:如空值检查消除等
- 三阶段: 编译为机器码,分配寄存器,窥孔优化(如删除无效的代码,合并冗余的操作)
Server编译器(C2编译器)在C1的基础上使用更多的优化技术
- 公共子表达式的消除
int d = (c*b)*12+a+a+b*c
//上面的语句b*c出现了两次,所以可能被优化成以下语句
int E = c*b;
int d = E*12+a+a+E
//编译器还可能进行代数优化
int d = E*13+2*a;
-
数组边界检查消除
再编写数组相关的代码时,我们编写很多检查数组越界的代码,如果虚拟机通过数据流分析发现,取值范围都是合法的,那么就会去掉数组边界检查的代码,以减少判断。 -
方法内联
方法内联的思路很简单,就是把被调用的方法直接搬到调用方法的点,这样可以省去调用方法的花费。
但是再上篇文章也提到过,静态分派的方法(构造器,私有方法,父类方法荷final修饰的方法)才能唯一确定调用的时哪个方法,而对于其他的方法(虚方法),只有再运行时才能确定调用的是哪个方法,这时候如何使用内联呢?
为了使得虚方法可以内联,JVM引入了“类型继承分析”,作用是判断一个方法是否有多种实现(父类实现,子类实现),如果只有一种版本的实现,那么这个时候也是可以内联的。不过这个属于激进的优化方式,因为继承关系可能会随着新加载的类而导致变化,这个时候这个编译的代码就会被遗弃了。
这也是为什么使用final荷private修饰方法可以提高运行速度的原因了。 -
逃逸分析
如果一个对象再方法中被定义,但是作为参数传到其他方法或者被其他线程使用,分别称之为方法逃逸和线程逃逸。
如果能证明一个对象即没有方法逃逸也没有线程逃逸,那么就可以做以下高级优化- 栈上分配。没错,对象直接在栈上分配,而不是在堆里分配。随着方法的退出而直接销毁对象而不需要垃圾回收。
- 同步消除。同步指的是线程间的同步,同步耗费时间,如果没有其他线程调用,当然不需要做同步操作,于是相关的同步操作就会被消除。
- 标量替换:如果一个对象没有逃逸,那么在创建对象时,可能只是创建被使用到的对象中的若干成员变量。
使用以下虚拟机变量开启- -XX:+DoEscapeAnalysis:手动开启逃逸分析 (JDK1.6之后默认开启)
- -XX:+EliminateAllocations:开启标量替换
- +XX:+EliminateLocks:开启同步消除