Java技术下讨论“编译期”需要结合具体上下文语境,因为它可能存在很多种情况:
前端编译器(叫“编译器的前端”更准确一些)把==*.java文件转变成*.class文件==的过程
JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)
即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程
HotSpot虚拟机的C1、C2编译器,Graal编译器
提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程
JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET 。
本章标题中的“前端”指的是由前端编译器完成的编译行为,对于前端编译优化,有以下说法:
前端编译器对代码的运行效率几乎没有任何优化措施可言
Java虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中
这样可以让那些不是由Javac产生的Class文件也同样能享受到编译器优化措施所带来的性能红利
相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者Java虚拟机的底层改进来支持。
Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;
前端编译器在编译期的优化过程,支撑着程序员的编码效率和语言使用者的幸福感的提高
从Javac源代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示:
准备过程:初始化插入式注解处理器
解析与填充符号表过程,包括:
插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段
分析与字节码生成过程,包括:
对于以上过程:执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转 回到之前的解析、填充符号表的过程中重新处理这些新符号
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KKk48wI2-1658907802375)(D:\note\笔记仓库\图片\image-20220727105014251.png)]](https://1000bd.com/contentImg/2022/07/31/213436850.png)
整个编译过程主要的处理由图中标注的8个方法来完成
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IlHuf8qp-1658907802376)(D:\note\笔记仓库\图片\image-20220727105045637.png)]](https://1000bd.com/contentImg/2022/07/31/213437131.png)
词法分析:词法分析是将源代码的字符流转变为标记(Token)集合的过程。
单个字符是程序编写时的最小元素
标记才是编译时的最小元素
关键字、变量名、字面量、运算符都可以作为标记
如“int a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2
在Javac的源码中,词法分析过程由 com.sun.tools.javac.parser.Scanner类来实现。
语法分析:语法分析是根据标记序列构造抽象语法树的过程
抽象语法树:抽象语法树(Abstract Syntax Tree,AST)是一 种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构
包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。
抽象语法树可通过Eclipse AST View插件查看,抽象语法树是以com.sun.tools.javac.tree.JCTree 类表示的
经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都建立在抽象语法树之上
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fcEFwqtU-1658907802376)(D:\note\笔记仓库\图片\image-20220727110009066.png)]](https://1000bd.com/contentImg/2022/07/31/213437445.png)
符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构(可以理解成哈希表中的键值对的存储形式)
符号表中所登记的信息在编译的不同阶段都要被用到:
在Javac源代码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,该过程的产出物是一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果 存在的话)的顶级节点
JDK 5之后,Java语言提供了对注解的支持,注解在设计上原本是与普通的Java代 码一样,都只会在程序运行期间发挥作用的。
但在JDK 6之后,设计了一组称为**“插入式注解处理器”**的标准API。可以==提前至编译期对代码中的特定注解进行处理, 从而影响到前端编译器的工作过程==。
注解处理器的工作流程:
可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round),这也就对应着图上图的那个回环
有了编译器注解处理的标准API后,程序员的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。
程序员能使用插入式注解处理器来实现许多原本只能在编码中由人工完成的事情:例如Java著名的编码效率工具Lombok,就是利用了这一点。
语义分析的主要任务是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等等。
常量折叠优化:Javac编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)
例如:如下的变量定义:
int a = 1 + 2
则在抽象语法树上仍然能看到字面量==“1”“2”和操作符“+”号==,但是在经过常量折叠优化之后,它们将会被折叠为字面量“3”
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W9uQcQCA-1658907802377)(D:\note\笔记仓库\图片\image-20220727124743414.png)]](https://1000bd.com/contentImg/2022/07/31/213437819.png)
数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,与类加载时的数据与控制流分析的目的基本一致。
可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。
例如:常量的不变性就是利用编译器数据及控制流分析来实现的(局部常量和普通变量编译出来的字节码是没有区别的,在编译期通过编译期去识别final标志)
语法糖:指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响, 但是却能更方便程序员使用该语言。
引入语法糖的优点以及缺点
Java中最常见的语法糖包括了泛型、变长参数、自动装箱拆箱,等等。
解语法糖:Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖。
字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。
实例构造器
注意:
如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、可访问性(public、protected、private或)与当前类型一致的默认构造函数,这个工作在填充符号表阶段中就已经完成。
完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交到 com.sun.tools.javac.jvm.ClassWriter类手上,由这个类的**writeClass()**方法输出字节码,生成最终的Class 文件,到此,整个编译过程宣告结束
Java中泛型的本质是参数化类型或者参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数。
Java选择的泛型实现方式叫作“类型擦除式泛型”:Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型了,并且在相应的地方插入了强制转型代码。
因此对于运行期的Java语言来说,ArrayList
C#选择的泛型实现方式是“具现化式泛型“:C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的
Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛型
Java的类型擦除式泛型的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java 5.0之上。
当时设计者们在泛型的设计上大体有两种方式可以选择:
考虑到遗留代码规模的问题(当时java已有10年历史,而C#只有两年的历史),java选择了第二种方式,C#选择了第一种方式。
“裸类型”(Raw Type)的概念:裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type)
代码清单10-4 裸类型赋值
ArrayList<Integer> ilist = new ArrayList<Integer>();
ArrayList<String> slist = new ArrayList<String>();
ArrayList list; // 裸类型
list = ilist;
list = slist;
如何实现裸类型?
直接在编译时把ArrayList
代码清单10-5是一段简单的Java泛型例子,我们可以看一下它编译后的实际样子是怎样的:
代码清单10-5 泛型擦除前的例子
public static void main(String[] args) {
Map<String, String> map = new HashMap<String, String>();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}
把这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了
public static void main(String[] args) {
Map map = new HashMap();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println((String) map.get("hello"));
System.out.println((String) map.get("how are you?"));
}
代码清单10-7 原始类型的泛型(目前的Java不支持)
ArrayList<int> ilist = new ArrayList<int>();
ArrayList<long> llist = new ArrayList<long>();
ArrayList list;
list = ilist;
list = llist;
因为不支持int、long与Object之间的强制转型,所以答案是不能的
java给出的解决方案是:
代码清单10-9 当泛型遇见重载1
public class GenericTypes {
public static void method(List<String> list) {
System.out.println("invoke method(List list)" );
}
public static void method(List<Integer> list) {
System.out.println("invoke method(List list)" );
}
}
![[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aNoDGlkl-1658907802378)(D:\note\笔记仓库\图片\image-20220727153442683.png)]](https://1000bd.com/contentImg/2022/07/31/213438113.png)
这段代码是不能被编译的,因为参数List
由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响并带来新的需求,所以JCP组织对《Java虚拟机规范》做出了相应的修改:引入了诸如Signature、LocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型的识别问题:
Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名
这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息
从Signature属性的出现我们还可以得出结论:擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。
修改后的虚拟机规范要求所有能识别49.0(JDK 5)以上版本的Class文件的虚拟机都要能正确地识别Signature参数。
代码清单10-11 自动装箱、拆箱与遍历循环
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);
}
代码清单10-12 自动装箱、拆箱与遍历循环编译之后
public static void main(String[] args) {
List 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;
}
System.out.println(sum);
}
代码清单10-11中一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数5种语法糖,代码 清单10-12则展示了它们在编译前后发生的变化。
Java语言天然的编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)就无须使用到预处理器。
代码清单10-14 Java语言的条件编译
public static void main(String[] args) {
if (true) {
System.out.println("block 1");
} else {
System.out.println("block 2");
}
}
该代码编译后Class文件的反编译结果:
public static void main(String[] args) {
System.out.println("block 1");
}
只能使用条件为常量的if语句才能达到上述效果:根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉