本博客为《深入理解java虚拟机》的学习笔记,所以大部分内容来自此书,另外一部分内容来自网络其他博客和源码分析。
主要内容包括:前期(编译期)优化,后期(运行期)优化。
一 前期(编译期)优化
1 编译过程
从javac的角度来看,编译过程大致分为三步:
Ø 解析与填充符号表过程。
Ø 插入式注解处理器的注解处理过程。
Ø 分析与字节码生成过程。
模型如下:
2 解析与填充符号表过程
1) 词法、语法分析
词法分析是将源代码中的字符流转变成标记(Token)集合的过程;由Scanner类实现。
语法分析是根据Token序列构造抽象语法树(Abstract Syntax Tree)的过程。抽象语法树是一种用来描述程序代码语法结构的属性表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构,例如:包、类型、修饰符、运算符、接口、返回值、注释等等。
2) 填充符号表
符号表(SymbolTable)是由一组符号地址和符号信息构成的表格;这些信息在变异的不同阶段都要用到。语义分析阶段,符号表所登记的内容将用于语义检查和产生中间代码;目标代码生成阶段,对符号名进行地址分配时,符号表是地址分配的依据。
如果类中没有提供任何构造函数,编译器会在此过程中添加一个没有参数、访问性和当前类一直的默认构造函数。
3 注解处理器
jdk1.6中提供了一组插入式注解处理器的标准API,在编译期间对注解进行处理,可以它认为是编译器的插件,可以读取、修改、添加抽象语法树的任意元素。注解处理器如果对抽象语法树进行了修改,那么编译器将回到解析与符号表填充阶段重新处理,直到注解处理器不会对抽象语法树修改为止。
4 语义分析与字节码生成
语义分析包括标注检查和数据及控制流分析。
1) 标注检查
检查内容包括:变量使用前是否已经被声明、变量与赋值之间的数据类型是否匹配等。此过程中会有一个常量折叠的动作,例如int a = 1 + 2这段代码,在常量折叠以后会编程int a = 3。
2) 数据及控制流分析
对程序上下文逻辑进行进一步验证,例如:程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有受查异常都被正确的处理了(捕获或者抛出)等。
局部变量在使用final修饰和不使用final修饰时,编译出来的Class文件是没有区别的,也不会被放入常量池中,所以将局部变量使用final修饰,不会对运行期产生任何影响;变量的不变性仅仅有编译器在编译器保障。
3) 解语法糖
虚拟机并不支持例如泛型、边长参数、自动装箱/拆箱等语法,所以需要将这些语法在编译阶段还原成基础语法结构,这个过程被称之为解语法糖。
4) 字节码生成
细节码生成不仅是把前面各个步骤生成的信息转换为字节码存储到磁盘文件中,还进行少量的代码添加和转换工作。例如实例构造器<init>()方法和类构造器<clinit>()方法就是在这个极端被加入到抽象语法树中的。
编译器会将语句块(对实例化构造器是{},对类构造器是static{})、初始化变量(实例变量、类变量)、调用父类的实例构造器等操作收敛到<init>()和<clinit>()方法中,并保证一定是按照先执行父类的实例化构造器,然后初始化变量,最后执行语句块的顺序进行的。
二 后期(运行期)优化
java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或者某个代码块的运行特别频繁是,会将这部分代码认定为“热点代码”,为了提高热点代码的执行效率,在运行时虚拟机将这些代码编译成与本地平台相关的机器码,并进行各个层次的优化,完成这个任务的编译器被称为及时编译器(Just In Time Compiler),即JIT编译器。
1 解释器(Interpreter)与编译器(Compiler)
当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
当程序的运行环境中内存资源限制较大,可以使用解释执行节省内存;反之可以使用编译执行来提升效率。如果加载了新类后,类型继承结构出现了变化时,可以通过逆优化退回到解释状态继续执行。在整个虚拟机执行架构中,解释器和编译器经常配合工作。
2 编译对象的触发条件
1) 热点代码种类:
被即时编译器编译的热点代码有两类:
Ø 被多次调用的方法。
Ø 被多次执行的循环体。
不管是那种热点代码,编译器都会以整个方法作为编译对象。
2) 热点探测判定方法
a) 基于采样的热点探测(Sample Based Hot Spot Detection)
虚拟机定期检查各个线程的栈顶,如果发现某个方法经常出现在栈顶,那么认为这个方法时热点方法。
优点是:简单、高效,而且很容易获取方法调用关系。
缺点是:很难精确的确认一个方法的热度,也会受到县城阻塞或别的外部因素的干扰。
b) 基于计数器的热点探测(Counter Based Hot Spot Detection)
虚拟机为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一个繁殖,则认为是热点方法。
优点是:统计结果更加精确和严谨。
缺点是:实现起来比较麻烦,需要为每个方法维护一个计数器;而且不能直接获取方法的调用关系。
HotSpot虚拟机使用这种方式。它为每个方法准备了两个计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)
c) 方法调用计数器触发即时编译
针对方法调用。如果不做任何设置,方法调用计数器统计的不是方法被调用的绝对次数,而是一段时间内被调用的次数。当超过一定的时间限度,如果方法调用次数小于阈值(交给即时编译器编译的阈值),那么方法调用计数器就会被减半。
d) 回边计数器触发即时编译
在字节码中遇到控制流向后跳转的指令称之为回边。
针对循环体。和方法计数器不同,回边计数器没有热度衰减过程。
3 几种常见的优化技术
1) 原始代码
static classData {
int value;
final int getValue() {
return value;
}
}
public voiddoJob() {
y = data.getValue();
// do other job
z = data.value;
int sum = y + z;
}
2) 方法内联
内联后的代码如下:
y = data.value;
// do other job
z = data.value;
int sum = y + z;
3) 冗余访问消除
消除冗余以后的代码如下:
y = data.value;
// do other job
z = y
int sum = y + z;
4) 复制传播
优化后的代码如下:
int y = data.value;
// do other job
y = y
int sum = y + y;
5) 无用代码消除
优化后的代码如下:
int y = data.value;
// do other job
int sum = y + y;
4 最具代表性的优化技术
1) 公共子表达式消除
如果一个表达式E已经计算过了,并且从先前计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为公共子表达式。对于这种表达式,没有必要重新计算,直接使用之前表达式的结果替换E就可以了。
这种优化如果仅限于程序的基本块内,称之为局部公共子表达式消除(Local Comon Subexpression Elimination);如果优化的涵盖了多个基本块,则称之为全局公共子表达式消除(Global Comon Subexpression Elimination)。
例如,以下示例代码
int d = (a * b)* 4 + c + (c + b * a)
编译器检测到a*b和b*a是一样的表达式那么可以替换成E,结果为
E = a * b
int d = E * 4 +c + c +( c+ E)
编译器还可能进行进一步优化
E = a * b
int d = E * 5 +c * 2
2) 数组边界检查消除
java语言中,访问数组元素的时候系统将自动进行上下界的范围检查,即满足index>=0 && index < array.length。为了安全,数组边界检查是必须做的,但是如果每次都检查势必会影响效率。
如果编译器只要通过数据流分析就可以判定循环变量的却只范围永远在[0, array.length]内,那么整个玄幻中就可以把数组的上下界检查消除,这可以节省很多次条件判断操作。
3) 方法内联
从表面上看起来,方法内联是将目标方法代码“复制”到调用的方法中,避免正式的方法调用而已。但实际上因为java支持多态的原因,直到真正执行的时候才能确认真正的方法,所以按照经典编译原理的优化理论,大多数java方法都无法进行内联。
Java虚拟机已入了一种被称之为类型继承关系分析(Class Hierarchy Analysis, CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,子类是否为抽象类等信息。
编译器在进行内联时,如果是非虚方法,那么直接进行内联。如果遇到虚方法,则向CHA查询此方法是否有多个目标版本可供选择,如果查询结果只有一个版本,那么可以进行内联。这种内联属于激进优化,需要预留一个逃生门,称之为守护内联。预留逃生门的目的是,当程序运行中加载了导致继承关系发生变化的类,那么就需要抛弃已经编译的代码,退回到解释状态执行或者重新进行编译。
4) 逃逸分析
逃逸分析不是直接优化的手段,而是为其他优化手段提供依据的分析技术。
逃逸分析的基本行为是分析对象的动态作用域,当一个对象在方法中被定义以后,可能外部方法引用,这就是方法逃逸。如果一个对象不会逃逸到方法或者线程之外,那么则可能对此对象进行一些高效的优化,例如:
Ø 栈上分配
Ø 同步消除
Ø 标量替换