Java语言的编译器是一段不确定的操作过程,可能是讲Java文件转变为class文件的过程,也可能是指虚拟机的后端编译,讲字节码转换为机器码的过程,还肯是静态提前编译器直接讲Java文件编译为本地机器代码的过程。
- 前端编译器:
Sun的Javac
,Eclipse JDT中的增量式编译器ECJ
- JIT编译器:HotSpotVM的
C1
,C2
编译器 - AOT编译器:
GNU Compiler for the Java(GCJ)
,Excelsior JET
编译过程详解
Javac编译器是由Java语言编写的程序,Javac的编译过程可以大致分为三个过程:
- 解析与填充符号表过程
- 插入式注解的注解处理过程
- 分析与字节码生成过程
Javac编译动作的入口是com.sun.tools.Javac.main.JavaCompiler
类,上述三个过程的代码逻辑集中在这个类的compile()
和compile2()
方法里,整个编译最关键的处理就由图中标注的8个方法来完成,下面我们具体看一下这8个方法实现了什么功能。
解析与填充符号表过程
解析步骤由
parseFiles()
方法完成,解析步骤包括了词法分析和语法分析
词法分析与语法分析
词法分析:将源代码的字符流转变为标记(Token)集合,标记是编译过程中的最小元素,如:关键字,变量名,字面量和运算符都可以成为标记。int a = b + 2
→int、a、=、b、+、2
都是标记。在Java中词法分析过程由com.sun.tools.javac.parser.Scanner类来实现。注意不是java.lang.Scanner
语法分析:根据Token序列来构造抽象语法树的过程,抽象语法树:用来描述程序语言语法结构的树形表示方式。语法树的每一个节点都代表者程序代码中的一个语法结构。如:包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一个语法结构。
经过词法,语法分析后编译器基本就不会对源码文件进行操作了。后续的操作都建立在抽象语法树上。
填充符号表
将一组符号地址和符号信息构成表格,可以理解为K-V,也可以是有序符号表,树状符号表,在语义分析中,符号表所登记的内容将用于语义检查和产生中间代码,在目标代码生成阶段当对符号名进行地址分配时,可以通过符号表找到其地址。
在Javac源码中,填充符号表的过程由com.sun.tools.comp.Enter类实现。如果下载了OpenJDK源码的话具体目录为src\jdk.compiler\share\classes\com\sun\tools\javac\comp
注解处理器
我们使用的注解标准API其实可以算是一个编译器插件,有了编译器注解处理的标准API后,我们的代码才有可能干涉编译器的行为。
处理注解的流程
- 注解处理器的发现与初始化
- 编译器在编译过程中会通过META-INF/services/javax.annotation.processing.Processor文件找到所有可用的注解处理器。
- 调用每个处理器的init方法进行初始化。
- 注解处理轮次
- 编译器在多个轮次中调用注解处理器的process方法,每个轮次提供一组被注解标注的元素。
- 每个轮次中,处理器可以生成新的源代码文件,这些文件会在下一个轮次中被编译并再次处理。
- 处理结束时,RoundEnvironment.processingOver()返回true,表示所有注解处理完成。
- 注解处理逻辑
- 在process方法中,注解处理器会根据注解类型获取相应的元素(类、方法、字段等)。
- 处理器可以读取注解的值,执行逻辑,并使用Filer生成新的源代码、配置文件等。
- 生成代码和编译消息
- 使用Filer生成文件:
Filer filer = processingEnv.getFiler();
JavaFileObject fileObject = filer.createSourceFile("com.example.GeneratedClass");
Writer writer = fileObject.openWriter();
writer.write(generatedCode);
writer.close();
语义分析与字节码生成
语义分析
语义分析主要是检查代码逻辑和语法是否是符合程序规范
如下:
int a = 1;
boolean b = false;
char c = 2;
后续可能出现的赋值运算如下
int d = a + c;
int e = b + c;
char f = a + c;
因为a为int,b为boolean,c为char,所以a+c是正确的,但是b+c和a+c是错误的,因为boolean无法参与运算,a+c后类型进行了升级必须强转为char。所以检查这些语法是否错误就是语法分析干的事情。
语义分析又可以分标注检查和数据及控制流分析两个步骤
1.标注检查
标记检查步骤:
- 变量使用前是否已被声明
- 变量与赋值之间的数据类型是否能够匹配
- 常量折叠–>a = 1 + 2可以被折叠为a = 3
- 所以在程序运行时期a = 1 + 2并不会影响程序的效率和a=3是一样的
- 但是所谓常量之间的运算如10241024或者606024我们都可以通过阅读代码时能大概了解代码的含义,如单位为byte时10241024可以理解为MB,单位为秒时可606024可以理解为一天。
2.数据及控制流分析
是对程序上下文逻辑的进近一步验证,它可以检查出注入程序局部变量在使用前是否有赋值,方法的每条路径是否都有返回值,是否所有的检查异常都被正确处理等问题
解语法糖
Javac中解语法糖的过程由desugar()
方法触发,在com.sun.tools.javac.comp.TransTypes
类和com.sun.tools.javac.comp.Lower
类中完成。
字节码生成
此过程是javac编译过程的最后一个阶段由com.sun.tools.javac.jvm.Gen
完成。主要是将前面各个步骤所生成的信息转换为字节码写入磁盘中,编译器还进行少量代码添加和转换工作。如实例构造器和类构造器<clinit>()
方法就是这个阶段添加到语法树中的。
完成了对语法树的遍历和调整后,就会把填充了所有所需信息的符号表交到com.sun.tools.javac.jvm.ClassWriter
类手中,由这个类的writeClass()
方法输出字节码,生成最终的Class文件。
Javac解析语法糖
Java的语法糖有许多,但是这里挑重要的我们用的最多的举例
泛型与类型擦除
泛型本质是参数化类型的应用,将类型作为参数传递。最早出现在C++语言中。和C#不同的是,java语言的泛型规则是:只在源码中存在,在编译后的字节码文件中就已经被替换为原来的原生类型了。并且在相应的地方插入了强制类型转换的代码。
Java的伪泛型
根据上面的描述Java中的泛型更像是一种伪泛型,被称为类型擦除。
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("a", "a");
map.put("b", "b");
System.out.println(map.get("a"));
System.out.println(map.get("b"));
}
把这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了Jva泛型出现之前的写法,泛型类型都变回了原生类型,如下
public static void main(String[] args) {
Map map = new HashMap();
map.put("a", "a");
map.put("b", "b");
System.out.println((String) map.get("a"));
System.out.println((String) map.get("b"));
}
伪泛型的特殊
如下代码
public class Test {
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)");
}
}
因为编译后的类型擦除,所以导致不能编译,最直观的就是代码编辑器会提示如下
不仅是代码编辑器还有对于编译器来说,你两个方法进行了类型擦擦除后是一模一样的。但是当使用Sun JDK的java从编译器编译以下代码,却有运行结果。
public class Test {
public static void main(String[] args) {
method(new ArrayList<String>());
method(new ArrayList<Integer>());
}
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)");
}
}
结果
invoke method(List<String>list)
invoke method(List<Integer>list)
通过这里可以看出类型擦除并不是导致无法重载的全部原因。这是因为虽然返回值并不是方法的特征签名,但是在Class文件格式中,只要描述符不是完全一致的两个方法就可以共同存在。
但是在**49.0**
版本之后的虚拟机能够识别Signature参数,来解决在字节码层面给方法存储特征签名,保存了参数化类型的信息。
自动拆装箱和遍历循环
看下面这个例子就可以知道自动拆装箱、增强for、泛型、变长参数的本质
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<Integer> list = Arrays.asList(new Integer[] {
Integer.valueOf(1),
Integer.valueOf(2),
Integer.valueOf(3),
Integer.valueOf(4),
});
int sum = 0;
for (Iterator iterator = list.iterator() ; iterator.hasNext();) {
int i = ((Integer) iterator.next()).intValue();
sum += i;
}
System.out.println(sum);
}
条件编译
Java的if在编译期间就会被执行,如下
public static void main(String[] args) {
if (true) {
System.out.println(1);
} else {
System.out.println(2);
}
}
会被编译成
public static void main(String[] args) {
System.out.println(1);
}