vlambda博客
学习文章列表

JVM系列之:初识Javac编译器和Java语法糖

Javac编译器

概念

《Java虚拟机规范》 中严格定义了 Class 文件格式的各种细节, 可是对如何把 Java 源码编译为Class 文件却描述得相当宽松。这里的 javac 编译器称为前端编译器,其他的前端编译器还有诸如 Eclipse JDT 中的增量式编译器 ECJ 等。相对应的还有后端编译器,它在程序运行期间将字节码转变成机器码,如 HotSpot 自带的 JIT 编译器,后续章节我们会详细介绍。

在《深入理解Java虚拟机》一文中描述了 javac 编译器的执行过程,大致可以分为1个准备过程和3个处理过程,它们分别如下所示:

1、准备过程:初始化插入式注解处理器。

2、解析与填充符号表过程,包括:

  • 词法、语法分析。将源代码的字符流转变为标记集合, 构造出抽象语法树。
  • 填充符号表。产生符号地址和符号信息

3、插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段。

4、分析与字节码生成过程,包括:

  • 标注检查。对语法的静态信息进行检查。
  • 数据流及控制流分析。对程序动态运行过程进行检查。
  • 解语法糖。将简化代码编写的语法糖还原为原有的形式。
  • 字节码生成。将前面各个步骤所生成的信息转化成字节码。

上述3个处理过程里, 执行插入式注解时又可能会产生新的符号, 如果有新的符号产生, 就必须转回到之前的解析、 填充符号表的过程中重新处理这些新符号, 从总体来看, 三者之间的关系与交互顺序如下图所示:

Javac 编译器入口位于 src/com/sun/tools/javac/Main.java,我们可以看一下它的 main 方法。

    public static void main(String[] args) throws Exception {
        System.exit(compile(args));
    }

如果深入查看源码,可以发现,先定位到 com.sun.tools.javac.main.Main 类,然后又到 com.sun.tools.javac.main.JavaCompiler类,那么上述 3个处理过程应该就是在 JavaCompiler 类中实现的,具体指 compile()、compile2()这两个方法,这里直接引用书中的图片。

JVM系列之:初识Javac编译器和Java语法糖

关于这部分代码,感兴趣的朋友可以先去了解一下,具体介绍可以参考《深入理解Java虚拟机》。关于插入式注解处理器,下篇文章会深入进行学习,其他处理流程暂时就了解其含义就行了,可以将关注点转移到如何利用 Javac 编译器来学习 class 文件中的指令这一方向。

我们知道可以通过 javac 命令来编译 Java 源文件,可是 javac 编译器到底如何进行的,还需要从源码入手进行学习。Javac 编译器不像 HotSpot 虚拟机那样使用 C++语言(包含少量C语言) 实现,它本身就是一个由 Java 语言编写的程序。

小试牛刀

下载

OpenJDK 的下载方式为:打开 hg.openjdk.java.net/jdk8/jdk8/l… ,点击左侧的 zip 或者 gz 进行下载。

在 Intellij 中新建一个 javac-source-code-reading 项目,把源码目录的 src/share/classes/com 目录整个拷贝到项目 src 目录下,删掉没用的 javadoc 目录。

运行代码

打开 src/com/sun/tools/javac/Main.java,在同级目录新建一个 HelloWorld.java 文件,内容随便写。复制该文件路径,然后加到 Main 的启动配置中,如下图所示:

JVM系列之:初识Javac编译器和Java语法糖

执行 Main 文件,可以得到一个 HelloWorld.class 文件。

学习源码最好的方式就是断点调试,一步步查看执行过程,来验证学习。那么如何在上述项目中进入断点调试呢?

首先在 main 方法内打一个断点,然后 debug 执行 main 方法,结果发现调试停在了 Main.class 的断点处,再定位一看,发现是 JDK8 的 tools.jar 包中的 class 文件。

为了让断点走 Javac 源码,可以这样修改 Project Structure,将 移动到顶部。

img

再次执行代码,可以发现调试停留在了源码的断点处。

实操:tableswitch 和 lookupswitch 选择的策略

我们修改 HelloWorld.java 文件,具体内容如下:

public class HelloWorld {

  public static void main(String[] args) {
    foo();
  }

  public static void foo() {
    int a = 0;
    switch (a) {
      case 0:
        System.out.println("#0");
        break;
      case 1:
        System.out.println("#1");
        break;
      default:
        System.out.println("default");
        break;
    }
  }
}

执行编译器主方法得到 class 文件后,使用 javap 命令来查看字节码,发现 switch-case 语句采用了 lookupswitch,而不是 tableswitch。

3: lookupswitch  { // 2
      0: 28
      1: 39
      default: 50
}

想要了解编译器为何选择 lookupswitch,那就查看这块的逻辑,全局搜索该字段,最终定位到 src/com/sun/tools/javac/jvm/Gen.java 中。核心代码如下:

// Determine whether to issue a tableswitch or a lookupswitch
// instruction.
long table_space_cost = 4 + ((long) hi - lo + 1); // words
long table_time_cost = 3// comparisons
long lookup_space_cost = 3 + 2 * (long) nlabels;
long lookup_time_cost = nlabels;
int opcode =
  nlabels > 0 &&
  table_space_cost + 3 * table_time_cost <=
  lookup_space_cost + 3 * lookup_time_cost
  ?
  tableswitch : lookupswitch;

我们在上述代码上打断点,重新 debug 执行 Main 文件,得到如下内容:

可以看出来,因为 table_space_cost + 3 * table_time_cost <= lookup_space_cost + 3 * lookup_time_cost 为 false,所以最终选择了 lookupswitch。

这只是通过 javac 源码学习研究字节码指令的一个示例,后续如果对字节码指令有所困惑,可以来查看源码学习其背后的逻辑。

在介绍 Javac 编译器的步骤时,其中第三步提到了解语法糖,之前或多或少听过这个术语,但是一直不知其意,接下来我们就来学习一下。

Java语法糖

定义

语法糖(Syntactic Sugar),也称糖衣语法,指在计算机语言中添加的某种语法,这些语法糖虽然不会提供实质性的功能改进,但是它们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。说白了,语法糖就是对现有语法的一个封装。

Java 语法糖可以看作是 Javac 编译器实现的一些“小把戏”,这些语法糖并不被虚拟机所支持,在编译成字节码阶段就自动转换成简单常用语法。一般来说 Java 中的语法糖主要有以下几种:

  1. 泛型与类型擦除
  2. 自动装箱与拆箱,变长参数
  3. 增强for循环
  4. 内部类与枚举类

泛型与类型擦除

泛型的本质是参数化类型(Parameterized Type) 或者参数化多态(Parametric Polymorphism) 的应用, 即可以将操作的数据类型指定为方法签名中的一种特殊参数, 这种参数类型能够用在类、 接口和方法的创建中, 分别构成泛型类、 泛型接口和泛型方法。

Java 泛型(generics)是 JDK 5 中引入的一个新特性, 泛型提供了编译时类型安全检测机制,该机制允许程序员在编译时检测到非法的类型。泛型的本质是参数化类型,也就是说所操作的数据类型被指定为一个参数。

Java 中泛型标记符:

  • E - Element (在集合中使用,因为集合中存放的是元素)
  • T - Type(Java 类)
  • K - Key(键)
  • V - Value(值)
  • N - Number(数值类型)
  • ? - 表示不确定的 java 类型

泛型应用在类、接口和方法中,简单示例如下:

static <E> void printArray(E[] inputArray){}
class Box<T>{}
interface Tox<T>{}

假设我们需要这样一个需求:写一个排序方法,能够对整型数组、字符串数组甚至其他任何类型的数组进行排序,该如何实现?

答案是可以使用 Java 泛型。代码如下:

public class GenericMethodTest {

  // 泛型方法 printArray
  public static <E> void printArray(E[] inputArray) {
    // 输出数组元素
    for (E element : inputArray) {
      System.out.printf("%s ", element);
    }
    System.out.println();
  }

  public static void main(String args[]) {
    // 创建不同类型数组:Integer, Double 和 Character
    Integer[] intArray = {12345};
    Double[] doubleArray = {1.12.23.34.4};
    Character[] charArray = {'H''E''L''L''O'};

    System.out.println("整型数组元素为:");
    printArray(intArray); // 传递一个整型数组

    System.out.println("\n双精度型数组元素为:");
    printArray(doubleArray); // 传递一个双精度型数组

    System.out.println("\n字符型数组元素为:");
    printArray(charArray); // 传递一个字符型数组
  }
}

类型擦除

泛型被引入 Java 语言以在编译时提供更严格的类型检查并支持泛型编程。为了实现泛型,Java 编译器将类型擦除应用于:

  • 如果类型参数是无界的,则将泛型类型中的所有类型参数替换为其边界或 Object。因此,生成的字节码只包含普通的类、接口和方法。
  • 必要时插入类型转换以保持类型安全。
  • 生成桥方法以保留扩展泛型类型中的多态性。

类型擦除确保不会为参数化类型创建新类;因此,泛型不会产生运行时开销。

在类型擦除过程中,Java 编译器擦除所有类型参数,如果类型参数是无界的,则将其替换为Object

public class Node<T{

  private T data;
  private Node<T> next;

  public Node(T data, Node<T> next) {
    this.data = data;
    this.next = next;
  }

  public T getData() {
    return data;
  }
}

编译上述代码,然后执行 javap 命令查看字节码内容,截取部分内容如下:

public com.msdn.java.javac.Node(T, com.msdn.java.javac.Node<T>);
    descriptor: (Ljava/lang/Object;Lcom/msdn/java/javac/Node;)V
    flags: (0x0001) ACC_PUBLIC

可以看到,描述符(descriptor)描述字段 data 的类型为 Object。

当然,并不是每一个泛型参数被擦除类型后都会变成 Object 类。对于限定了继承类的泛型参数,经过类型擦除后,所有的泛型参数都将变成所限定的继承类。也就是说,Java 编译器将选取该泛型所能指代的所有类中层次最高的那个,作为替换泛型的类。

举个例子,在下面这段 Java 代码中,定义了一个 T extends Number 的泛型参数。

class GenericTest<T extends Number> {
  T foo(T t) {
    return t;
  }
}

我们同样查看其字节码文件:

T foo(T);
  descriptor: (Ljava/lang/Number;)Ljava/lang/Number;
  flags: (0x0000)
  Code:
    stack=1, locals=2, args_size=2
       0: aload_1
       1: areturn
  Signature: (TT;)TT;

泛型的类型擦除带来了不少问题。比如说下面这个案例(目前Java不支持):

ArrayList<int> ilist = new ArrayList<int>();
ArrayList<long> llist = new ArrayList<long>();
ArrayList list;
list = ilist;
list = llist;

我们都知道声明 List 对象不支持基本数据类型,其实就是泛型擦除导致的问题,因为不支持 int、long 与 Object 之间的强制转换,所以 Java 就索性不支持基础数据类型,要求我们直接使用 List 。但实际应用时又遇到新的问题,比如说我们往 List 对象中新增 int类型的值,要进行类型转换,好在 Java 支持自动装箱、拆箱(后文我们会介绍),能够处理这个问题,但这也是 Java 泛型慢的重要原因。

桥接方法

泛型的类型擦除带来了不少问题。其中一个便是方法重写。

对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法来实现 Java 中的重写语义。

来看一个案例:

public class Parent<T> {

  public void sayHello(T value) {
    System.out.println("This is Parent Class, value is " + value);

  }
}

public class Child extends Parent<String> {

  public void sayHello(String value) {
    System.out.println("This is Child class, value is " + value);
  }

  public static void main(String[] args) {
    Child child = new Child();
    Parent<String> object = child;
    object.sayHello("Java");
  }
}

然后执行下述命令:

javac Child.java Parent.java 
javap -v -c Child 

可以看到这样一个方法:

  public void sayHello(java.lang.Object);
    descriptor: (Ljava/lang/Object;)V
    flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
    Code:
      stack=2, locals=2, args_size=2
         0: aload_0
         1: aload_1
         2: checkcast     #13                 // class java/lang/String
         5: invokevirtual #14                 // Method sayHello:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 8: 0
        
  // 这个桥接方法等同于
  public void sayHello(Object value) {
    sayHello((String) value);
  }

因为类型擦除,T 关键字会被替换为 Object,然后编译器会在 Child 中生成一个桥方法 sayHello,它重写了父类的同名同方法描述符的方法。该桥接方法将传入的 Object 参数强制转换为 String 类型,再调用原本的 sayHello(String) 方法。

需要注意的是,在 javap 的输出中,该桥接方法的访问标识符除了代表桥接方法的 ACC_BRIDGE 之外,还有 ACC_SYNTHETIC。它表示该方法对于 Java 源代码来说是不可见的。当你尝试通过传入一个声明类型为 Object 的对象作为参数,调用 Child 类的 sayHello 方法时,Java 编译器会报错,并且提示参数类型不匹配。

Child child = new Child();
Object o = new Object();
child.sayHello(o);

除了前面介绍的泛型重写会生成桥接方法之外,如果子类定义了一个与父类参数类型相同的方法,其返回类型为父类方法返回类型的子类,那么 Java 编译器也会为其生成桥接方法。比如说下面这个案例:

class Merchant {
  public Number actionPrice(Customer customer) {
    return 0;
  }
}

class NaiveMerchant extends Merchant {
  public Double actionPrice(Customer customer) {
    return 0.0D;
  }
}

自动装箱、拆箱

自动装箱、拆箱相较于泛型来说,技术难度低一些,我们在 Java 基础知识学习都接触过。简单来说,自动装箱就是 Java 编译器在基本数据类型和对应的对象包装类型间的转化,即 int 转化为 Integer,自动拆箱是 Integer 调用其方法将其转化为 int 的过程。

往期文章有介绍过 Java 的数据类型,我们知道,Java 语言拥有 8 个基本类型,每个基本类型都有对应的包装(wrapper)类型。

还以 List 对象为例,当我们 add 数值时,需要先将其转换为对应的包装类,再存入容器之中。在 Java 程序中,这个转换可以是显式,也可以是隐式的,后者正是 Java 中的自动装箱。

public int foo() {
  ArrayList<Integer> list = new ArrayList<>();
  list.add(0);
  int result = list.get(0);
  return result;
}

对应字节码文件为:

public int foo();
  Code:
     0: new java/util/ArrayList
     3: dup
     4: invokespecial java/util/ArrayList."<init>":()V
     7: astore_1
     8: aload_1
     9: iconst_0
    10: invokestatic java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
    13: invokevirtual java/util/ArrayList.add:(Ljava/lang/Object;)Z
    16: pop
    17: aload_1
    18: iconst_0
    19: invokevirtual java/util/ArrayList.get:(I)Ljava/lang/Object;
    22: checkcast java/lang/Integer
    25: invokevirtual java/lang/Integer.intValue:()I
    28: istore_2
    29: iload_2
    30: ireturn

在上面字节码偏移量为 10 的指令中,我们调用了 Integer.valueOf 方法,将 int 类型的值转换为 Integer 类型,再存储至容器类中。

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

在上面字节码偏移量为 25 的指令中,调用了 Integer.intValue,将 Integer 类型转换为 int 类型,这就是自动拆箱。

增强for循环

for-each 的实现原理其实就是使用了普通的for循环和迭代器。

如下案例所示:

List<Integer> list = Arrays.asList(1234);
int sum = 0;
for (int i : list) {
  sum += i;
}
System.out.println(sum);

class 文件内容为:

public class GenericsTest {
  public GenericsTest() {
  }

  public static void main(String[] var0) {
    List var1 = Arrays.asList(1, 2, 3, 4);
    int var2 = 0;

    int var4;
    for(Iterator var3 = var1.iterator(); var3.hasNext(); var2 += var4) {
      var4 = (Integer)var3.next();
    }

    System.out.println(var2);
  }
}

遍历循环是把代码还原成了迭代器的实现, 这也是为何遍历循环需要被遍历的类实现 Iterable 接口的原因。

条件编译

—般情况下,程序中的每一行代码都要参加编译。但有时候出于对程序代码优化的考虑,希望只对其中一部分内容进行编译,此时就需要在程序中加上条件,让编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃,这就是条件编译。

如下案例所示:

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");
}

参考文献

Java 泛型

Javac 源码调试教程

《深入理解Java虚拟机》

泛型