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

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

1.jpg

本文为《深入学习 JVM 系列》第十四篇文章


Javac编译器


概念


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


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


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


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


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


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


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


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


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


1.jpg


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()这两个方法,这里直接引用书中的图片。


2.jpg


关于这部分代码,感兴趣的朋友可以先去了解一下,具体介绍可以参考《深入理解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 的启动配置中,如下图所示:


4.jpg


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


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


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

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


5.jpg


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


实操: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 文件,得到如下内容:


6.jpg


可以看出来,因为 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 = {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。但实际应用时又遇到新的问题,比如说我们往 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(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");
}
目录
相关文章
|
16天前
|
Oracle Java 关系型数据库
java体系结构和jvm
java体系结构和jvm
|
1月前
|
Java 编译器 测试技术
滚雪球学Java(03):你知道JDK、JRE和JVM的不同吗?看这里就够了!
【2月更文挑战第12天】🏆本文收录于「滚雪球学Java」专栏,专业攻坚指数级提升,助你一臂之力,带你早日登顶🚀,欢迎大家关注&&收藏!持续更新中,up!up!up!!
105 4
|
1月前
|
存储 Java 数据安全/隐私保护
【JVM】Java虚拟机栈(Java Virtual Machine Stacks)
【JVM】Java虚拟机栈(Java Virtual Machine Stacks)
36 0
|
27天前
|
缓存 Java C#
【JVM故障问题排查心得】「Java技术体系方向」Java虚拟机内存优化之虚拟机参数调优原理介绍(一)
【JVM故障问题排查心得】「Java技术体系方向」Java虚拟机内存优化之虚拟机参数调优原理介绍
76 0
|
2天前
|
监控 Ubuntu Java
Java VisualVM远程监控JVM
Java VisualVM远程监控JVM
Java VisualVM远程监控JVM
|
8天前
|
缓存 监控 Java
深入理解Java虚拟机(JVM)性能调优
【4月更文挑战第18天】本文探讨了Java虚拟机(JVM)的性能调优,包括使用`jstat`、`jmap`等工具监控CPU、内存和GC活动,选择适合的垃圾回收器(如Serial、Parallel、CMS、G1),调整堆大小和新生代/老年代比例,以及代码优化和JIT编译策略。通过这些方法,开发者能有效提升应用性能并应对复杂性挑战。性能调优是持续过程,需伴随应用演进和环境变化进行监控与优化。
|
1月前
|
存储 缓存 安全
[Java基础]——JVM内存模型
[Java基础]——JVM内存模型
|
1月前
|
算法 Java UED
【JVM】分代收集算法:提升Java垃圾回收效率
【JVM】分代收集算法:提升Java垃圾回收效率
21 0
|
1月前
|
Java
【JVM】深入理解Java引用类型:强引用、软引用、弱引用和虚引用
【JVM】深入理解Java引用类型:强引用、软引用、弱引用和虚引用
100 0
|
1月前
|
存储 安全 Java
【JVM】Java堆 :深入理解内存中的对象世界
【JVM】Java堆 :深入理解内存中的对象世界
53 0