Javac 用来把 .java 文件编译成 .class 文件,这个过程对代码的运行效率几乎没有任何优化措施,因为虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中,这样可以让那些不是由 Javac 产生的 Class 文件同样能享受到编译器优化措施带来的性能红利。
但 Javac 做了许多针对 Java 语言编码过程的优化来降低程序员的编码复杂度、提高编码效率。相当多新生的 Java 语法特性,都是靠编译器的语法糖来实现,而不是依赖字节码或 Java 虚拟机的底层改进来支持。
Javac 编译器
Javac 编译器是由 Java 语言写的,在 OpenJDK 源码的 src/share/classes/com/sun/tools/javac 路径下,我们新建一个项目,导入 javac 源码进行调试,启动入口为 com.sun.tools.javac.Main 的 main() 方法。
Javac 的编译过程大致分为以下几个过程:
- 初始化插入式注解处理器
- 解析与填充符号表
- 词法、语法分析,构造出抽象语法树
- 填充符号表,产生符号地址和符号信息
- 执行插入式注解处理器
- 分析与字节码生成
- 标注检查,对语法的静态信息进行检查
- 数据流及控制流分析,对程序动态运行过程进行检查
- 解语法糖,将简化代码编写的语法糖还原为原有的形式
- 字节码生成
在执行插入式注解处理器时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号。因此整体关系与交互顺序如下图所示:
我们把上述处理过程对应到代码中,Javac 编译动作的入口是 com.sun.tools.javac.main.JavaCompiler 类,上述过程的代码逻辑主要集中在这个类的 compile() 和 compile2() 方法里。
1. 解析与填充符号表
解析过程由上图中的 parseFiles() 方法来完成,主要包括词法分析和语法分析两个步骤。
词法分析是将源代码的字符流转变为标记(Token)集合的过程,标记是编译时的最小元素,关键字、变量名、字面量、运算符都可以作为标记,比如 int a=b+2 这句代码中就包含了 int、a、=、b、+、2 这六个标记。
语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(AST)是一种用来描述程序代码语法结构的树形表示方法,抽象语法树的每一个节点都代表着程序代码中的一个语法结构。例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。
经过词法和语法分析生成语法树后,编译器就不会再对源码字符流进行操作了,后续的操作都建立在抽象语法树之上。下一个阶段是对符号表进行填充的过程,由上图中的 enterTrees() 方法完成,符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构,符号表中所登记的信息在编译的不同阶段都要被用到。
2. 注解处理器
JDK 5 之后,Java 语言提供了对注解(Annotations)的支持,注解在设计上原本只会在程序的运行期间发挥作用。但在 JDK 6 中又提供了一组被称为 “插入式注解处理器” 的标准 API,可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。
我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止。
有了编译器注解处理的标准 API 后,程序员的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。只要有足够的创意,程序员能使用插入式注解处理器来实现许多原本只能在编码中由人工完成的事情。譬如著名的编码效率工具 Lombok,也是依赖插入式注解处理器来实现的。
在 Javac 源码中,插入式注解处理器的初始化过程是在 initPorcessAnnotations() 方法中完成的,而它的执行过程则是在 processAnnotations() 方法中完成。注解处理器的使用参考:
注解
3. 语义分析与字节码生成
经过语法分析后,编译器获得了程序代码的抽象语法树表示,抽象语法树能够表示一个结构正确的源程序,但无法保证源程序的语义是符合逻辑的。而语义分析的主要任务则是对结构上正确的源程序进行上下文相关性质的检查,主要包括标注检查、数据及控制流流检查等。
标注检查:
标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否匹配等。在标注检查中,还会顺便进行一个称为常量折叠(Constant Folding)的代码优化,比如我们定义了 int a=1+2,经过了常量折叠优化后,它们会被折叠为字面量 3,即 int a=3。
数据及控制流分析:
数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,它可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题。编译时期的数据及控制流分析与类加载时期的数据及控制流分析的目的基本上可以看作是一致的,但校验范围有所区别,有一些校验项只有在编译期或运行期才能进行。
解语法糖:
Java 中最常见的语法糖包括:泛型、变长参数、自动装箱拆箱等,Java 虚拟机运行时并不直接支持这些语法,它们在编译阶段会被还原回原始的基础语法结构,这个过程就称为解语法糖。
字节码生成:
字节码生成是 Javac 编译过程的最后一个阶段,字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。例如实例构造器 <init>() 方法和类构造器 <clinit>() 方法就是在这个阶段被添加到语法树中的,此外还会把通过 + 号进行字符串变量连接的操作转为更可取的 StringBuilder 的 append() 操作,避免每次操作都生成新对象。
Java 语法糖
1. 泛型
泛型的本质是 参数化类型(Parameterized Type)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的抽象能力。
但 Java 语言中的泛型只在程序源代码中存在,在编译后生成的字节码文件中,全部泛型都会被替换为原来的裸类型(Raw Type)了,并且在相应的地方插入了强制转型代码,因此对于运行期的 Java 语言来说,ArrayList
1.1 类型擦除
public static void main(String[] args) {
Map<String, String> map = new HashMap<>();
map.put("hello", "你好");
map.put("how are you?", "吃了没?");
System.out.println(map.get("hello"));
System.out.println(map.get("how are you?"));
}
使用 Javap 命令查看这段 Java 代码的字节码指令,将会发现泛型都不见了,程序又变回了 Java 泛型出现之前的写法,泛型类型都变回了裸类型,只在元素访问时插入了从 Object 到 String 的强制转型代码。这么做主要是为了兼容引入泛型之前的代码。
当然,并不是每一个泛型参数被擦除类型后都会变成 Object 类。对于限定了继承类的泛型参数,经过类型擦除后,所有的泛型参数都将变成所限定的继承类。也就是说,Javac 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的类。
class GenericTest<T extends Number> {
T foo(T t) {
return t;
}
}
编译后的字节码:
不过,字节码中仍存在泛型参数的信息,如方法声明里的 T foo(T); 以及方法签名(Signature)中的 (TT;)TT;。这类信息主要由 Java 编译器在编译他类时使用。
1.2 类型擦除的缺陷
首先,使用擦除法实现泛型直接导致了对原始类型(Primitive Types)数据的支持又成了新的麻烦,譬如将上面的代码改成原始类型的泛型:
// 目前不支持这么写
ArrayList<int> list = new ArrayList<>();
ArrayList<long> list = new ArrayList<>();
这种情况下,一旦把泛型信息擦除后,到要插入强制转型代码的地方就没法往下做了,因为不支持 int、long 与 Object 之间的强制转型。而 Java 给出的解决方案非常简单粗暴:既然没法转换,那就索性不支持原生类型的泛型,当遇到原生类型时自动进行装箱、拆箱。这个决定后面导致了无数构造包装类和装箱、拆箱的开销,成为 Java 泛型慢的重要原因。
其次,运行期无法取到泛型类型信息,会让一些代码变得相当啰嗦。比如我们写一个泛型版本的从 List 到数组的转换方法,由于不能从 List 中取得参数化类型 T,所以不得不从一个额外参数中再传入一个数组的组件类型进去,实属无奈。
public static <T> T[] convert(List<T> list, Class<T> componentType) {
T[] array = (T[])Array.newInstance(componentType, list.size());
...
}
1.3 桥接方法
泛型的类型擦除还为方法重写带来了问题,如下代码示例:
class Merchant<T extends Customer> {
public double actionPrice(T customer) {
return 0.0d;
}
}
class VIPOnlyMerchant extends Merchant<VIP> { // VIP继承了Customer
@Override
public double actionPrice(VIP customer) {
return 0.0d;
}
}
上面的代码经过类型擦除后,父类的方法描述符为 (LCustomer;)D,而子类的方法描述符为 (LVIP;)D。这显然不符合 Java 虚拟机关于方法重写的定义。为了保证编译而成的 Java 字节码能够保留重写的语义,Java 编译器额外添加了一个桥接方法。该桥接方法在字节码层面重写了父类的方法,并将调用子类的方法。
VIPOnlyMerchant 编译后的字节码如下图所示:
从图中可以看到,VIPOnlyMerchant 类中包含一个桥接方法 actionPrice(Customer),它重写了父类的同名同方法描述符的方法。该桥接方法将传入的 Customer 参数强制转换为 VIP 类型,再调用原本的 actionPrice(VIP) 方法。因此这个桥接方法相当于:
public double actionPrice(Customer customer) {
return actionPrice((VIP) customer);
}
当一个声明类型为 Merchant,实际类型为 VIPOnlyMerchant 的对象,调用 actionPrice 方法时,字节码里的符号引用指向的是 Merchant.actionPrice(Customer) 方法。Java 虚拟机将动态绑定至 VIPOnlyMerchant 类的桥接方法之中,并且调用其 actionPrice(VIP) 方法。
需要注意的是,在 javap 的输出中,该桥接方法的访问标识符除了代表桥接方法的 ACC_BRIDGE 之外,还有一个 ACC_SYNTHETIC,表示该方法对于 Java 源代码来说是不可见的。当你尝试通过传入一个声明类型为 Customer 的对象作为参数,调用 VIPOnlyMerchant 类的 actionPrice 方法时,Java 编译器会报错,并且提示参数类型不匹配。
1.4 总结
由于 Java 泛型的引入,为了在泛型类中获取传入的参数化类型等。所以《Java 虚拟机规范》做出了相应的修改,引入了诸如 Signature、LocalVariableTypeTable 等新的属性用于解决伴随泛型而来的参数类型的识别问题,Signature 是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息。
示例代码:
public class ParameterizedTypeTest<V> {
private final V t;
public ParameterizedTypeTest(V t) {
this.t = t;
}
}
通过 Javap 命令输出该代码的字节码指令:
Signature 表示的含义为:T 表示该方法的参数是一个泛型类型,其泛型名称是 V,方法返回值是 void。
从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过反射获取参数化类型的根本依据。
2. 自动装箱、拆箱与遍历循环
Java 语言拥有 8 个基本类型,每个基本类型都有对应的包装(wrapper)类型。之所以需要包装类型,是因为许多 Java 核心类库的 API 都是面向对象的。
举个例子,Java 核心类库中的容器类就只支持引用类型。当需要一个能够存储数值的容器类时,我们往往定义一个存储包装类对象的容器。对于基本类型的数值来说,我们需要先将其转换为对应的包装类,再存入容器之中。在 Java 程序中,这个转换可以是显式也可以是隐式的,后者正是 Java 中的自动装箱。
我们通过下面的代码来看下这些语法糖在编译之后是什么样的:
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 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);
}
由代码可知,自动装箱、拆箱在编译之后被转化成了对应的包装和还原方法,如本例中的 Integer.valueOf() 与 Integer.intValue() 方法,而遍历循环则是把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现 Iterable 接口的原因。
3. try-with-resource
在 Java 7 之前,对于打开的资源,我们需要定义一个 finally 代码块,来确保该资源在正常或者异常执行状况下都能关闭。资源的关闭操作本身容易触发异常。因此,如果同时打开多个资源,那么每一个资源都要对应一个独立的 try-finally 代码块,以保证每个资源都能够关闭。这样代码将会变得十分繁琐。
Java 7 的 try-with-resources 语法糖,极大地简化了上述代码。程序可以在 try 关键字后声明并实例化实现了 AutoCloseable 接口的类,编译器将自动添加对应的 close() 操作。
在声明多个 AutoCloseable 实例的情况下,编译生成的字节码类似于上面手工编写代码的编译结果。与手工代码相比,try-with-resources 还会使用 Suppressed 异常的功能,来避免原异常“被消失”。
public class Foo implements AutoCloseable {
private final String name;
public Foo(String name) { this.name = name; }
@Override
public void close() {
throw new RuntimeException(name);
}
public static void main(String[] args) {
try (Foo foo0 = new Foo("Foo0"); // try-with-resources
Foo foo1 = new Foo("Foo1");
Foo foo2 = new Foo("Foo2")) {
throw new RuntimeException("Initial");
}
}
}
// 运行结果:
Exception in thread "main" java.lang.RuntimeException: Initial
at Foo.main(Foo.java:18)
Suppressed: java.lang.RuntimeException: Foo2
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
Suppressed: java.lang.RuntimeException: Foo1
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
Suppressed: java.lang.RuntimeException: Foo0
at Foo.close(Foo.java:13)
at Foo.main(Foo.java:19)
4. foreach
foreach 循环允许 Java 程序在 for 循环里遍历数组或 Iterable 对象。对于数组来说,foreach 循环将从 0 开始逐一访问数组中的元素,直至数组的末尾。其等价的代码如下面所示:
public void foo(int[] array) {
for (int item : array) {
}
}
// 等同于
public void bar(int[] array) {
int[] myArray = array;
int length = myArray.length;
for (int i = 0; i < length; i++) {
int item = myArray[i];
}
}
对于 Iterable 对象来说,foreach 循环将调用其 iterator 方法,并且用它的 hasNext 以及 next 方法来遍历该 Iterable 对象中的元素。其等价的代码如下面所示:
public void foo(ArrayList<Integer> list) {
for (Integer item : list) {
}
}
// 等同于
public void bar(ArrayList<Integer> list) {
Iterator<Integer> iterator = list.iterator();
while (iterator.hasNext()) {
Integer item = iterator.next();
}
}
5. 其他
- 内部类
- 枚举类
- 断言语句
- 数值字面量
- 对枚举和字符串的 switch 支持
- Lambda 表达式(Lambda 不能算是单纯的语法糖,但在前端编译器中做了大量的转换工作)