Java语言的“编译期”是一段“不确定”的操作过程,它可能是指一个前端编译器把.java文件转变成 .class 的过程,也可能是指JIT编译器把字节码变成机器码的过程,还能是指 ATO编译器直接把 .java 文件编译成本地代码的过程。最符合大家认知的应该是第一类,在本章中,我们提到的 “编译期” 以及 “编译器” 都指限于第一类过程。
Javac 编译器
前面很多个人觉得只有写编译器才能用得着的内容,先略过。
标注检查:javac的编译过程包含标注检查,标注检查的内容包括诸如变量使用前是否已经被声明,变量与赋值类型是否匹配等。此外,标注检查还有一个重要动作称为常量折叠,如果我们在代码中谢了如下定义:
int a = 1 + 2;
那么在语法树上仍然能看到字面量“1”、“2” 以及操作符 “+”,但是经过常量折叠后,他们将会被折叠为字面量 “3”,由于编译期进行了常量折叠,因此在代码中定义 “a = 1 + 2” 与直接定义 “a = 3” 的cpu指令运算量是一样的,并不会增加额外的哪怕一个cpu指令开销。数据及控制流分析是对程序上下文逻辑的进一步验证,以下举一个关于 final 修饰符的数据及控制流分析的例子:
1 | //方法一有final修饰 |
这两个 foo() 方法中,在代码编写时程序肯定会受到 final 修饰符的影响,不能再改变第一个方法的 arg 和 var 变量的值,但是这两段代码编译出来的Class 文件是没有任何区别的。通过 第六章 的内容可知,局部变量与字段(实例变量、类变量) 是有区别的,前者在常量池中没 CONSTANT_Fieldref_info 符号引用,自然也没有访问标志(Access_Flags)的信息,甚至可能连名称也不会保留下来(取决于编译时的选项),自然在Class文件中不可能知道一个布局变量是不是声明为 final 了,因此,将局部变量声明为 final 对运行是没有影响的,变量的不可变性仅仅由编译器在编译期间保障。
Java 语法糖的味道
语法糖可以看做编译器实现的一些 “小把戏” ,这些小把戏不会提供实质性的功能改进,但它可能会使得效率“大提升”,但我们也应该去了解这些“小把戏”背后的真实世界,那样才能利用好它们,而不是被它们迷惑。
泛型与类型擦除
Java中的类型,本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。泛型技术在Java 和 C# 之中的使用方式看似相同,但实现上却有根本性的分期,C# 中的泛型无论在程序源码中、编译后的IL(中间语言)中或是运行期的 CLR 中,都是切实存在的, List
Java 语言中的泛型规则不一样,它们只在源码中存在,在编译后的字节码文件中就已经替换为原来的原声类型了,因此,对于运行期的Java语言来说, ArraList
1 | public static void main(String[] args){ |
把这段代码编译成 Class 文件,再用子界面反编译工具反编译成Java代码,会发现代码变成如下形式:
1 | public static void main(String[] args){ |
会发现,反编译回来的 Map 定义都变成了 Map map = new HashMap(),输出的时候,是靠强转实现的,也就是把object转为程序员写的实际类型。Java 的伪泛型招致很多批评的声音,不过这种实现方式在某些情况下丧失了泛型思想应有的一些优雅,比如在类中存在如下两个方法:
1 | public class GenericTypes{ |
思考一下,这段代码是否正确。也许你已经知道了,这段代码是不能被编译的,因为List
1 | public class GenericTypes{ |
编译执行发现,不但可以编译还能正常输出结果:
System.out.println(“invoke method(List
list)”)
System.out.println(“invoke method(Listlist)”)
为两个方法添加了不同的返回值之后,方法重载居然成功(注意,仅仅只是在jdk 1.6及以下才能编译通过,高版本是编译不通过的,但在书上是没有这个版本说明的,而我们读者只需要知道有这么个事情就行)了,这是对Java语言中返回值不参与重载选择的基本认知的挑战吗?当然不是的,之所以能够编译成功,是因为两个 method 方法加入了不同的返回值之后,能够共存在同一个 Class 文件了。由于这只是针对低版本的功能,故此处不多解释了。
自动装箱、拆箱与遍历循环
这几个专门拿出来讲只是因为它们是Java语言中使用得最多的语法糖。可以通过以下代码看看这些语法糖在编译后会发生什么变化:
1 | public static void main(String[] args){ |
上述代码在自动装箱、拆箱与遍历循环编译后,变成以下样式:
1 | public static void main(String[] args){ |
代码一共包含自动装箱、自动拆箱、遍历循环与变长参数5种语法糖。遍历循环还原成了迭代器的实现,而变长参数则是通过数组的方式转变。语法糖看起来简单,但是也有很多需要注意的地方,如以下代码:
1 | Integer a = 1; |
结果和注释都已经写上了,关于Integer的128限制,再来例子说明:
1 | Integer i=127; |
详细解释:jvm在运行时创建了一个缓存区域,并创建了一个integer的数组。这个数组存储了-128至127的值。因此如果integer的值在-128至127之间,则是去缓存里面获取。因此上面的i和j指向的是同一个内存地址。因为128超过了这个缓存区域,因此第二次赋值的时候是重新开辟了两个内存地址。第三次因为使用了new关键字,在java中。new关键字是开辟内存空间。因此第三次赋值是开辟了新的内存空间,此时发现即便i与j都是127,但内存地址不再相同。
包装类的 “==” 运算在不遇到算术运算的情况下不会自动拆箱,并且它们的 equals() 方法不处理数据转型的关系。
条件编译
略
实战: 插入式注解处理器
略