1.概述
在没有上下文的情况下谈 Java 的编译期是一种模糊的表述。它包含三种类型的编译:
- 前端编译器:把 .java 文件编译成 .class 文件的过程。代表性编译器产品:JDK 的 Javac。
- 即时编译器:运行期把字节码转变成本地机器码的过程(JIT),代表性编译器产品:HostSpot 的 C1、C2、Graal 编译器。
- 提前编译器:直接把程序编译成与目标机器指令集相关的二进制代码的过程。代表性编译器:JDK 的 Jaotc。
我们通常讲的编译多数是指前端编译期。Java 虚拟机团队把对性能的优化全部集中到运行期的即时编译器中,这样其他语言(JRuby、Groovy等)产生的 Class 文件也能享受到编译器优化带来的优势。
2.Javac编译器
Javac 的编译过程大致可以分为 1 个准备过程和 3 个处理过程:
- 1.准备过程:初始化插入式注解处理器。
- 2.解析与填充符号表过程:
-
- 词法、语法分析:将源代码的字符流转变为标记集合,构造出抽象语法树。
-
- 填充符号:产生符号地址和符号信息。
-
- 3.插入式注解处理器的注解处理器执行阶段。
- 4.分析与字节码生成过程:
-
- 标注检查:对语法和静态信息进行检查。
-
- 数据流及控制流分析:对程序动态运行过程进行检查。
-
- 解语法糖:将简化代码编写的语法糖还原为原有的形式。
-
- 字节码生成:将前面各个步骤所生成的信息转化为字节码。
-
在上面的 3 个处理过程中,如果 第 3 步执行插入式注解时可能会产生新的符号,如果产生新的符号,就会转会到第 2 步解析与填充符号过程。当所有的插入式注解执行完成后,从就会进行分析和生成字节码。
2.2解析与填充符号表
2.1.1词法、语法分析
词法分析是将源代码的字符流转变为 token 集合。单个字符是程序编写时的最小元素,但是 token 才是编译时的最小元素。例如:int a = b + 2,这句代码中包含 6 个 token : int、a、=、b、+、2 。 int 是作为一个 token 不可再拆分。
语法分析是根据 token 的序列,构造抽象语法树的过程。在 idea 中可以通过 JDT ASTView 插件来查看构建出来的语法树。
2.1.2填充符号
完成了语法分析和词法分析后,下一步就是对符号表进行填充。符号表是由一组符号地址和符号信息构成的数据结构,符号表中的信息在编译的不同阶段要被用到,比如在语义分析时,符号表所登记的内容将会用于语义检查和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。
2.3注解处理器
在 JDK 5 之后, Java 提供了对注解的支持。但是注解只会在程序运行期发挥作用。但是 JKD 6 之后,提出的插入式注解处理器,使得可以提前到编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。比如著名的 Lombok 它通过注解来实现自动产生 getter/setter 方法、进行空置检查、生成受检异常、产生 equals 和 hashCode 方法,从而简化代码。
2.4语义分析与字节码生成
语法分析后,编译器获得了程序代码的抽象语法树,但是语法树只能保证源程序的结构正确,但是无法保证源程序的语义正确。语义分析主要是对源程序进行上下文相关性质的检查。比如:类型检查、控制流检查、数据流检查等等。
2.4.1标注检查
语义分析可分为标注检查和数据及控制流分析两个步骤。
标注检查主要检查的包括:变量使用前是否已经被声明、变量与赋值之间的数据类型是否能匹配等。在标注检查中还会顺便进行常量折叠优化,这是 Javac 编译器对源代码做的优化,例如:int a = 1 + 2 这变量经折叠优化后,它的字面量将会是 3 。
2.4.2数据及控制流分析
数据及控制流分析主要检查程序局部变量使用前是否由赋值、方法的每条路径是否都有返回值、是否所有的受检异常都被正确处理了等问题。编译器的数据及控制流分析与类加载时的数据及控制流分析的目的基本上时一致的,但是校验范围有区别,有一些校验项只有在编译器或者运行期才能进行。
public class Main {
public void foo(final int arg) {
}
public void foo(int arg) {
}
}
上面两个 foo 方法,第一个方法在编译器时, arg 参数被 final 修饰,编译时肯定不能被修改。但是如果编译成字节码后会发现它们的字节码一模一样。因为参数被放在局部变量表里,没有访问标志。由此可以推断 final 只在编译器生效,在运行期完全没有影响。
2.4.3解语法糖
语法糖是指在计算机语言中添加的某种语法,这种语法对编译结果和功能没有实际影响,但是却能方便程序员使用该语言。通常来说,语法糖能减少代码量、增加程序可读性,从而减少程序代码出错的机会。
Java 在现代编程语言中已经属于低糖语言了。也就是比较啰嗦。Java 常见的语法糖:泛型、变长参数、自动装拆箱等。这些语法不被 JVM 直接支持,而是在编译阶段还原返回基础的语法结构,这个过程叫做解语法糖。
2.4.4字节码生成
字节码生成过程会把前面生成的信息(语法树、符号表)转化成字节码指令写入到磁盘中,同时还会添加少量的代码。
需要添加的代码比如:实例构造器 <init>() 方法和类构造器 <clinit>() 方法。这里的实例构造器不等同于默认构造函数,如果用户代码没有提供构造函数,编译器会添加一个没有参数的、访问性与当前类一致的默认构造函数,这个工作是在填充符号表阶段完成的。
<init>() 和 <clinit>() 构造器的作用是把代码块、变量初始化、调用父类的实例构造器等操作收敛到 <init>() 和 <clinit>() 方法中,并且保证无论源码中出现的顺序如何,都一定是按先执行父类实例构造器,然后初始化变量,最后执行语句块的顺序进行。
3.Java 语法糖
几乎所有的编程语言都或多或少的有一些语法糖,语法糖不能提供性能上的改进,但是可以提高效率,或提升语法的严谨性,或减少编程出错的机会。坏处是容易让程序员无法看清语法糖背后程序代码的真实面目。
3.1泛型
泛型的本质是参数化类型,或参数化多态的应用。即将数据类型指定为方法签名中的一种特殊参数。
3.1.1Java 与 C# 的泛型
Java 选择的泛型实现方式叫做"类型擦除式泛型". C# 选择的泛型实现方式是"具现化式泛型". C# 的泛型在程序源码、编译后的中间语言、运行期都是切实存在的,List<int> 和 List<string> 是两种不同的类型。Java 中的泛型只存在源码中,在编译后的字节码中泛型全部被替换成裸类型。对于运行期的 Java 来说,ArrayList<int> 与 ArrayList<String> 其实是同一个类型。
Java 泛型的实现方式对编码的影响如下:
public class TypeErasureGenerics<E> {
public void doSomething(Object item) {
if (item instanceof E) {} // 不合法,无法对泛型进行实例判断 ...
E newItem = new E(); // 不合法,无法使用泛型创建对象
E[] itemArray = new E[10]; // 不合法,无法使用泛型创建数组
}
}
Java 泛型的实现对 Java 的执行性能影响也很大,因为对于泛型类型 Java 需要不厌其烦的装箱拆箱。如果要避免装箱拆箱的影响就必须构造一个数据类型相关的容器类,比如:IntFloatHashMap,但是这样就丧失了泛型本身存在的价值。
Java 的类型擦除式泛型在使用效果和运行效率上,都落后于 C# 的具现化式泛型。它的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型只需要在 Javac 编译器上做出改进即可,不需要改动字节码、不需要改动 Java 虚拟机,也可以保证以前没有使用泛型的库可以直接运行在 Java 5 之上。
3.1.2泛型的实现选择
由于 Java 中的数组支持协变,对应的集合类可以存入不同类型的元素,如下:
Object[] array = new String[10];
array[0] = 10; // 编译期不报错,运行时会报错。
ArrayList list = new ArrayList();
list.add(Integer.valueOf(10)); // 编译、运行时都不会报错。
list.add("hello");
为了保证上面这些代码编译出来的 Class 文件在 Java 5 引入泛型后能继续运行,有两条设计思路:
- 需要泛型化的类型,以前有的保持不变,然后平行的添加一套泛型化版本的新类型。
- 直接把已有的类型泛型化,让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类的泛型版本。
C# 选择了第一种方式,添加一套平行的新类型。而 Java 则选择了第二种方式,直接将已有的类型泛型化。Java 做出这样的选择是因为它已经有一套新老不同的集合类了,Vector -> ArrayList , HashTable -> HashMap。
3.1.3类型擦除
Java 选择了将已有的类型泛型化,例如:让已有的 ArrayList 直接变成了 ArrayList<T>,为了保证 ArrayList<T> 和 ArrayList 能共用同一个容器,就必须让泛型类成为原来没有泛型类的子类型,否则类型转换就是不安全的,由此引出了裸类型。裸类型应该视为所有该类型泛化实例的共同父类型。
对于裸类型如何实现又有两种选择:一种是在运行期由 Java 虚拟机来自动的、真实的构造出 ArrayList<Integer> 这样的类型,并且自动实现从 ArrayList<Integer> 派生自 ArrayList 的继承关系来满足裸类型的定义;另外一种是直接在编译时把 ArrayList<Integer> 还原成 ArrayList ,只在元素访问、修改时自动插入一些强制类型转换和检查指令。
下面的例子展示将带有泛型的代码编译后再反编译,泛型信息丢失,并且在访问数据的时候插入了强制类型转换。
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?"));
}
编译成 Class 文件后,反编译成 java 文件后:
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?"));
}
下面一个例子展示擦除式泛型对原始类型支持的影响:
ArrayList<int> ilist = new ArrayList<int>();
ArrayList<long> llist = new ArrayList<long>();
ArrayList list; list = ilist; list = llist;
对于上面的代码,在把泛型信息擦除掉后,进行获取时,没有办法进行强制类型转换,因为原始类型 int、long 无法强转为 Object 类型。Java 简单粗暴的放弃了对原始类型的泛型支持,因此只能用 ArrayList<Integer>、ArrayList<Long> 从而导致后续很多的构造包装类和装箱、拆箱,这也是 Java 泛型慢的重要原因。
下面一个例子展示由于运行期取不到泛型信息,我们去实现一个 List 到数组的转换时,不得不额外传递一个数组的组件类型进去:
public static <T> T[] convert(List<T> list, Class<T> compomentType){
T[] array = (T[])Array.newInstance(componmentType, list.size());
}
下面的代码展示泛型擦除对方法签名的影响:
public class GenericTypes {
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)");
}
}
由于泛型擦除,上面两个方法的签名相同,导致上面的代码无法正确编译。
public class GenericTypes {
public static String method(List<String> list) {
System.out.println("invoke method(List<String> list)");
return "";
}
public static int method(List<Integer> list) {
System.out.println("invoke method(List<Integer> list)");
return 1;
}
}
上面的代码加上返回值后,又能正确的进行编译调用了,这是对 Java 语言返回值不参与重载的挑战。因为方法重载要求方法具有不同的特征签名,而返回值不包含在方法签名中,所以返回值不参与方法重载,但是在 Class 文件格式中,只要是描述符不是完全一致的两个方法就可以共存。这是上面代码能正常运行的原因。
上面的例子展示了擦除法对实际编码带来的不良影响,同时也可以得出结论,擦除法仅仅对方法的 Code 属性中的字节码进行了擦除,实际上元数据还是保留了泛型信息,这也是我们能通过反射获取到参数化类型的原因。
3.2自动装箱、拆箱与循环遍历
下面用代码来演示泛型、自动装箱、拆箱、遍历和变长参数语法糖。
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);
}
下面的代码展示了上面代码中的语法糖被编译后的变化,其中泛型被擦除,用 Integer.valueOf() 与 Integer.intValue() 方法进行装拆箱,循环被编译成迭代器,变长参数被编译成数组类型参数。
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);
}
下面的例子展示自动装拆箱的常见陷阱:
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;
System.out.println(c == d);
/**
* true
* 1. 对于 Integer c = 3; 会触发 Integer valueOf(int i) 装箱。
* 2. 对于 Integer 对 -128 ~ 127 的值进行了缓存,进行 == 比较时,实际上比较的同一个缓存对象,所以返回 true。
* 3. Long 的情况同上。
*/
System.out.println(e == f);
/**
* false
* 1. 对于 Integer e = 321; 会触发 Integer valueOf(int i) 装箱。
* 2. 对于 Integer 对 -128 ~ 127 的值进行了缓存,对于超过这个范围的值进行 == 比较时,装箱后分别属于两个不同的对象。所以返回 false。
* 3. Long 的情况同上。
*/
System.out.println(c == (a + b));
/**
* true
* 1. 对于 (a + b) 的结果是返回基本类型。
* 3. == 任意一边的值是基本类型,都会触发另一边数据拆箱比较。
*/
System.out.println(c.equals(a + b));
/**
* true
* 1. 对于 Integer 的 equals 方法,首先判断参数的类型,如果类型不一致,返回 false。如果参数类型一致,然后对参数值进行拆箱后 == 比较。
* 2. 在这个例子中,首先判断 (a + b) 的类型与 c 类型都是 Integer, 然后对 (a + b) 拆箱与 c 的值比较,返回true。
*/
System.out.println(g == (a + b));
/**
* true
* 1. 对于 (a + b) 返回基本类型 int,然后被强转为 long 类型。
* 2. 由于 == 有一边是基本类型,导致 g 被拆箱比较,返回 true。
*/
System.out.println(g.equals(a + b));
/**
* false
* 1. 对于 (a + b) 返回基本类型 int,在 equals 方法作为参数,被装箱。
* 2. 在 Long 的 equals 方法,首先判断参数的类型,如果类型不一致,返回 false。如果参数类型一致,然后对参数值进行拆箱后 == 比较。
* 3. 在这个例子中, (a + b) 被装箱为 Integer, equals 返回 false。
*/
Integer aa = new Integer(10);
Integer bb = new Integer(10);
int cc = 10;
System.out.println(aa == bb);
/**
* false
* 1. aa 与 bb 是两个不同的对象,返回 false。
*/
System.out.println(aa == cc);
/**
* true
* 1. == 一边为基本类型,触发另一边拆箱比较。
*/
}
3.3条件编译
Java 也可以使用条件编译,最常见的条件编译时条件为常量的 if 语句,如下面的代码,编译后后的字节码只有 System.out.println("block 1"); 一条语句,其他的代码不会被编译。
public static void main(String[] args) {
if (true) {
System.out.println("block 1");
} else {
System.out.println("block 2");
}
}
下面的代码在条件编译时,控制流分析会提示报错,拒绝编译:
public static void main(String[] args) {
// 编译器将会提示“Unreachable code”
while (false) {
System.out.println("");
}
}
Java 的条件编译的实现也是一颗语法糖,根据布尔常量值来把不成立的代码块去掉,所以这种基于 Java 语法的条件编译,只能在方法体内部,是语句块级别的条件编译,无法实现根据条件调整整个 Java 类的结构。
Java 的语法糖除了泛型、自动装箱、拆箱、循环遍历、变长参数、条件编译之外。还有:内部类、枚举类、断言语句、数值字面量、对枚举和字符串的 switch 支持、try 语句中定义和关闭资源等等。Lamdba 不能算是单纯的语法糖。