Java编译优化
前期优化:
概述
Java语言编译期是一段不确定的操作过程,可能指前端编译器(叫编译器的前端更准确)把*.java文件转变为*.class文件的过程,也可能指虚拟机后端编译器(JIT编译器 Just Time Compiler)把字节码转为机器码的过程;还可能指静态提前编译器(AOT编译器)直接把*.java文件编译为本地机器代码的过程。
列举下这三类编译过程中又代表性的编译器:
前端编译器:Sun的Javac,Eclipse JDT中的增量式编译器(ECJ)。
JIT编译器:HotSpot VM中的C1 C2编译器。
AOT编译器:GCJ JET
我们对于优化二字定义稍微宽松一些,因为Javac编译器对代码运行效率几乎没有任何优化措施,虚拟机设计团队将对性能的优化集中到了后端的JIT中,这样可以让那些不是由javac产生的class文件(Groovy JRuby等语言的class文件)也同样能享受到编译器优化带来的好处。但是Javac做了许多针对编码的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的Java语法特性,都是靠编译器的语法糖来实现,而不是依赖虚拟机的底层改进来支持。Java中JIT在运行期的优化过程对于程序运行更加重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。
Javac编译器
将Javac编译器源码导入到eclipse中后,就可以运行javac.Main的main方法来执行编译,与命令行中使用javac的命令没有区别,编译的文件与参数在Eclipse的Debug Configurations面板中的Arguments页签中指定。
虚拟机规范严格定义了Class文件的格式,但是并没有对如何把Java源码文件转变为Class文件的编译过程进行十分严格的定义,这导致Class文件编译在某种程度上是与具体JDK实现相关的,在一些极端情况,可能出现一段代码Javac编译器可以编译,但是ECJ无法编译的问题(后面的泛型擦除问题),从Sun Javac代码来看,编译过程大致可以分为3个过程:
1.解析与填充符号表过程
2.插入式注解处理器的注解处理过程
3.分析与字节码生成过程
java编译过程的入口是javac.main.JavaCompiler类,上面三个过程的代码逻辑集中在这个类的compile和compile2方法中,主体代码如下:
解析与填充符号表
解析步骤包括了经典程序编译原理中的词法分析和语法分析两个过程。
1、词法分析是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记集合则是编译过程中的最小元素,关键字、变量名、字面量、运算符都可以成为标记,如:int a = b+2这句代码包含6个标记,分别是int、a、=、b、+、2 虽然关键字int由3个字符构成,但是它只是一个Token,不可拆分。词法分析由javac.parser.Scanner类实现
2.语法分析时根据token序列构造抽象语法树的过程,抽象语法树是一种用来描述程序代码语法结构的树形表示方式。语法树的每一个节点都代表程序代码中一个语法结构,如:包 类 修饰符 运算符 甚至注释 等等 语法树由javac.parser.Parser类实现
完成语法词法分析后,下一步就是填充符号表的过程,符号表是由一组符号地址与符号信息构成的表格,可以想象为哈希表中K-V键值对的形式(实际上还可以是list tree 等),在Javac源代码中,填充符号表由javac.comp.Enter类实现。
注解处理器
jdk1.5以后,Java语言提供了对注解的支持,注解与java代码一样在运行期发挥作用。jdk1.6中提供了插入式注解处理器的标准API在编译期间对注解进行处理,可以将其看做编译器的插件,可以读取、修改、添加抽象语法树中任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环称为一个Round,也就是上图的回环过程。
有了编译器注解处理的标准API后,我们的代码才有可能干涉编译器行为,由于语法树中任意元素,甚至代码注释都可以在插件中访问到,所以通过插入式注解处理器来实现许多只能在编码中完成的事。
语义分析与字节码生成
语法分析后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序抽象,但是无法保证源程序是负荷逻辑的,而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查。举例,假设有如下3个变量定义语句:
int a = 1; boolean b = false; char c = 2;
后续可能出现的赋值运算:
int d = a+c; int d = b_c; char d = a+c;
后续代码如果出现了如上3种赋值运算的话,都能构成结构正确的语法树,但是只有第一种写法在语义上没有问题,能够通过编译。
语义分析过程分为标注检查与数据及控制流分析两个步骤。
1.标注检查
标注检查由arrtibute()方法完成,标注检查步骤检查的内容包括诸如变量使用前时候已被声明、变量与赋值之间的数据是否匹配等。标注检查步骤中,还有一个重要的动作称为常量折叠,如果我们在代码中写了如下定义:
int a = 1+2;
在语法树上仍能看到字面量1 2以及操作符+,但是经过常量折叠之后,他们将会被折叠为字面量3,由于编译期间进行了常量折叠,所以在代码里面定义a=1+2比起直接定义c=3并不会增加程序运行期哪怕仅仅一个CPU指令的运算量。标注检查步骤在javac源码中的实现类是javac.comp.Attr类和javac.comp.Check类。
2.数据及控制流分析
数据及控制流分析是对程序上下文逻辑的进一步验证,可以检查出诸如程序局部变量使用前是否赋值,方法的每条路径是否都有返回值等。编译器的数据及控制流分析与类加载时的数据及控制流分析的目的基本是一致的,但校验范围有所区别,有一些校验只有在编译期或运行期才能进行。举一个关于final修饰符的数据及控制流分析的例子:
public void foo(final int arg){
final int var = 0;
}
public void foo(int arg){
int var = 0;
}
这两个foo中,第一种方法的参数和局部变量定义使用了final修饰符,而第二种方法没有,在代码编写时程序肯定会受到final修饰符的影响,不能再改变arg和var的值,但是这两段代码编译出来的class文件是没有区别的!! 因为局部变量与字段(实例变量、类变量)是有区别的,它在常量池中没有CONSTANT_Fieldref_info的符号引用,自然就没有访问标志(Access_Flags)的信息,甚至可能连名称都不会保留下来(取决于编译时的选项),自然在class文件中不可能知道一个局部变量是不是声明为final了。因此将局部变量声明为final,对运行期没有影响,变量的不变性仅仅由编译器在编译期间保障。在Javac源码中,数据及控制流分析的入口是flow方法,由javac.comp.Flow类完成。
3.解语法糖
Java最常用的语法糖主要由泛型、变参、自动装箱/拆箱等,虚拟机运行时不支持这些语法,他们在编译阶段还原简单的基础语法结构,这个过程称为解语法糖。 javac源码中,解语法糖过程由desugar方法触发,在comp.TransTypes类和comp.Lower类中完成。
4.字节码生成
字节码生成式Javac编译过程最后一个阶段,由javac.jvm.Gen类完成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化为字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。
例如,前面提到的实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段添加到语法树中的,这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对实例构造器而言是{}块,对类构造器而言是static{}块)、变量初始化(实例变量和类变量)、调用父类实例构造器(仅仅是实例构造器)等操作收敛到<init>和<clinit>方法之中,并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行。 除了生成构造器以外,还有其他的一些代码替换工作用于优化程序的实现逻辑,如把字符串的加操作替换为StringBuffer或StringBuilder(取决于jdk是否大于1.5)的append操作等。
完成了对语法树的遍历和调整后,就会把填充了所有所需信息的符号表交给javac.jvm.ClassWriter类,由这个类的writeClass方法输出字节码,生成最终的class文件,到此编译过程结束。
Java语法糖的味道
语法糖可以看做编译器实现的一些小把戏,这些小把戏可能会使效率提升,但是我们也应该了解这些小把戏背后真实世界,那样才能利用好它们,不被迷惑。
泛型与泛型擦除
泛型是JDK1.5新增特性,它的本质是参数化类型的应用,所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口、方法的创建中,分别称为泛型类、泛型接口、泛型方法。
泛型思想起源于c++,在Java还没有出现泛型版本时,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。例如在HashMap的get方法中,返回值就是一个Object对象,由于所有类型都继承于Object,所以Object转型为任何对象都有可能。但是也因为有无限的可能性,就只有程序员和运行期的虚拟机才知道这个Object到底是什么类型的对象。在编译期间,编译器无法检查这个Object强制转型是否成功,如果仅仅依赖程序员去保证这项操作的正确性,许多风险就会转嫁到程序运行期中。
泛型技术在C#和Java中使用方式看似相同,实现上却有根本性分歧,C#里面泛型无论在程序源码中、编译后、运行期都是切实存在的,List<int> 与 List<String>就是两个不同的类型,他们在运行期生成,有自己的虚方法和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。
Java语言中的泛型则不一样,他只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型了(裸类型),并且在相应的地方插入了强制转型代码,因此,对于运行期Java语言来说,ArrayList<int>与ArrayList<String>就是同一个类,所以泛型技术本质上是Java语言的语法糖,Java语言中的泛型实现称为类型擦除,基于这种方法实现的泛型称为伪泛型。
通过一段代码来验证一下:
泛型擦除前的例子
public class Test01 {
public static void main(String[] args) {
Map<String,String> map = new HashMap<String,String>(); //hashMap后面不加泛型javac会警告
map.put("hello","你好");
map.put("how are you?","你好吗");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}
}
编译后,再反编译, 泛型都不见了:
public class Test01
{
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?"));
}
}
JDK团队为什么选择类型擦除的方式来实现Java语言的泛型支持呢?因为实现简单、兼容性考虑还是别的原因呢?我们不得而知,有不少人对Java提供的伪泛型颇有微词,在众多批评中,有一些比较表面,还有一些从性能上说泛型会由于强制转型操作和运行期缺少针对类型的优化等 从而导致比C#的泛型慢一些,这种说法完全偏离了方向,姑且不论Java泛型是不是真的比C#泛型慢,从性能的角度上评价用于提升语义准确性的泛型思想就不太恰当。 泛型在某些场景下确实存在不足!通过擦除法实现泛型丧失了一些泛型思想应有的优雅!
当泛型遇见重载1
上面这段代码不能被编译,因为参数List<Integer>和List<String>编译之后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两种方法的特征签名变得一模一样。 泛型擦除成相同的原生类型只是无法重载的其中一部分原因,再看一段代码:
泛型遇见重载2
(上面代码只有在jdk1.6的Javac编译器进行编译才能通过)加入两个不同的返回值后,重载竟然成功了。即这段代码可以被编译和执行了。这是对Java语言中返回值不参与重载选择的基本认知的挑战。
上面代码的重载当然不是根据返回值来确定的,之所以这次能编译和执行成功,是因为两个method方法加入了不同的返回值后才能共存于一个class文件中。 前面说过,方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名中,所以返回值不参与重载选择,但是在class文件格式中,只要描述符不完全一直就可以共存。 也就是说,两个方法如果有相同的名称和特征签名,但返回值不同,那他们也是可以合法共存于一个class文件。
由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有基础产生影响和新的需求,如在泛型类中如何获取传入的参数化类型等。引入了注入Signature等新的属性用于解决伴随泛型而来的参数类型识别问题,signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息,虚拟机规范要求49以上版本的虚拟机都要能正确识别signature。
擦除法所谓的擦除,仅仅是对方法的code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射获取参数化类型的根本依据。
自动装箱、拆箱与遍历循环
从纯技术角度讲,自动装箱、自动拆箱与循环遍历这些语法糖,无论是实现上还是思想上都不能和上文的泛型相比较,两者的难度与深度有很大差距。之所以特地讲一下,是因为它们是Java中使用得最多的语法糖。下面举例说明:
自动装箱、拆箱与遍历循环
public class Test01 {
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.println(sum);
}
}
javac,再反编译
public class Test01
{
public static void main(String[] paramArrayOfString)
{
List localList = Arrays.asList(new Integer[] { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) });
int i = 0;
for (Iterator localIterator = localList.iterator(); localIterator.hasNext(); ) {
int j = ((Integer)localIterator.next()).intValue();
i += j;
}
System.out.println(i);
}
}
上面代码包含了泛型、自动装箱、自动拆箱、遍历循环、变参5种语法糖,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的Integer.valueOf(装箱) Integer.intValue(拆箱),而遍历循环则把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看下变参,在调用时变成了一个数组类型的参数,在变参出现前,程序员就是使用数组来完成类似功能的。
自动装箱的陷阱
public class Test01 {
public static void main(String[] paramArrayOfString)
{
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
}
}
为什么结果是这样呢?
我们反编译下:
public class Test01
{
public static void main(String[] paramArrayOfString)
{
Integer localInteger1 = Integer.valueOf(1);
Integer localInteger2 = Integer.valueOf(2);
Integer localInteger3 = Integer.valueOf(3);
Integer localInteger4 = Integer.valueOf(3);
Integer localInteger5 = Integer.valueOf(321);
Integer localInteger6 = Integer.valueOf(321);
Long localLong = Long.valueOf(3L);
System.out.println(localInteger3 == localInteger4);
System.out.println(localInteger5 == localInteger6);
System.out.println(localInteger3.intValue() == localInteger1.intValue() + localInteger2.intValue());
System.out.println(localInteger3.equals(Integer.valueOf(localInteger1.intValue() + localInteger2.intValue())));
System.out.println(localLong.longValue() == localInteger1.intValue() + localInteger2.intValue());
System.out.println(localLong.equals(Integer.valueOf(localInteger1.intValue() + localInteger2.intValue())));
}
}
首先涉及到IntegerCache,在-128-127之间的数据valueOf,会返回IntegerCache,所以这个范围内的数==为true
然后如果是 对象 == 数字时,会通过intValue都转为数字
如果是对象.equals(数字)将会把数字的和转为对象
long对象 == 数字时,同理将对象转为数字,long.longValue返回数字
long对象.equals(int值) 由于是两个类中的缓存数组中的数据,所以必然是false
条件编译
许多语言都提供了条件编译,如C,C++中使用预处理器指示符来完成条件编译。而在Java语言中并没有使用预处理器,因为Java语言的天然编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无须使用预处理器。那Java语言是否有办法实现条件编译呢?
Java语言当然能实现条件编译,方法就是使用条件为常量的if语句。下面代码中的if语句不同于其他Java代码,它在编译阶段就会被“运行”,生成的字节码中只包括System.out.println("block 1")一条语句
上述代码编译后反编译:
Java中条件编译的实现,也是语法糖,根据布尔值常量的真假,编译器将会把分支中不成立的代码块清除掉,这一工作将在编译器解除语法糖阶段完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此它只能实现语句基本块(Block级别)的条件编译,而没有办法实现根据条件调整整个Java类的结构。
处理本节中介绍的泛型、自动装箱、自动拆箱、变量循环、变长参数、条件编译为,java语言中还有不少其他的语法糖,如内部类、枚举类等等
二. 晚期优化(运行期优化)
在部分商业虚拟机中,Java 最初是通过解释器解释执行的,当虚拟机发现某个方法或者代码块的运行特别频繁时,就会把这些代码认定为「热点代码」(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT)。
即时编译器不是虚拟机必须的部分,Java 虚拟机规范并没有规定虚拟机内部必须要有即时编译器存在,更没有限定或指导即时编译器应该如何实现。但是 JIT 编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一。
2.1 HotSpot 虚拟机内的即时编译器
由于 Java 虚拟机规范中没有限定即时编译器如何实现,所以本节的内容完全取决于虚拟机的具体实现。我们这里拿 HotSpot 来说明,不过后面的内容涉及具体实现细节的内容很少,主流虚拟机中 JIT 的实现又有颇多相似之处,因此对理解其它虚拟机的实现也有很高的参考价值。
解释器与编译器
尽管并不是所有的 Java 虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如 HotSpot、J9 等,都同时包含解释器与编译器。
解释器与编译器两者各有优势:
-
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器码之后,可以获得更高的执行效率。
-
当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释器执行来节约内存,反之可以使用编译执行来提升效率。
同时,解释器还可以作为编译器激进优化时的一个「逃生门」,当编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新的类后类型继承结构出现变化、出现「罕见陷阱」时可以通过逆优化退回到解释状态继续执行。
编译对象与触发条件
程序在运行过程中会被即时编译器编译的「热点代码」有两类:
- 被多次调用的方法;
- 被多次执行的循环体。
这两种被多次重复执行的代码,称之为「热点代码」。
-
对于被多次调用的方法,方法体内的代码自然会被执行多次,理所当然的就是热点代码。
-
而对于多次执行的循环体则是为了解决一个方法只被调用一次或者少量几次,但是方法体内部存在循环次数较多的循环体问题,这样循环体的代码也被重复执行多次,因此这些代码也是热点代码。
对于第一种情况,由于是方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的 JIT 编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,但是编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为发生在方法执行过程中,因此形象地称之为栈上替换(On Stack Replacement,简称 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。
我们反复提到多次,可是多少次算多次呢?虚拟机如何统计一个方法或一段代码被执行过多少次呢?回答了这两个问题,也就回答了即时编译器的触发条件。
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为「热点探测」。其实进行热点探测并不一定需要知道方法具体被调用了多少次,目前主要的热点探测判定方式有两种。
-
基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是「热点方法」。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因数的影响而扰乱热点探测。
-
基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是「热点方法」。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是统计结果相对来说更加精确和严谨。
HotSpot 虚拟机采用的是第二种:基于计数器的热点探测。因此它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。
在确定虚拟机运行参数的情况下,这两个计数器都有一个确定的阈值,当计数器超过阈值就会触发 JIT 编译。
方法调用计数器
顾名思义,这个计数器用于统计方法被调用的次数。当一个方法被调用时,会首先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在,则将此方法的调用计数器加 1,然后判断方法调用计数器与回边计数器之和是否超过方法调用计数器的阈值。如果超过阈值,将会向即时编译器提交一个该方法的代码编译请求。
如果不做任何设置,执行引擎不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译完成后,这个方法的调用入口地址就会被系统自动改写成新的,下一次调用该方法时就会使用已编译的版本。
如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器值就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰期。
进行热度衰减的动作是在虚拟机进行 GC 时顺便进行的,可以设置虚拟机参数来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。此外还可以设置虚拟机参数调整半衰期的时间。
回边计数器
回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为「回边」(Back Edge)。建立回边计数器统计的目的是为了触发 OSR 编译。
当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否已经有编译好的版本,如果有,它将优先执行已编译的代码,否则就把回边计数器值加 1,然后判断方法调用计数器和回边计数器值之和是否超过计数器的阈值。当超过阈值时,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
与方法计数器不同,回边计数器没有计算热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出时,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。
2.2 编译优化技术
我们都知道,以编译方式执行本地代码比解释执行方式更快,一方面是因为节约了虚拟机解释执行字节码额外消耗的时间;另一方面是因为虚拟机设计团队几乎把所有对代码的优化措施都集中到了即时编译器中。这一小节我们来介绍下 HotSpot 虚拟机的即时编译器在编译代码时采用的优化技术。
优化技术概览
代码优化技术有很多,实现这些优化也很有难度,但是大部分还是比较好理解的。为了便于介绍,我们先从一段简单的代码开始,看看虚拟机会做哪些代码优化。
static class B { int value; final int get() { return value; } } public void foo() { y = b.get(); z = b.get(); sum = y + z; }
首先需要明确的是,这些代码优化是建立在代码的某种中间表示或者机器码上的,绝不是建立在 Java 源码上。这里之所使用 Java 代码来介绍是为了方便演示。
上面这段代码看起来简单,但是有许多可以优化的地方。
第一步是进行方法内联(Method Inlining),方法内联的重要性要高于其它优化措施。方法内联的目的主要有两个,一是去除方法调用的成本(比如建立栈帧),二是为其它优化建立良好的基础,方法内联膨胀之后可以便于更大范围上采取后续的优化手段,从而获得更好的优化效果。因此,各种编译器一般都会把内联优化放在优化序列的最前面。内联优化后的代码如下:
public void foo() { y = b.value; z = b.value; sum = y + z; }
第二步进行冗余消除,代码中「z = b.value;」可以被替换成「z = y」。这样就不用再去访问对象 b 的局部变量。如果把 b.value 看做是一个表达式,那也可以把这项优化工作看成是公共子表达式消除。优化后的代码如下:
public void foo() { y = b.value; z = y; sum = y + z; }
第三步进行复写传播,因为这段代码里没有必要使用一个额外的变量 z,它与变量 y 是完全等价的,因此可以使用 y 来代替 z。复写传播后的代码如下:
public void foo() { y = b.value; y = y; sum = y + y; }
第四步进行无用代码消除。无用代码可能是永远不会执行的代码,也可能是完全没有意义的代码。因此,又被形象的成为「Dead Code」。上述代码中 y = y 是没有意义的,因此进行无用代码消除后的代码是这样的:
public void foo() { y = b.value; sum = y + y; }
经过这四次优化后,最新优化后的代码和优化前的代码所达到的效果是一致的,但是优化后的代码执行效率会更高。编译器的这些优化技术实现起来是很复杂的,但是想要理解它们还是很容易的。接下来我们再讲讲如下几项最有代表性的优化技术是如何运作的,它们分别是:
- 公共子表达式消除;
- 数组边界检查消除;
- 方法内联;
- 逃逸分析。
公共子表达式消除
如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接使用前面计算过的表达式结果代替 E 就好了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围覆盖了多个基本块,那就称为全局公共子表达式消除。
数组边界检查消除
如果有一个数组 array[],在 Java 中访问数组元素 array[i] 的时候,系统会自动进行上下界的范围检查,即检查 i 必须满足 i >= 0 && i < array.length,否则会抛出一个运行时异常:java.lang.ArrayIndexOutOfBoundsException,这就是数组边界检查。
对于虚拟机执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这是一种不小的性能开销。为了安全,数组边界检查是必须做的,但是数组边界检查并不一定每次都要进行。比如在循环的时候访问数组,如果编译器只要通过数据流分析就知道循环变量是不是在区间 [0, array.length] 之内,那在整个循环中就可以把数组的上下界检查消除。
方法内联
方法内联前面已经通过代码分析介绍过,这里就不再赘述了。
逃逸分析
逃逸分析不是直接优化代码的手段,而是为其它优化手段提供依据的分析技术。逃逸分析的基本行为就是分析对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其它方法中,称为方法逃逸。甚至还有可能被外部线程访问到,例如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
如果能证明一个对象不会逃逸到方法或者线程之外,也就是别的方法和线程无法通过任何途径访问到这个方法,则可能为这个变量进行一些高效优化。比如:
-
栈上分配:如果确定一个对象不会逃逸到方法之外,那么就可以在栈上分配内存,对象所占的内存空间就可以随栈帧出栈而销毁。通常,不会逃逸的局部对象所占的比例很大,如果能栈上分配就会大大减轻 GC 的压力。
-
同步消除:如果逃逸分析能确定一个变量不会逃逸出线程,无法被其它线程访问,那这个变量的读写就不会有多线程竞争的问题,因而变量的同步措施也就可以消除了。
-
标量替换:标量是指一个数据无法再拆分成更小的数据来表示了,Java 虚拟机中的原始数据类型都不能再进一步拆分,所以它们就是标量。相反,一个数据可以继续分解,那它就称作聚合量,Java 中的对象就是聚合量。如果把一个 Java 对象拆散,根据访问情况将其使用到的成员变量恢复成原始类型来访问,就叫标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散,那程序执行的时候就可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来替代。对象被拆分后,除了可以让对象的成员变量在栈上分配和读写,还可以为后续进一步的优化手段创造条件。
三. 总结
本文用两个小节分别介绍了 Java 程序从源代码编译成字节码和从字节码编译成本地机器码的过程,Javac 字节码编译器与虚拟机内的 JIT 编译器的执行过程合并起来其实就等同于一个传统编译器所执行的编译过程。下一篇文章我们来聊聊虚拟机是如何高效处理并发的。
参考链接:
https://blog.csdn.net/qq_37113604/article/details/91398850
https://www.cnblogs.com/baronzhang/p/11108322.html
《深入理解 Java 虚拟机》