编译期处理 —— 编译器到底干了些什么?
目录
所谓的语法糖,其实就是指 java 编译器所有 *.java 源码编译为 *.class 字节码的过程,自动生成和转换的一些代码, 主要是为了减轻程序员的负担,算是 java 编译器给我们的一个额外福利(给糖吃嘛)。
注意: 以下代码的分析,借助了 javap 工具,idea 的反编译功能,idea插件 jclasslib等工具。另外,编译器转换的结果直接就是 class字节码,只是为了便于阅读,给出了几乎等价的 java源码方式,并不是编译器还会转换出中间的 java源码,切记。
1.1 语法糖 - 默认构造器
先来看一下示例代码,里面没有自己声明构造方法:
- // 编译期处理(语法糖)——默认构造器
- public class T01_CompileTime_DefaultConstructor {
-
- }
编译成 class 后的代码: 里面没有声明构造方法,无参构造是编译器帮助我们加上的。即调用父类 Object的无参构造方法:java/lang/Object."
- // 编译期处理(语法糖)——默认构造器
- public class T01_CompileTime_DefaultConstructor {
- // 这个无参构造是编译器帮助我们加止的
- public T01_CompileTime_DefaultConstructor() {
- super(); // 即调用父类 Object 的无参构造方法,即调用 java/lang/Object."
":()V - }
- }
总结:
- 如果一个类中没有声明构造方法,无参构造是编译器会帮我们加上的。即调用父类 Object的无参构造方法:java/lang/Object."
"方法
1.2 语法糖 - 自动拆装箱
这个自动拆装箱特性是 JDK 5 开始加入的,代段片段1如下:
- // 编译期处理(语法糖)——自动拆装箱
- public class T02_CompileTime_AutoPackUnpack {
- public static void main(String[] args) {
- Integer x = 1;
- int y = x;
- }
- }
这段代码在 JDK 5 之前无法编译通过的,必须改写为 代码片段2:
- // 编译期处理(语法糖)——自动拆装箱
- public class T02_CompileTime_AutoPackUnpack {
- public static void main(String[] args) {
- Integer x = Integer.valueOf(1); // 装箱
- int y = x.intValue; // 拆箱
- }
- }
显然之前版本的代码太麻烦了,需要在基本类型和包装类型之间来回转换(尤其是集合类中操作的都是包装类型),因此这些转换的事情在 JDK 5 以后 装箱与拆箱操作 都由编译器在编译阶段完成,即 代码片段1 都会在编译阶段被转换为 代码片段2
总结:
- 自动拆装箱转换的事情在 JDK 5 以后 装箱与拆箱操作 都由编译器在编译阶段完成
1.3 语法糖 - 泛型擦除
泛型也是在 JDK 5 开始加入的特性,但 java 在编译泛型代码后会执行 泛型擦除 的动作,即泛型信息在编译为字节码之后果就丢失了,实际的类型都当做了 Object 类型来处理:
- // 编译期处理(语法糖)——泛型擦除
- public class T03_CompileTime_GenericErase {
- public static void main(String[] args) {
- List
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();
总结:
- 在JDK 5 及之后,泛型的类型转换麻烦事都不用自己做,java编译时就会帮我们做好。也就是说泛型参数只用于编译阶段的类型检查,不影响运行阶段
- 擦除的是字节码上的泛型信息,可以看到 LocalVariableTypeTable 仍然保留了方法参数泛型的信息
上述代码通过:javap -v T03_CompileTime_GenericErase.class进行反编译,得到如下字节码。
- 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."
":()V - 7: astore_1
- 8: aload_1
- 9: bipush 10
- 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
- 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
- LineNumberTable: ...
- 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
;
1.4.1 语法糖 - 泛型反射
使用反射,仍然能够获得这些信息:
- public Set
test(List list, Map map) { - }
- Method test = T03_CompileTime_GenericErase.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("原始类型 - " + parameteredType.getRawType());
- Type[] arguments = parameterizedType.getActualTypeArguments();
- for (int i = 0; i < arguments.length; i++) {
- System.out.printf("泛型参数[%d] - %s\n", i, arguments[i]);
- }
- }
- }
输出
- 原始类型 - interface java.util.List
- 泛型参数[0] - class java.lang.String
- 原始类型 - interface java.util.Map
- 泛型参数[0] - class java.lang.Integer
- 泛型参数[1] - class java.lang.Object
1.4 语法糖 - 可变参数
可变参数也是 JDK 5 开始加入的新特性,示例代码如下:
- // 编译期处理(语法糖)——可变参数
- public class T04_CompileTime_VariableParameter {
- 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 T04_CompileTime_VariableParameter {
- public static void foo(String[] args) { // 编译器将可变参数转换为数组
- String[] array = args; // 直接赋值
- System.out.println(array);
- }
-
- public static void main(String[] args) {
- foo("hello", "world");
- }
- }
总结:
- 可变参数也是 JDK 5 开始加入的新特性;
- 可变参数 String... args 其实是一个 String[] args,编译器会将可变参数转换为数组;
- 如果调用了 foo() 则等价代码为 foo(new String[] {}),创建了一个空的数组,而不会传递 null 进去。
1.5 语法糖 - foreach 循环
仍是 JDK 5 开始引入的语法糖,数组的循环:
- // 编译期处理(语法糖)——foreach
- public class T05_CompileTime_Foreach {
- public static void main(String[] args) {
- int[] array = {1,2,3,4,5}; // 数组赋初值的简化写法也是语法糖哦
- for (int e : array) {
- System.out.println(e);
- }
- }
- }
foreach 循环会被编译器转换为 fori 循环:
- // 编译期处理(语法糖)——foreach
- public class T05_CompileTime_Foreach {
- public T05_CompileTime_Foreach() {
- }
-
- 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);
- }
- }
- }
而集合的循环:
- // 编译期处理(语法糖)——foreach
- public class T05_CompileTime_Foreach {
- public static void main(String[] args) {
- List
list = Arrays.asList(1,2,3,4,5); - for (Integer i : list) {
- System.out.println(i);
- }
- }
- }
实际被编译器转换为对迭代器的调用:
- // 编译期处理(语法糖)——foreach
- public class T05_CompileTime_Foreach {
- public T05_CompileTime_Foreach() {
- }
-
- public static void main(String[] args) {
- List
list = Arrays.asList(1,2,3,4,5); - Iterator iter = list.iterator(); // 获取迭代器
- while (iter.hasNext()) {
- Integer e = (Integer)iter.next(); // 类型转换
- System.out.println(e);
- }
- }
- }
总结:
- JDK 5 开始引入的语法糖
- 数组的循环 foreach 循环会被编译器转换为 fori 循环
- 集合的循环 实际被编译器转换为对迭代器的调用
- foreach 循环写法,能够配合数组,以及所有实现了 Iterable 接口的集合类一起使用,其中 Iterable 用来获取集合的迭代器 (Iterator)
1.6 语法糖 - switch-string字符串
从JDK 7 开始,switch 可以作用于字符串和枚举类,这个功能其实也是语法糖,例如:
- // 编译期处理(语法糖)——switch-string
- public class T06_CompileTime_SwitchString {
- 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 ,原因分析完语法糖转换后的代码应当自然清楚
会被编译器转换为:
- // 编译期处理(语法糖)——switch-string
- public class T06_CompileTime_SwitchString {
- public T06_CompileTime_SwitchString() {
-
- }
-
- public static void choose(String str) {
- byte x = -1;
- switch (str.hashCode()) {
- case 99162322: // hello 的 hashCode
- if (str.equals("hello")) {
- x = 0;
- }
- case 113318802: // word 的 hashCode
- if (str.equals("world")) {
- x = 1;
- }
- }
- switch (x) {
- case 0:
- System.out.println("h");
- break;
- case 1:
- System.out.println("w");
- break;
- }
- }
- }
可以看到,执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串的转换为相应的 byte 类型,第二遍才利用 byte 执行进行比较。
为什么第一遍时必须比较 hashCode ,又利用 equals 比较呢?hashCode 是为了提高效率,减少可能的比较;而 equals 是为了防止 hashCode 冲突,例如 BM 和 C. 这两个字符串的 hashCode 值都是 2123,如下代码:
- // 编译期处理(语法糖)——switch-string
- public class T06_CompileTime_SwitchString2 {
- public static void choose(String str) {
- switch (str) {
- case "BM": {
- System.out.println("h");
- break;
- }
- case "C.": {
- System.out.println("w");
- break;
- }
- }
- }
会被编译器转换为:
- // 编译期处理(语法糖)——switch-string
- public class T06_CompileTime_SwitchString2 {
- public T06_CompileTime_SwitchString2() {
-
- }
-
- public static void choose(String str) {
- byte x = -1;
- switch (str.hashCode()) {
- case 2123: // hashCode 值可能相同,需要进一步用 equals 比较
- if (str.equals("C.")) {
- x = 0;
- } else if (str.equals("BM")) {
- x = 1;
- }
- default:
- switch (x) {
- case 0:
- System.out.println("h");
- break;
- case 1:
- System.out.println("w");
- break;
- }
- }
- }
- }
总结:
- switch-string字符串语法糖,从JDK 7 开始
- switch-string字符串,会java 编译器调整为执行了两遍 switch,第一遍是根据字符串的 hashCode 和 equals 将字符串的转换为相应的 byte 类型,第二遍才利用 byte 执行进行比较。
- hashCode 是为了提高效率,减少可能的比较;而 equals 是为了防止 hashCode 冲突
1.7 语法糖 - switch 枚举
switch 枚举的例子,原始代码:
- enum Sex {
- MALE, FEMALE;
- }
- // 编译期处理(语法糖)——switch-enum
- enum Sex {
- MALE, FEMALE;
- }
-
- public class T07_CompileTime_SwitchEnum {
- public static void foo(Sex sex) {
- switch (sex) {
- case MALE:
- System.out.println("男"); break;
- case FEMALE:
- System.out.println("女"); break;
- }
- }
- }
转换后代码:
- public class T07_CompileTime_SwitchEnum {
- /**
- * 定义一个合成类(仅 jvm 使用,对我们不可见)
- * 用来映射枚举的 ordinal 与数组元素的关系
- * 枚举的 ordinal 表示枚举对象的序号,从 0 开始
- * 即 MALE 的 ordinal()=0, FEMALE 的 ordinal()=1
- */
- static class $MAP {
- // 数组大小即为枚举元素个数,里面存储case用来对比的数字
- 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;
- }
- }
- }
- }
总结:
- 枚举类使用 switch 时,java编译器会帮我们自动合成一个静态类用来映射枚举的 ordinal 下标 与数组元素的关系,数组大小即为枚举元素个数,里面存储 case 是用来对比的数字;
- 接着,在 switch 方法中,取出枚举下标对应静态类中定义的数字,进行switch判断;
1.8 语法糖 - 枚举类
JDK 5 新增枚举类,以前面的性别的枚举为例:
- enum Sex {
- MALE, FEMALE;
- }
java 编译器将上述转换后,得到如下代码:
- public final class Sex extends Enum
{ - 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 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);
- }
- }
总结:
- 枚举本质上是通过普通的类来实现的,只是编译器为我们进行了处理。每个枚举类型都继承自 java.lang.Enum,并自动添加了 values 和 valueOf 方法。而每个枚举常量是一个静态常量字段,使用内部类实现,该内部类继承了枚举类。所有枚举常量都通过静态代码块来进行初始化,即在类加载期间就初始化。另外通过把 clone、readObject、writeObject 这三个方法定义为 final 的,同时实现是抛出相应的异常。这样保证了每个枚举类型及枚举常量都是不可变的。可以利用枚举的这两个特性来实现线程安全的单例。
1.9 语法糖 - try-with-resources
JDK 7 开始新增了对需要关闭的资源处理的特殊语法 try-with-resources
- try (资源变量 = 创建资源对象) {
-
- } catch {
-
- }
其中资源对象需要实现 AutoCloseable 接口,例如:InputStream、OutputStream、Connection、Statement、ResultSet 等接口实现了 AutoCloseable,使用 try-with-resources 可以不用写 finally 语句块,编译器会帮助生成 finally 代码关闭资源,例如:
- // 编译期处理(语法糖) —— try-with-resources
- public class T09_CompileTime_TryWithResources {
- public static void main(String[] args) {
- try (InputStream is = new FileInputStream("in/1.txt")){
- System.out.println(is);
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
java 编译器在编译时,会做如下改动:
- // 编译期处理(语法糖) —— try-with-resources
- public class T09_CompileTime_TryWithResources2 {
- public T09_CompileTime_TryWithResources2() {
-
- }
- public static void main(String[] args) {
- try {
- InputStream is = new FileInputStream("in/1.txt");
- Throwable t = null;
- try {
- System.out.println(is);
- } catch (Throwable e1) {
- // t 是我们代码出现的异常
- t = e1;
- throw e1;
- } finally {
- // 判断了资源不为空
- if (is != null) {
- // 如果我们代码有异常
- if (t != null) {
- try {
- is.close();
- } catch (Throwable e2) {
- // 如果 close 出现异常,作为被压制异常添加; 这样异常不会丢
- t.addSuppressed(e2); // 一般开发人员不会考虑这么全面的异常捕获
- }
- } else {
- // 如果我们代码没有异常,close 出现的异常就是最后的 catch 块中的 e
- is.close();
- }
- }
- }
- } catch (IOException e) {
- e.printStackTrace();
- }
- }
- }
什么要设计一个 addSuppressed (Throwable e) (添加被压制异常)的方法呢?是为了防止异常信息的丢失(想想 try-with-resources 生成的 finally 中如果抛出了异常):
- // 编译期处理(语法糖) —— try-with-resources
- public class T09_CompileTime_TryWithResources3 {
- public static void main(String[] args) {
- try (MyResource resource = new MyResource()) {
- int i = 1/0;
- } catch (Exception e){
- e.printStackTrace();
- }
- }
-
- static class MyResource implements AutoCloseable {
- //@Override
- public void close() throws Exception {
- throw new Exception("close 异常");
- }
- }
- }
如以上代码所示,两个异常信息都不会丢。
总结:
- JDK 7 开始新增了对需要关闭的资源处理的特殊语法 try-with-resources
- 资源对象需要实现 AutoCloseable 接口,例如:InputStream、OutputStream、Connection、Statement、ResultSet 等接口实现了 AutoCloseable,使用 try-with-resources 可以不用写 finally 语句块,编译器会帮助生成 finally 代码关闭资源
- try-with-resources 能实现正常资源的关闭,如果出现了异常,异常信息不会丢失
1.10 语法糖 - 重写桥接
我们都知道,方法重写对对返回值分两种情况:
- 父子类的返回值完全一致
- 子类返回值可以是父类返回值的子类(比较绕口,见下面的例子)
- class A {
- public Number m() {
- return 1;
- }
- }
-
- class B extends A {
- @Override
- // 子类 m 方法的返回值是 Integer 是父类 m 方法返回值 Number 的子类
- public Number m() {
- return 2;
- }
- }
对于子类,java编译器会做如下处理:
- class B extends A {
- public Integer m() {
- return 2;
- }
- // 此方法才是真正重写了父类 public Number m() 方法; synthetic bridge 是jvm合成的,对我们不可见
- 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()
总结:
- 父类 A 的 m 的返回值是 Number 类型,子类 B 重写 m 返回的值是 Integer 类型,对于 Java 语言是重写的,但对于 Java 虚拟机解析来说却不是重写的,只有当两个方法的参数类型以及返回类型一致时,Java 虚拟机才会判定为重写,为了保持重写的语义,Java 编译器会在 B类 的字节码文件中自动生成一个桥接方法来保证重写语义。
1.11 语法糖 - 匿名内部类
先来看一下匿名内部类的示例:
- public class T11_Compile_HideInnerClass {
- public static void main(String[] args) {
- Runnable runnable = new Runnable() {
- @Override
- public void run() {
- System.out.println("ok");
- }
- };
- }
- }
转换后代码:java 编译器会自动生成一个叫 xxx$1 的类,该类实现 Runnable 接口
- final class T11_Compile_HideInnerClass$1 implements Runnable {
- T11_Compile_HideInnerClass$1() {
- }
-
- public void run() {
- System.out.println("ok")
- }
- }
然后在 xxx 类中,调用由 java 编译器自动生成的 xxx$1 类对象
- final class T11_Compile_HideInnerClass {
- public static void main(String[] args) {
- Runnable runnable = new T11_Compile_HideInnerClass$1();
- }
- }
接下来,我们继续看另外一个例子:引用局部变量的匿名内部类,源代码:
- // 编译期处理(语法糖) —— 匿名内部类
- public class T11_Compile_HideInnerClass {
- public static void test(final int x) {
- Runnable runnable = new Runnable() {
- @Override
- public void run() {
- System.out.println("ok:" + x);
- }
- };
- }
- }
java 编译器会自动生成一个叫 xxx$1的类,该类实现 Runnable 接口。如有存在引用局部变量,则会将局部变量 x 做为动态生成类 xxx$1 类的一个成员变量,通过构造方法传入。 转换后代码:
- final class T11_Compile_HideInnerClass$1 implements Runnable {
- int val$x;
- T11_Compile_HideInnerClass$1(int x) {
- this.val$x = x;
- }
-
- public void run() {
- System.out.println("ok" + this.val$x)
- }
- }
- final class T11_Compile_HideInnerClass {
- public static void test(final int x) {
- Runnable runnable = new T11_Compile_HideInnerClass$1(x);
- }
- }
注意:
这同时解释了为什么匿名内部类引用局部变量时,局部变量必须是 final 的:因为在创建 xxx$1 对象时,将 x 的值赋值给了 xxx$1 对象的 val$x 属性,所以 x 不应该再发生变化了,如果变化,那 val$x 属性没有机会再跟着一起变化
总结:
- 匿名内部类的底层实现,由Java 编译器自动生成一个叫 xxx$1的类,该类实现 Runnable 接口。如果存在引用局部变量,则会将局部变量 x 做为动态生成类 xxx$1 类的一个成员变量,通过构造方法传入。
- 局部变量必须是 final 的:因为在创建 xxx$1 对象时,将 x 的值赋值给了 xxx$1 对象的 val$x 属性,所以 x 不应该再发生变化了,如果变化,那 val$x 属性没有机会再跟着一起变化
文章最后,给大家推荐一些受欢迎的技术博客链接:
- Hadoop相关技术博客链接
- Spark 核心技术链接
- JAVA相关的深度技术博客链接
- 超全干货--Flink思维导图,花了3周左右编写、校对
- 深入JAVA 的JVM核心原理解决线上各种故障【附案例】
- 请谈谈你对volatile的理解?--最近小李子与面试官的一场“硬核较量”
- 聊聊RPC通信,经常被问到的一道面试题。源码+笔记,包懂
欢迎扫描下方的二维码或 搜索 公众号“10点进修”,我们会有更多、且及时的资料推送给您,欢迎多多交流!
评论记录:
回复评论: