0%

第10章:早期(编译期)优化

Java语言的“编译期”是一段“不确定”的操作过程,它可能是指一个前端编译器把.java文件转变成 .class 的过程,也可能是指JIT编译器把字节码变成机器码的过程,还能是指 ATO编译器直接把 .java 文件编译成本地代码的过程。最符合大家认知的应该是第一类,在本章中,我们提到的 “编译期” 以及 “编译器” 都指限于第一类过程。

Javac 编译器

前面很多个人觉得只有写编译器才能用得着的内容,先略过。

标注检查:javac的编译过程包含标注检查,标注检查的内容包括诸如变量使用前是否已经被声明,变量与赋值类型是否匹配等。此外,标注检查还有一个重要动作称为常量折叠,如果我们在代码中谢了如下定义:

int a = 1 + 2;

那么在语法树上仍然能看到字面量“1”、“2” 以及操作符 “+”,但是经过常量折叠后,他们将会被折叠为字面量 “3”,由于编译期进行了常量折叠,因此在代码中定义 “a = 1 + 2” 与直接定义 “a = 3” 的cpu指令运算量是一样的,并不会增加额外的哪怕一个cpu指令开销。数据及控制流分析是对程序上下文逻辑的进一步验证,以下举一个关于 final 修饰符的数据及控制流分析的例子:

1
2
3
4
5
6
7
8
9
10
11
12
//方法一有final修饰
public void foo(final int arg){
final int var = 0;
//do something
}


//方法二没有final修饰
public void foo(int arg){
int var = 0;
//do something
}

这两个 foo() 方法中,在代码编写时程序肯定会受到 final 修饰符的影响,不能再改变第一个方法的 arg 和 var 变量的值,但是这两段代码编译出来的Class 文件是没有任何区别的。通过 第六章 的内容可知,局部变量与字段(实例变量、类变量) 是有区别的,前者在常量池中没 CONSTANT_Fieldref_info 符号引用,自然也没有访问标志(Access_Flags)的信息,甚至可能连名称也不会保留下来(取决于编译时的选项),自然在Class文件中不可能知道一个布局变量是不是声明为 final 了,因此,将局部变量声明为 final 对运行是没有影响的,变量的不可变性仅仅由编译器在编译期间保障

Java 语法糖的味道

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

泛型与类型擦除

Java中的类型,本质是参数化类型(Parametersized Type)的应用,也就是说所操作的数据类型被指定为一个参数。泛型技术在Java 和 C# 之中的使用方式看似相同,但实现上却有根本性的分期,C# 中的泛型无论在程序源码中、编译后的IL(中间语言)中或是运行期的 CLR 中,都是切实存在的, List 与 List 就是两个不同的类型,它们在系统运行期生成,有自己的虚方法表和类型数据,这种实现方式称为类型膨胀,基于这种方法实现的泛型称为真实泛型。

Java 语言中的泛型规则不一样,它们只在源码中存在,在编译后的字节码文件中就已经替换为原来的原声类型了,因此,对于运行期的Java语言来说, ArraList 与 ArrayList 就是同一个类,所以泛型技术实际上是Java的一颗语法糖,这种实现方法称为“类型擦除”,基于这种方法实现的泛型称为 伪泛型。可以通过代码反编译来查看Java泛型实现过程:

1
2
3
4
5
6
7
8
public static void main(String[] args){
Map<String,String> map = new HashMap();
map.put("hello","你好");
map.put("how are you","吃了吗");
Systemt.out.println(map.get("hello"));
Systemt.out.println(map.get("how are you"));
}

把这段代码编译成 Class 文件,再用子界面反编译工具反编译成Java代码,会发现代码变成如下形式:

1
2
3
4
5
6
7
public static void main(String[] args){
Map map = new HashMap();
map.put("hello","你好");
map.put("how are you","吃了吗");
Systemt.out.println((String)map.get("hello"));
Systemt.out.println((String)map.get("how are you"));
}

会发现,反编译回来的 Map 定义都变成了 Map map = new HashMap(),输出的时候,是靠强转实现的,也就是把object转为程序员写的实际类型。Java 的伪泛型招致很多批评的声音,不过这种实现方式在某些情况下丧失了泛型思想应有的一些优雅,比如在类中存在如下两个方法:

1
2
3
4
5
6
7
8
9
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 与 List 编译后都被擦除了,变成了一样的原生类型 List ,擦除动作导致这两种方法的特征签名变得一样。初看起来,无法重载的原因找到了,但真的如此吗?其实,泛型擦除成相同的原生类型只是无法重载的原因之一,接着看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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;
}

public static void main(String[] args){
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}
}

编译执行发现,不但可以编译还能正常输出结果:

System.out.println(“invoke method(List list)”)
System.out.println(“invoke method(List list)”)

为两个方法添加了不同的返回值之后,方法重载居然成功(注意,仅仅只是在jdk 1.6及以下才能编译通过,高版本是编译不通过的,但在书上是没有这个版本说明的,而我们读者只需要知道有这么个事情就行)了,这是对Java语言中返回值不参与重载选择的基本认知的挑战吗?当然不是的,之所以能够编译成功,是因为两个 method 方法加入了不同的返回值之后,能够共存在同一个 Class 文件了。由于这只是针对低版本的功能,故此处不多解释了。

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

这几个专门拿出来讲只是因为它们是Java语言中使用得最多的语法糖。可以通过以下代码看看这些语法糖在编译后会发生什么变化:

1
2
3
4
5
6
7
8
9
public static void main(String[] args){
List<Integer> list = Arrays.asList(1,2,3,4);
int sum = 0;
for(int i: list){
sum += i;
}

Systemt.out.println(sum);
}

上述代码在自动装箱、拆箱与遍历循环编译后,变成以下样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public static void main(String[] args){
List<Integer> 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;
}

Systemt.out.println(sum);
}

代码一共包含自动装箱、自动拆箱、遍历循环与变长参数5种语法糖。遍历循环还原成了迭代器的实现,而变长参数则是通过数组的方式转变。语法糖看起来简单,但是也有很多需要注意的地方,如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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 Interger 128限制(缓存了 -128~127的对象,超出这个值就重新new,否则就直接取)
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 ,因为 g 是 Long 类型,而 a + b 后是Integer类型,类型都不一样,equals 不会自动处理数据转型

结果和注释都已经写上了,关于Integer的128限制,再来例子说明:

1
2
3
4
5
6
7
8
9
10
Integer i=127;
Integer j =127;
System.out.println(i==j); //true
i=128;
j=128;
System.out.println(i==j); //false

i=new Integer(127);
j=new Integer(127);
System.out.println(i==j); //false

详细解释:jvm在运行时创建了一个缓存区域,并创建了一个integer的数组。这个数组存储了-128至127的值。因此如果integer的值在-128至127之间,则是去缓存里面获取。因此上面的i和j指向的是同一个内存地址。因为128超过了这个缓存区域,因此第二次赋值的时候是重新开辟了两个内存地址。第三次因为使用了new关键字,在java中。new关键字是开辟内存空间。因此第三次赋值是开辟了新的内存空间,此时发现即便i与j都是127,但内存地址不再相同。

包装类的 “==” 运算在不遇到算术运算的情况下不会自动拆箱,并且它们的 equals() 方法不处理数据转型的关系。

条件编译

实战: 插入式注解处理器

谢谢你的鼓励