目录
一、Javac编译器
Javac的编译过程大致可以分为3个过程,分别是:
- 解析与填充符号表过程
- 插入式注解处理器的注解处理过程
- 分析与字节码生成过程
1.1 解析与填充符号表
1.1.1 词法、语法分析
词法分析是将源码的字符流转变为**标记(Token)**集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记,如“int a = b + 2”这句代码包含了6个标记,分别是int、a、=、b、+、2,虽然int由3个字符构成,但是它只是一个Token,不可再拆分。
语法分析则是根据Token序列构造抽象语法树的过程,**抽象语法树(Abstract Syntax Tree)**是一种用来描述程序代码语法结构的树形表示方式。经过这个步骤之后,编译器就基本不会再对源码文件进行操作了,后续的操作都建立在抽象语法树上。
1.1.2 填充符号表
符号表是由一组符号地址和符号信息构成的表格,从理解上可以把它想象成哈希表中K-V的结构(实际上符号表不一定是哈希表实现,可以是:有序符号表、树状符号表、栈结构符号表等)。在语义分析中,符号表用于语义检查和产生中间代码。在目标代码生成阶段,符号表是对符号名进行地址分配的依据。
在这个阶段,如果类没有提供任何构造函数(不是方法),那编译器会添加一个没有参数的、访问性(public等)与当前类一直的默认构造函数。
1.2 注解处理器
在JDK1.6中实现了JSR-269规范,提供了一组嵌入式注解处理器的标准API在编译期间对注解进行处理。可以把它看做是一组编译器插件,在这些插件里可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有嵌入式注解处理器都没有再对语法树进行修改为止。
1.3 语义分析与字节码生成
抽象语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的检查。假设有如下3个变量的定义语句:
int a = 1;
boolean b = false;
char c = 2;
后续可能出现的赋值运算:
int d = a + c;
int d = b + c;
char d = a + c ;
上述3个赋值运算,只有第一个在语义上没有问题,能通过编译,其余两种在Java语言中不合逻辑,无法编译。
Javac编译过程中,语义分析分为标注检查以及数据及控制流分析两个步骤。
1.3.1 标注检查
标注检查步骤检查的内容包括诸如变量使用前是否已被声明、变量与赋值之前的数据类型是否能够匹配等。另外还有一个动作称为常量重叠,如果我们在代码中写了如下定义:
int a = 1 + 2;
在经过常量折叠后,字面量“1”、“2”、“+”会被折叠为字面量“3”。由于常量定义的存在,在代码里定义"a = 1+ 2"和直接定义“a = 3”,效果都是一样的。
1.3.2 数据及控制流分析
数据及控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
1.3.3 解语法糖
像泛型、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。
1.3.4 字节码生成
字节码生成是编译的最后一个阶段,不仅仅会把前面各个步骤所生成的信息(语法树、符号表)转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转换工作。
比如实例构造器<init>()方法和类构造器<clinit>()方法就是在这个阶段生成的。这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对于实例构造器是"{}"块,对于类构造器是"static{}"块)、变量初始化(实例变量和类变量)、调用父类的实例构造器(<clinit>()方法无需调用父类的<clinit>(),虚拟机会自动保证父类的构造器的执行)等操作收敛到<init>()和<clinit>()方法之中,并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行(一定要注意这里说的是构造器方法,不是构造函数)。
除了生成构造器之外,还有一些其它代码替换工作由于优化程序的实现逻辑,比如把字符串对象的加操作替换为StringBuilder的append操作等。
1.4 解语法糖
1.4.1 泛型与类型擦除
Java实现的泛型是伪泛型,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型了,并且在相应的地方插入了强制转型代码。因此,对于运行期的Java语言来说,ArrayList<Integer>与ArrayList<String>就是同一个类,所以泛型技术实际上是Java语言的一颗语法糖,也称为泛型擦除。
最初由于泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响和新的需求,如在泛型类中如何获取传入的参数化类型等。因此JCP组织对虚拟机规范做出了相应的修改,引入了诸如Signature、LocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括参数化类型的信息。
由于Sinature属性的出现,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是能通过反射手段取得泛型化参数类型的根本依据。
1.4.2 自动装箱、拆箱与遍历循环
这部分直接上代码,由于编译期的不断优化和常量池属性的日趋完善(如LineNumberTable的Signature),书中关于反编译的例子可能不适合,这里直接贴字节码看看解语法糖的结果。代码如下:
public static void print(int... params) {
System.out.println(params);
}
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4);
Integer sum = 0;
for (int i : list) {
System.out.println(i);
}
print(sum);
}
示例代码中包含了自动装箱、自动拆箱、循环遍历、变长参数、泛型等,编译成字节码之后(主要贴出方法表中的Code和LocalVariableTypeTable):
可以看到,变长参数实际上是一个数组类型。然后再看看main方法:
遍历循环被解析成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。并且发生了自动装箱、自动拆箱等操作。
1.4.3 条件编译
Java进行条件编译的方法是使用条件为常量的if语句。比如以下代码:
public static void main(String[] args) {
if (true) {
System.out.println("123");
} else {
System.out.println("456");
}
}
被编译后,生成的字节码之中只包括sout(“123”);这条语句:
public static void main(String[] args) {
System.out.println("123");
}
Java中的条件编译会根据布尔常量值的真假把分支中不成立的代码块消除掉,这个工作在编译器解除语法糖阶段完成。另外,书中举了使用while搭配被拒绝编译的问题:
while(false){
sout("123");
}
通过对于if的条件编译,我们可以跳过编译器检查,比如以下代码:
public static void main(String[] args) {
return;
System.out.println("123");
}
会提示 Unreachable code,拒绝编译,但是我们加上条件编译:
public static void main(String[] args) {
if (true) {
return;
}
System.out.println("123");
}
就能顺利通过编译,最终生成一个空的方法。
1.5 嵌入式式注解处理器
书中给出了一个对Java程序命名进行检查的插入式注解处理器案例,代码比较简单,这里就不贴出来了。主要就是使用注解处理器API,继承抽象类AbstractProcessor,然后通过JDK提供的ElementScanner以Visitor模式访问抽象语法树中的元素,然后分别对Java类、方法和字段名进行命名检查。现在很多人使用的Lombok也是基于嵌入式注解处理器:根据已有元素生成新的语法树元素。






还没有评论,来说两句吧...