您的位置:首页 > 其它

Jvm早期优化(编译期)

2011-12-05 10:52 246 查看

1.概述

Java语言的“编译期”是一段“不确定”的操作过程,因为它可能是指一个前端编译器(其实叫“编译器的前端”更准确一些)把*.java文件转变成*.class文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,just in time compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,ahead of time compiler)直接把*.java文件编译成本地机器代码的过程。下面列举了这三类编译过程中一些比较有代表性的编译器:

Ø 前端编译器:sun的javac、eclipse JDT中的增量式编译器(ECJ)。

Ø JIT编译器:HotSpot VM 的C1、C2编译器。

Ø AOT编译器:GNU Compiler for the java、Excelsior JET。

这三类过程中最符合大家对java程序编译认知的应该是第一类,在后面的讲解里,提到的“编译期”和“编译器”都仅限于第一类编译过程。限制了编译范围后,对于“优化”二字的定义就需要宽松一些,因为javac这类编译器对代码的运行效率几乎没有任何优化措施(在JDK1.3之后,javac的-O优化参数就不再有意义了)。虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样可以让那些不是由javac产生的class文件也同样能享受到编译器优化带来的好处。

但是javac做了许多针对编码过程的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持,可以说,java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。

2.javac编译器

分析源码是了解一项技术实现内幕的最有效的手段,javac编译器不像HotSpot虚拟机那样使用c++语言(包含少量C语言)实现,它本身就是一个由java语言编写的程序,这为纯java的程序员了解它的编译过程带来了很大的便利。

2.1javac的源码与调试

在myeclipse中新建一个java项目,将javac的源码导入进去,导入期间,源码文件“AnnotationProxyMaker.java”会报错,被myeclipse拒绝编译。

这是由于myeclipse的JRE System Library中默认包含了一系列的代码访问规则(Access Rules),如果代码中引用了这些访问规则所禁止引用的类,就会提示这个错误。可以通过添加一条允许访问jar包中所有类的访问规则来解决这个问题,如图所示:



导入了javac的源码之后,就可以运行com.sun.tools.javac.Main的main()方法来执行编译了,与命令行中使用javac的命令没有什么区别。

虚拟机规范严格定义了Class文件的格式,但是对如何把java源码文件转变为class文件的编译过程未作任何定义,所以这部分内容是与具体JDK实现相关的。从sun javac的代码来看,编译过程大致可以分为三个过程,分别是:

v 解析与填充符号表过程;

v 插入式注解处理器的注解处理过程;

v 分析与字节码生成过程。

Javac编译动作的入口是com.sun.tools.javac.main.JavaCompiler类,上述三个过程的代码逻辑集中在这个类的compile()和compile2()方法里。整个编译最关键的处理是由8个方法来完成的,分别是:

initProcessAnnotations(processors);//准备过程:初始化插入式注解处理器

delegateCompiler =

processAnootations( //过程2:执行注解处理

enterTrees(stopIfError(CompileState.PARSE, //过程1.2:输入到符号表

parseFiles(sourceFileObject))), //过程1.1:词法分析、语法分析

classnames);

delegateCompiler.compile2(); //过程3:分析及字节码生成

case BY_TODO:

while(! todo.isEmpty())

generate(desugar(flow(attribute(todo.remove()))));

break;

//generate,过程3.4:生成字节码

desugar,过程3.3解语法糖

flow,过程3.2:数据流分析

attribute,过程3.1:标注

语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机科学家Peter J. Landin发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是更方便程序员使用。通常来说使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

Java在现代编程语言之中属于“低糖语言”(相对于C#及许多其他jvm语言来说),尤其是JDK1.5之前的版本,“低糖”语法也是java语言被怀疑已经“落后”的一个表面理由。Java中最常用的语法糖主要是泛型、变长参数、自动装箱拆箱,等等,虚拟机运行时不支持这些语法,它们在编译阶段被还原回简单的基础语法结构,这个过程就被称为解语法糖。

在javac的源码中,解语法糖的过程由desugar()方法触发。

3.java语法糖的味道

几乎各种语言或多或少都提供过一些语法糖来方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少代码出错的机会。不过也有一种观点认为语法糖并不一定都是有益的,大量添加和使用含糖的语法容易让程序员产生依赖,无法看清语法糖的糖衣背后程序代码的真实面目。

总而言之,语法糖可以看做是编译器实现的一些“小把戏”,这些“小把戏”可能会使得效率有一个“大提升”,但我们也应该去了解这些“小把戏”背后的真实世界,那样才能利用好它们,而不是被它们所迷惑。

3.1泛型与类型擦除

泛型是JDK1.5的一项新特性,它的本质是参数化类型(Parameterized Type)的应用,也就是说所操作的数据类型被指定为一个参数。这种参数类型可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

泛型思想早在C++语言的模板(Template)中就开始生根发芽,在java语言还没有出现泛型时,只能通过Object是所有类型的父类和类型强制转换两个特点的配合来实现类型泛化。例如在哈希表的存取中,JDK1.5之前使用HashMap的get()方法,返回值就是一个Object对象,由于java语言里面所有的类型都继承于java.lang.Object,那Object转型成任何对象都是有可能的。但是也因为有无限的可能,就只有程序员和运行期的虚拟机才知道这个Object到底是个什么类型的对象。在编译期间,编译器无法检查这个Object的强制转型是否成功,如果仅仅依赖程序员去保障这项操作的正确性,许多ClassCastException的风险就会被转嫁到程序运行期中。

泛型技术在C#和java之中的使用方式看似相同,但实现上却有着根本性的分歧,C#里面泛型无论在程序源码之中、编译后的IL中(Intermediate Language,中间语言,这时候泛型是一个占位符)还是在运行期的CLR(common language runtime)中都是切实存在的,List<int>与List<String>就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现成为类型膨胀,基于这种方法实现的泛型被称为真实泛型。

Java语言中的泛型则不一样,它只在程序源码中存在,在编译后的字节码文件中,就已经被替换为原来的原生类型(Raw Type,也称为裸类型)了,并且在相应的地方插入了强制转型代码,因此对于运行期的java语言来说,ArrayList<int>与ArrayList<String>就是同一个类。所以说泛型技术实际上是java语言的一颗语法糖,java语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型被称为伪泛型。

代码1是一段简单的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"));

}

把这段代码编译成class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了java泛型出现之前的写法,泛型类型都变回了原生类型:

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"));

}

当初JDK设计团队为什么选择类型擦除的方式来实现java语言的泛型支持呢?是因为实现简单、兼容性考虑还是别的原因?我们不得而知,但确实有不少人对java语言提供的伪泛型颇有微词,当时甚至连《thinging in java》的作者Bruce Eckel也发表了一篇文章《这不是泛型!》来批评JDK1.5中的泛型实现。

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<String>编译之后都被擦除了,变成了一样的原生类型List<E>,擦除动作导致这两个方法的特征签名变的一模一样。初步来看,无法重载的原因已经找到了,但是真的如此吗?只能说,泛型擦除成相同的原生类型只是无法重载的一部分原因,请再看一下下面的代码:

public class GenericTypes {

public static String method(List<String> list){

System.out.println("invoke method(List<String> list)");

return "";

}

public static int method(List<Integer> list){

System.out.println("invoke method(List<Integer> list");

}

return "1";

}

这两段代码的差别,是两个method方法添加了不同的返回值,由于这两个返回值的加入,方法重载居然成功了,即这段代码可以被编译和执行了。

重载的时候,方法名要一样,但是参数类型和个数不一样,返回值类型可以相同也可以不相同。无法以返回型别作为重载函数的区分标准。

上面代码中的重载当然不是根据返回值来确定的,之所以这次编译和执行能成功,是因为两个method()方法加入了不同的返回值后才能共存在一个class文件中。之前介绍过class文件内容中方法表(method_info),方法重载要求方法具备不同的特征签名,返回值并不包含在方法的特征签名之中,所以返回值不参与重载选择,但是在class文件格式之中,只要描述符不是完全一致的两个方法就可以共存。也就是说两个方法如果有相同的名称和特征签名,但返回值不同,那它们也是可以合法的共存于一个class文件中。

3.2自动装箱、拆箱与遍历循环

自动装箱、拆箱与遍历循环是java语言里使用的最多的语法糖。如下代码所示:

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);

}

上述代码编译之后的变化:

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.println(sum);

}

上面第一段代码中一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数五种语法糖,上面第二段代码则展示了它们在编译后的变化。泛型就不必说了,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例子中的Integer.valueOf()与Integer.intValue()方法,而遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。最后再看看变长参数,它在调用的时候变成了一个数组类型的参数,在变长参数出现之前,程序员就是使用数组来完成类似功能的。

这些语法糖虽然看起来很简单,但也不见得就没有任何值得我们注意的地方,下面的代码演示了自动装箱的一些错误用法:

public static void main(String[] args){

Integer a = 1;

Integer b = 2;

Integer c = 3;

Integer d = 4;

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));

}

请思考两个问题:一是代码中的6句打印语句输出时什么?二是6句打印语句中,解除语法糖后参数是什么样?鉴于包装类的“==”运算在没有遇到算数运算的情况下不会自动拆箱,而且它们的equals()方法不会处理数据转型的关系,我们建议在实际编码中应该尽量避免这样使用装箱与拆箱。

3.3条件编译

许多程序设计语言都提供了条件编译的途径,如C、C++中使用预处理器指示符(#ifdef)来完成条件编译。C、C++的预处理器最初的任务是解决编译时的代码依赖关系(众所周知的#include预处理指令),而在java语言之中并没有使用预处理器,因为java语言天然的编译方式(编译器并非一个一个的编译java文件,而是将所有的编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无需使用预处理器。那java语言是否有办法实现条件编译呢?

Java语言当然也可以进行条件编译,方法就是使用条件为常量的if语句。如下代码所示,此代码中的if语句不同于其他java代码,它在编译阶段就会被“运行”,生成的字节码之中只包括System.out.println("block 1");一条语句,并不会包含if语句及另外一个分支中的System.out.println("block 2");。

public static void main(String[] args){

if(true){

System.out.println("block 1");

}else{

System.out.println("block 2");

}

}

此代码编译后class文件的反编译结果:

public static void main(String[] args){

System.out.println("block 1");

}

只能使用条件为常量的if语句才能达到上述效果,如果使用常量和其他带有条件判断能力的语句搭配,则可能在控制流分析中提示错误,被拒绝编译,如下面代码所示,就会被编译器拒绝编译:

public static void main(String[] args){

while(false){

System.out.print("");

}

java语言中条件编译的实现,是java语言的一个语法糖,根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖的阶段完成。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的java语法,只能写在方法体内部,因此它只能实现语句基本块级别的条件编译,而没有办法实现根据条件调整整个java类的结构。

除了我们介绍的泛型、自动装箱、自动拆箱、遍历循环、变长参数和条件编译之外,java语言还有不少其他的语法糖,如内部类、枚举类、断言语句、对枚举和字符串的switch支持、在try语句中定义和关闭资源等,可以通过跟踪javac源码、反编译class文件等方式了解它们的本质实现。
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: