image-20210616191609122

编译期的分类:

  • 前端编译器:把*.java文件转换为*.class文件。例如sun的javac、eclipseJDT中的增量编译器。
  • 即时编译器(JIT编译器):运行期把字节码转换成机器码。例如 HotSpot VM的C1、C2编译器。
  • 提前编译器(AOT编译器):静态提前编译器,直接把程序编译成与目标机器指令集相关的二进制代码,例如GCJ。

Javac的源码与调试

从Javac代码的总体结构来看,编译过程大致可以分为一个准备过程和3个处理过程:

  • 准备过程:初始化插入式注解处理器
  • 解析与填充符号表的过程。
  • 插入式注解处理器的注解过程。
  • 分析与字节码生成的过程。

上述三个过程里,执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须回到之前的解析、填充符号表的过程中重新处理这些符号。三者间的关系与顺序如下图所示:

image-20210616192934076

解析与填充符号表

  • 词法分析,是将源代码的字符流转变为标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记。
  • 语法分析,是根据Token序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个阶段都代表着程序代码中的一个语法结构(Construct),例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。
  • 填充符号表,符号表是由一组符号地址和符号信息构成的数据结构。符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地质分配时,符号表是地址分配的依据。填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,此过程的产出物是一个待处理列表(To Do List),包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果存在的话)的顶级节点。

注解处理器

  • JDK5之后,Java语言提供了对注解的支持,注解在设计上原本是与普通的Java代码一样,都只会在程序运行期间发挥作用的。
  • 但在JDK1.6中实现了JSR-269规范,提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素
  • 如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次。

语义分析与字节码生成

语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如进行类型审查、控制流检查、数据流检查,等等。

语义分析过程分为标注检查数据及控制流分析两个步骤。而字节码生成之前还需要解语法糖

  • 标注检查,检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等。还会顺便进行一个称为常量折叠(Constant Folding)的代码优化。标注检查步骤在Javac源码中的实现类是com.sun.tools.javac.comp.Attr和com.sun.tools.javac.comp.Check类。
  • 数据及控制流分析,是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
  • 解语法糖, 语法糖(System Sugar),也称糖衣语法,指在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响,但是更方便程序员使用。通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。java中常用的语法糖主要是泛型、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,他们在编译阶段还原回原始的基础语法结构,这个过程称为解语法糖。
  • 字节码生成,是javac编译过程的最后一个阶段,这个阶段不仅把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘里,编译器还进行了少量的代码添加和转换工作。 比如:实力构造器()方法和类构造器()方法就是在这个阶段添加到语法树中(并不是默认构造函数,如何用户代码中没有任何构造函数,添加默认构造函数是在填充符号表阶段完成)

Java语法糖的味道

语法糖可以看作是前端编译器实现的一些小把戏,这些小把戏可能会使效率得到到提升,但我们也应该去了解这些小把戏背后的真实面貌,那样才能利用好它们,而不是被它们迷惑。

泛型和类型擦除

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

泛型擦除的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 类型擦除前
public static void main(String[] args) {
Map< String , String > map = new HashMap<>();

map.put("How are you ?","吃了吗?");

map.put("Hi","您好!");

System.out.println(map.get("Hi"));
}
// 类型擦除后
public static void main(String[] args){
Map map = new HashMap();

map.put("How are you ?", "吃了吗?");

map.put("Hi", "您好!");

System.out.println((String)map.get("Hi"));
}

擦除式泛型的缺陷(具体例子看P372):

  • 导致对原始类型数据的支持又成了新的麻烦
  • 运行期无法获取到泛型类型信息,会让一些代码变得相当啰嗦
  • 丧失了一些面向对象思想应有的优雅,带来了一些模棱两可的模糊状况

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

自动装箱、拆箱与遍历循环解语法糖测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 编译之前
public class ForeachTest {
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 class ForeachTest
{
public static void main(String[] args)
{
List list = Arrays.asList(new Integer[] { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) });

List list2 = (List)Stream.of(new Integer[] { Integer.valueOf(1), Integer.valueOf(2), Integer.valueOf(3), Integer.valueOf(4) }).collect(Collectors.toList());

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()方法
  • 而遍历循环则被还原成了迭代器的实现,这也是为什么遍历器循环需要被遍历的类实现Iterator接口的原因
  • 变长参数(asList),它在调用的时候变成了一个数组类型的参数,在变长参数出来之前,程序员使用数组来完成类似功能。

自动装箱的陷阱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
/**
* 基本数据类型和引用类型的区别主要在于基本数据类型是分配在栈上的,而引用类型是分配在堆上的
* 不论是基本数据类型还是引用类型,他们都会先在栈中分配一块内存,对于基本类型来说,这块区域包含的是基本类型的内容;
* 而对于引用类型来说,这块区域包含的是指向真正内容的指针,真正的内容被手动的分配在堆上。
*/
public class AutoBox {
public static void main(String[] args) {
Integer a = 1;
Integer b = 2;
Integer c = 3;
Integer d = 3;
Integer e = 321;
Integer f = 321;
Long g = 3L;
Integer h = new Integer(3);
Integer i = new Integer(3);
/*
包装类遇到“==”号的情况下,如果不遇到算数运算符(+、-、*、……)是不会自动拆箱的.所以这里“==”比较的是对象(地址)
*/
//true 对于Integer 类型,整型的包装类系统会自动在常量池中初始化-128至127的值,如果c和d都指向同一个对象,即同一个地址。
System.out.println("c==d:" + (c == d));
//false 但是对于超出范围外的值就是要通过new来创建包装类型,所以内存地址也不相等
System.out.println("e==f:" + (e == f));
//true 因为遇到运算符自动拆箱变为数值比较,所以相等。
System.out.println("c==(a+b):" + (c == (a + b)));
//true 包装类都重写了equals()方法,他们进行比较时是比的拆箱后数值。但是并不会进行类型转换
System.out.println("c.equals(a+b)" + (c.equals(a + b)));
//true ==遇到算数运算符会自动拆箱(long) 3==(int)3
System.out.println("g==(a+b)" + (g == (a + b)));
//false equals首先看比较的类型是不是同一个类型,如果是,则比较值是否相等,否则直接返回false
System.out.println("g.equals(a+b):" + g.equals(a + b));
//true equals首先看比较的类型是不是同一个类型,如果是,则比较值是否相等,否则直接返回false
System.out.println("h.equals(i):" + h.equals(i));
//false 通过new来创建包装类型,所以内存地址也不相等
System.out.println("h == i:" + (h == i));
}
}

条件编译

  • java语言之中并没有使用预处理器,因为Java语言天然的编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)无须使用预处理器。
  • Java语言可以使用条件为常量的if语句进行条件编译,根据布尔常量的真假来将分支中不成立的代码块消除掉。由于这种条件编译的实现方式使用了if语句,所以它必须遵循最基本的Java语法,只能写在方法体内部,因此它只能实现语句基本块(Block)级别的条件编译。
  • 只能使用if,若使用常量与其他带有条件判断能力的语句描述搭配,则可能在控制流分析中提示错误,拒绝编译。

实战:插入式注解处理器

请看P378.