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()这两个方法,这里直接引用书中的图片。
关于这部分代码,感兴趣的朋友可以先去了解一下,具体介绍可以参考《深入理解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 的启动配置中,如下图所示:
执行 Main 文件,可以得到一个 HelloWorld.class 文件。
学习源码最好的方式就是断点调试,一步步查看执行过程,来验证学习。那么如何在上述项目中进入断点调试呢?
首先在 main 方法内打一个断点,然后 debug 执行 main 方法,结果发现调试停在了 Main.class 的断点处,再定位一看,发现是 JDK8 的 tools.jar 包中的 class 文件。
为了让断点走 Javac 源码,可以这样修改 Project Structure,将
再次执行代码,可以发现调试停留在了源码的断点处。
实操: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 中的语法糖主要有以下几种:
-
泛型与类型擦除 -
自动装箱与拆箱,变长参数 -
增强for循环 -
内部类与枚举类
泛型与类型擦除
泛型的本质是参数化类型(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 = {1, 2, 3, 4, 5};
Double[] doubleArray = {1.1, 2.2, 3.3, 4.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
桥接方法
泛型的类型擦除带来了不少问题。其中一个便是方法重写。
对于 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
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(1, 2, 3, 4);
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虚拟机》
泛型