07-前端编译与优化(待补充)
1. 概述
在 Java 技术下谈“编译期”而没有具体上下文语境的话,其实是一句很含糊的表述:
- 因为它可能是指一个前端编译器(叫“编译器的前端”更准确一些)把
*.java
文件转变成*.class
文件的过程; - 也可能是指 Java 虚拟机的即时编译器(常称 JIT 编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程;
- 还可能是指使用静态的提前编译器(常称 AOT 编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程。
下面笔者列举了这 3 类编译过程里一些比较有代表性的编译器产品:
- 前端编译器:JDK 的 Javac、Eclipse JDT 中的增量式编译器(ECJ)。
- 即时编译器:HotSpot 虚拟机的 C1、C2 编译器,Graal 编译器。
- 提前编译器:JDK 的 Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET。
这 3 类过程中最符合普通程序员对 Java 程序编译认知的应该是第 1 类,本章标题中的“前端”指的也是这种由前端编译器完成的编译行为。
2. Javac 编译器
2.1 Javac的源码与调试
2.2 解析与填充符号表
2.3 注解处理器
2.4 语义分析与字节码生成
3. 语法糖
Java 虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器中,这样可以让那些不是由 Javac 产生的 Class 文件(如 JRuby、Groovy 等语言的 Class 文件)也同样能享受到编译器优化措施所带来的性能红利。
但是,如果把“优化”的定义放宽,把对开发阶段的优化也计算进来的话,Javac 确实是做了许多针对 Java 语言编码过程的优化措施来降低程序员的编码复杂度、提高编码效率。相当多新生的 Java 语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者 Java 虚拟机的底层改进来支持。
我们可以这样认为,Java 中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;而前端编译器在编译期的优化过程,则是支撑着程序员的编码效率和语言使用者的幸福感的提高~
注意,以下代码的分析,借助了 javap 工具、idea 的反编译功能、idea 插件 jclasslib 等工具。另外,编译器转换的结果直接就是 class 字节码,只是为了便于阅读,给出了几乎等价的 Java 源码方式,并不是编译器还会转换出中间的 Java 源码!
3.1 默认构造器
public class Candy1 {}
编译成 class 后的代码:
public class Candy1 {
// 这个无参构造是编译器帮助我们加上的
public Candy1() {
super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."<init>":()V
}
}
3.2 自动拆装箱
这个特性是 JDK 5 开始加入的:
public class Candy2 {
public static void main(String[] args) {
Integer x = 1;
int y = x;
}
}
这段代码在 JDK 5 之前是无法编译通过的,必须改写为如下格式:
public class Candy2 {
public static void main(String[] args) {
Integer x = Integer.valueOf(1);
int y = x.intValue();
}
}
显然之前的版本太麻烦了,需要在基本类型和包装类型之间来回转换(尤其是集合类中操作的都是包装类型),因此这些转换的事情再 JDK 5 以后都由编译器再编译阶段完成(即 code-1 会在编译阶段转换成 code-2)。
3.3 泛型集合取值
泛型也是在 JDK 5 开始加入的特性,但 Java 在编译泛型代码后会执行「泛型擦除」的动作,即泛型信息在编译为字节码之后就丢失了,实际的类型都当做了 Object 类型来处理:
public class Candy3 {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
list.add(10); // 实际调用的是 List.add(Object e)
Integer x = list.get(0); // 实际调用的是 Object obj = List.get(int index);
}
}
所以在取值时,编译器在真正生成字节码时,还要额外做一个类型转换的操作:
// 需要将 Object 转为 Integer
Integer x = (Integer) list.get(0);
如果前面的 x 变量类型修改为 int 基本类型那么最终生成的字节码:
// 需要将 Object 转为 Integer, 并执行拆箱操作
int x = ((Integer) list.get(0)).intValue();
擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 仍然保留了方法参数泛型的信息:
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
0: new #2 // class java/util/ArrayList
3: dup
4: invokespecial #3 // Method java/util/ArrayList."<init>":()V
7: astore_1
8: aload_1
9: bipush 10 // <- [装箱] -v
11: invokestatic #4 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
14: invokeinterface #5, 2 // InterfaceMethod java/util/List.add:(Ljava/lang/Object;)Z
19: pop // 这步是把返回值出栈:boolean add(E e)
20: aload_1
21: iconst_0
22: invokeinterface #6, 2 // InterfaceMethod java/util/List.get:(I)Ljava/lang/Object;
27: checkcast #7 // class java/lang/Integer
30: astore_2
31: return
LocalVariableTable:
Start Length Slot Name Signature
0 32 0 args [Ljava/lang/String;
8 24 1 list Ljava/util/List;
31 1 2 x Ljava/lang/Integer;
LocalVariableTypeTable:
Start Length Slot Name Signature
8 24 1 list Ljava/util/List<Ljava/lang/Integer;>;
使用反射,仍然能够获得这些信息:
public Set<Integer> test(List<String> list, Map<Integer, Object> map) {}
// =========================================================================
Method test = Candy3.class.getMethod("test", List.class, Map.class);
Type[] types = test.getGenericParameterTypes();
for (Type type : types) {
if (type instanceof ParameterizedType) {
ParameterizedType parameterizedType = (ParameterizedType) type;
System.out.println("原始类型 - " + parameterizedType.getRawType());
Type[] arguments = parameterizedType.getActualTypeArguments();
for (int i = 0; i < arguments.length; i++) {
System.out.printf("泛型参数[%d] - %s\n", i, arguments[i]);
}
}
}
打印输出:
3.4 可变参数
可变参数也是 JDK 5 开始加入的新特性,例如:
public class Candy4 {
public static void foo(String... args) {
String[] array = args; // 直接赋值
System.out.println(array);
}
public static void main(String[] args) {
foo("hello", "world");
}
}
可变参数 String... args
其实是一个 String[] args
,从代码中的赋值语句中就可以看出来。 同样 Java 编译器会在编译期间将上述代码变换为:
public class Candy4 {
public static void foo(String[] args) {
String[] array = args; // 直接赋值
System.out.println(array);
}
public static void main(String[] args) {
foo(new String[]{"hello", "world"});
}
}
注意,如果调用了 foo()
则等价代码为 foo(new String[]{})
,创建了一个空的数组,而不会传递 null 进去。
3.5 foreach 循环
仍是 JDK 5 开始引入的语法糖:
(1) 数组的循环
public class Candy5_1 {
public static void main(String[] args) {
// 数组赋初值的简化写法也是语法糖
int[] array = {1, 2, 3, 4, 5};
for (int e : array) {
System.out.println(e);
}
}
}
// =====================================================
public class Candy5_1 {
public Candy5_1() {}
public static void main(String[] args) {
int[] array = new int[]{1, 2, 3, 4, 5};
for(int i = 0; i < array.length; ++i) {
int e = array[i];
System.out.println(e);
}
}
}
(2) 集合的循环
public class Candy5_2 {
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1,2,3,4,5);
for (Integer i : list) {
System.out.println(i);
}
}
}
// ====== 实际被编译器转换为对迭代器的调用 ======
public class Candy5_2 {
public Candy5_2() {}
public static void main(String[] args) {
List<Integer> list = Arrays.asList(1, 2, 3, 4, 5);
Iterator iter = list.iterator();
while(iter.hasNext()) {
Integer e = (Integer)iter.next();
System.out.println(e);
}
}
}
注意 foreach 循环写法,能够配合数组,以及所有实现了 Iterable 接口的集合类一起使用,其中 Iterable 用来获取集合的迭代器(Iterator)。
3.6 switch
从 JDK 7 开始,switch 可以作用于字符串和枚举类,这个功能其实也是语法糖,例如:
public class Candy6_1 {
public static void choose(String str) {
switch (str) {
case "hello": {
System.out.println("h");
break;
}
case "world": {
System.out.println("w");
break;
}
}
}
}
注意,switch 配合 String 和枚举使用时,变量不能为 null!原因分析完语法糖转换后的代码应当自然清楚:
public class Candy6_1 {
public Candy6_1() {}
public static void choose(String str) {
byte x = -1;
switch(str.hashCode()) {
case 99162322: // hello 的 hashCode
if (str.equals("hello")) {
x = 0;
}
break;
case 113318802: // world 的 hashCode
if (str.equals("world")) {
x = 1;
}
}
switch(x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}
可以看到,执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串的转换为相应 byte 类型,第二遍才是利用 byte 执行进行比较。
为什么第一遍时必须既比较 hashCode,又利用 equals 比较呢?hashCode 是为了提高效率,减少可能的比较,而 equals 是为了防止 hashCode 冲突,例如 BM
和 C.
这两个字符串的 hashCode 值都是 2123 ,如此便有了如下代码:
public class Candy6_2 {
public static void choose(String str) {
switch (str) {
case "BM": {
System.out.println("h");
break;
}
case "C.": {
System.out.println("w");
break;
}
}
}
}
会被编译器转换为:
public class Candy6_2 {
public Candy6_2() {}
public static void choose(String str) {
byte x = -1;
switch(str.hashCode()) {
case 2123: // hashCode 值可能相同,需要进一步用 equals 比较
if (str.equals("C.")) {
x = 1;
} else if (str.equals("BM")) {
x = 0;
}
default:
switch(x) {
case 0:
System.out.println("h");
break;
case 1:
System.out.println("w");
}
}
}
}
当使用枚举时:
enum Sex {
MALE, FEMALE
}
public class Candy7 {
public static void foo(Sex sex) {
switch (sex) {
case MALE:
System.out.println("男"); break;
case FEMALE:
System.out.println("女"); break;
}
}
}
上述代码会被编译器转换为:
public class Candy7 {
/**
* 定义一个合成类(仅 jvm 使用,对我们不可见)
* 用来映射枚举的 ordinal 与数组元素的关系,枚举的 ordinal 表示枚举对象的序号,从 0 开始
* 即 MALE 的 ordinal()=0,FEMALE 的 ordinal()=1
*/
static class $MAP {
// 数组大小即为枚举元素个数,里面存储 case 用来对比的数字,如 L17 所示
static int[] map = new int[2];
static {
map[Sex.MALE.ordinal()] = 1;
map[Sex.FEMALE.ordinal()] = 2;
}
}
public static void foo(Sex sex) {
int x = $MAP.map[sex.ordinal()];
switch (x) {
case 1:
System.out.println("男"); break;
case 2:
System.out.println("女"); break;
}
}
}
3.7 枚举类
JDK 7 新增了枚举类,以前面的性别枚举为例:
enum Sex {
MALE, FEMALE
}
转换后代码为:
public final class Sex extends Enum<Sex> {
public static final Sex MALE;
public static final Sex FEMALE;
private static final Sex[] $VALUES;
static {
MALE = new Sex("MALE", 0);
FEMALE = new Sex("FEMALE", 1);
$VALUES = new Sex[]{MALE, FEMALE};
}
/**
* Sole constructor. Programmers cannot invoke this constructor.
* It is for use by code emitted by the compiler in response to
* enum type declarations.
* @param name - The name of this enum constant, which is the identifier
* used to declare it.
* @param ordinal - The ordinal of this enumeration constant (its position
* in the enum declaration, where the initial constant is assigned.
*/
private Sex(String name, int ordinal) {
super(name, ordinal);
}
public static Sex[] values() {
return $VALUES.clone();
}
public static Sex valueOf(String name) {
return Enum.valueOf(Sex.class, name);
}
}
3.8 try-with-resources
JDK 7 开始新增了对需要关闭的资源处理的特殊语法 try-with-resources
:
try (资源变量 = 创建资源对象) {
// ...
} catch (...) {
// ...
}
其中资源对象需要实现 AutoCloseable
接口,例如 InputStream、OutputStream、Connection、Statement 、ResultSet 等接口都实现了 AutoCloseable
,使用 try-with-resources
可以不用写 finally
语句块,编译器会帮助生成关闭资源代码,例如:
public class Candy8 {
public static void main(String[] args) {
try(InputStream is = new FileInputStream("d:\\1.txt")) {
System.out.println(is);
} catch (IOException e) {
e.printStackTrace();
}
}
}
会被转换为:
public class Candy9 {
public Candy9() {}
public static void main(String[] args) {
try {
InputStream is = new FileInputStream("d:\\1.txt");
Throwable t = null;
try {
System.out.println(is);
} catch (Throwable e1) {
t = e1; // t 是我们代码出现的异常
throw e1;
} finally {
if (is != null) { // 判断了资源不为空
if (t != null) { // 如果我们代码有异常
try {
is.close();
} catch (Throwable e2) {
t.addSuppressed(e2); // 如果 close 出现异常,作为被压制异常添加
}
} else {
is.close(); // 如果我们代码没有异常,close 出现的异常就是最后 catch 块中的 e
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
为什么要设计一个 addSuppressed(Throwable e)
(添加被压制异常)的方法呢?是为了防止异常信息的丢失(想想 try-with-resources 生成的 fianlly 中如果抛出了异常),举例说明:
public class Test {
public static void main(String[] args) {
try (MyResource resource = new MyResource()) {
int i = 1/0;
} catch (Exception e) {
e.printStackTrace();
}
}
}
class MyResource implements AutoCloseable {
public void close() throws Exception {
throw new Exception("close 异常");
}
}
抛出的异常堆栈信息:
java.lang.ArithmeticException: / by zero
at test.Test.main(Test.java:7)
Suppressed: java.lang.Exception: close 异常
at test.MyResource.close(Test.java:18)
at test.Test.main(Test.java:6)
3.9 方法重写时的桥接方法
我们都知道,方法重写时对返回值分 2 种情况:① 父子类的返回值完全一致;② 子类返回值可以是父类返回值的子类。
class A {
public Number m() {
return 1;
}
}
class B extends A {
@Override
public Integer m() { // 子类 m 方法的返回类型 Integer 是父类 m 方法返回类型 Number 的子类
return 2;
}
}
对于子类,Java 编译器会做如下处理:
class B extends A {
public Integer m() {
return 2;
}
// [桥接方法] 此方法才是真正重写了父类 public Number m() 方法
public synthetic bridge Number m() {
// 调用 public Integer m()
return m();
}
}
其中「桥接方法」比较特殊,仅对 Java 虚拟机可见,并且与原来的 public Integer m()
没有命名冲突(原因如下图所示),可以用下面反射代码来验证:
for (Method m : B.class.getDeclaredMethods()) {
System.out.println(m);
}
// =============== 打印结果 ==============
public java.lang.Integer test.candy.B.m()
public java.lang.Number test.candy.B.m()
review:《类文件结构》#8.3-方法重写
3.10 匿名内部类
(1)简单使用
public class Candy10 {
public static void main(String[] args) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok");
}
};
}
}
转换后代码:
// 额外生成的类
final class Candy10$1 implements Runnable {
Candy11$1() {}
public void run() {
System.out.println("ok");
}
}
// ==============================================
public class Candy10 {
public static void main(String[] args) {
Runnable runnable = new Candy11$1();
}
}
(2)引用局部变量的匿名内部类
public class Candy11 {
public static void test(final int x) {
Runnable runnable = new Runnable() {
@Override
public void run() {
System.out.println("ok:" + x);
}
};
}
}
转换后的代码:
// 额外生成的类
final class Candy11$1 implements Runnable {
int val$x;
Candy11$1(int x) {
this.val$x = x;
}
public void run() {
System.out.println("ok:" + this.val$x);
}
}
// ==============================================
public class Candy11 {
public static void test(final int x) {
Runnable runnable = new Candy11$1(x);
}
}
注意!这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:因为在创建 Candy11$1
对象时,将局部变量 x 的值赋值给了 Candy11$1
对象的 val$x
属性,所以 x 之后不应该再发生变化了。如果变化,那么 val$x
属性是没机会跟着一起变的。