JVM之代码优化
java的编译分为
1. 前端编译器:将.java文件编译成字节码文件, javac
2. 运行时编译JIT:将字节码文件转换成机器指令文件。Hotspot VM的C1、C2编译器
3. 静态提前编译AOT:直接将.java文件转换成机器指令文件。
早期编译优化
javac编译器是使用java语言实现的编译器。
因为虚拟机规范了class文件的格式,但并没有严格规定如何从.java文件编译成class文件,因此class文件的编译某种程度上与JDK相关联。
编译过程分为:
1)解析与符号表填充过程
2)插入式注解处理器的注解处理过程
3)分析与字节码生成过程
1、解析与填充符号表过程
1)词法分析:将程序的字符流转变为标记Token集合。字符是java程序的最小元素,而标记是编译过程的最小元素。
例如:int a = b + 2; 这里有6个Token
2)语法分析:将词法分析的结果Token集合构造成抽象语法树。抽象语法树是用来描述程序代码语法结构的树形表示。语法树的每一个节点代表程序代码的一个语法结构,例如:包、类型、修饰符等。
3)填充符号表:符号表是由一组符号地址和符号信息构成的表格。符号表中记录的信息在编译的不同时期都会被用到。
2、注解处理器
JDK1.5引入的Annotation是在运行期间发挥作用的,JDK1.6引入的插入式注解处理器的API可以在编译期间对注解进行处理。如果在处理注解的期间对语法树进行了修改,那么需要重新回到解析和符号表填充的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改。
3. 语义分析和字节码生成
语义分析主要是对结构正确的源程序进行上下文有关性质的审查,主要分为标注检查和数据及控制流分析两个步骤。
1)标注检查:检查变量使用前是否被声明,变量和赋值之间的数据类型是否匹配等。还有一个重要的过程是常量折叠,即int a = 1+2;会被折叠成int a = 3。
2)数据流和控制流分析:对程序上下文逻辑进行更进一步的验证,比如局部变量使用前是否初始化,是否所有的异常都被正确处理了等等。
3)解语法糖:泛型、变长参数以及自动拆装箱等,在编译期转换回最基本的语法结构。使用语法糖可以增加程序的可读性,减少代码出错的机会
4)生成目标字节码文件,编译期此时还会进行少量代码的添加和转换,比如类构造器和实例构造器。
晚期(运行期)优化
即时编译器:编译完成后,java最初是通过解释器进行解释执行的。为了提高热点代码的执行效率,在运行时,会将这些代码编译成本地平台相关的机器码,并进行各种层次的优化。
热点代码:访问或者运行比较频繁的方法或者代码块。
- 解释器和编译器的并存架构
在程序需要迅速启动和执行的时候,解释器可以直接工作,省去编译的时间;在程序运行后,随着时间的推移,编译器可以将大量的代码编译成本地代码,提高执行效率。当运行内存的资源限制比较大时,可以利用解释器节约内存,反之可以利用编译器来提升执行效率。
解释器可以作为编译器激进优化的选择,来提升运行速度,如果激进优化的假设不成立,可以通过逆优化退回解释状态。 - 为什么HotSpot虚拟机要实现两个即时编译器呢?
- HotSpot VM具有两个即时编译器:C1 client编译器和C2 server编译器,一般会根据运行模式选择一个编译器来配合解释器一起工作,称为混合模式。也可以通过设置-Xint来强制虚拟机使用解释器工作,或者-Xcomp来强制虚拟机处于编译模式,该模式只是优先选择编译器工作。
- 即时编译器编译本地代码占用CPU的运行时间,还要进行不同层次的优化,消耗的时间更多。此时,解释器需要提编译器收集性能监控信息,这对解释执行的速度也有影响。
- 为了能让程序启动响应时间和运行效率之间达到最佳平衡,HotSpot逐渐启用分层编译的策略,即根据编译器编译、优化的规模和耗时,划分出不同的编译层次。
- 第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
- 第1层编译,即C1编译。将字节码编译成本地文件,进行简单、可靠的优化,如果有必要加入性能监控。
- 第2层及以上的编译,C2编译:将字节码编译成本地文件,但是会启动一些编译耗时较长的优化,甚至会根据性能监控信息尝试进行一些不可靠的激进优化。
分层编译的代码可能会进入多次编译,C1获取编译速度而C2获取编译优化的质量。
- 编译对象:
1)被多次调用的方法:整个方法作为编译对象
2)被多次执行的循环体:还是以整个方法作为编译对象(即使编译动作是由循环体触发的)。因为该编译发生在方法执行过程中,此时方法栈帧还在栈上,因此又称为栈上替换。 - 编译的触发条件(”多次执行“)
判断一段代码是不是热点代码,是不是需要触发即时编译的行为称为热点探测。热点探测的判定方式有:
1)基于采样的热点探测:JVM周期性检查各个线程的栈项,如果发现某个方法经常出现在栈顶,那么这个方法就是热点方法。实现简单、高效,容易获取到方法调用关系,但是不够精确,容易受到线程堵塞或其他外界因素影响。
2)基于计数器的热点探测:JVM为每个方法建立计数器,统计方法的执行次数。如果执行次数超过一定的阈值,就认为它是热点代码。成本比较高且麻烦,但是精确。
HotSpot虚拟机采用的方法2,它为每个方法准备了两类计数器:方法调用计数器和回边计数器。前者统计方法的相对调用次数,在半衰周期内达不到即时编译的要求,减少一半的计数;后者统计是方法中的循环体执行的绝对次数。 - 编译过程
在代码编译器未完成前,仍按照解释执行方式继续执行。而编译动作在后台的编译线程中进行。
Client编译器是一个简单快速的三段式编译器,主要关注局部性能的优化。而Server编译器是专门面向服务端的典型应用,是充分优化过的高级编译器。 - 编译优化技术
- 公共子表达式消除(语言无关):如果一个表达式E已经计算过了,并且从计算到现在E的所有变量的值都没有变化,那么E的这次出现就成为了公共子表达式。就可以用前面计算过的表达式消除E就可以了。
- 数组边界检查消除(语言相关性)
- 方法内联:非虚方法,直接关联;虚方法,查目标版本是不是唯一的,唯一,激进关联,后面继承关系丰盛变化时,逆优化到解释状态;非唯一,维护一个内联缓存。
- 逃逸分析:分析对象的动态作用域。
- 一个对象在方法中被定义后可能会被外部方法引用,例如作为调用参数传递到其他方法中去,称为方法逃逸。
- 甚至还有可能被外部线程访问到,比如赋值给类变量或者可以在其他线程中访问到的实例变量,称为线程逃逸。
如果能证明该对象不会逃逸到方法或者线程之外,就可能为该变量进行高效的优化:
1)在栈上分配:在堆上分配对象是线程共享的和可见的,需要进行垃圾回收,是耗时耗资源的。而栈是线程私有的,在栈上分配会随着方法的入栈出栈而自动销毁。
2)同步消除:线程同步本身是一件相对耗时的过程。如果对象不会发生方法或者线程逃逸,也就是对其他方法或者线程不可见,就可以不用同步线程了。
3)标量替换:前提除了不会逃逸,还需要对象是可分解的,分解成该对象的成员变量保存在栈中。