hao同学的技术博客

  • 首页
  • Java
    • Java
    • JVM教程
    • Java面试
    • Java并发入门
    • Java并发进阶
  • 项目
    • 从零打造项目
  • Python
    • Python
    • Python爬虫
    • 算法
  • Java框架
    • Spring
    • SpringBoot
  • 前端
    • Angular
  • 其他
    • Linux
    • SQL
  • 随笔
分享技术,记录人生
一个痴迷于技术的厨艺爱好者
  1. 首页
  2. JVM教程
  3. 正文

初识Javac编译器和Java语法糖

2022年5月8日 155点热度 0人点赞 0条评论

初识Javac编译器和Java语法糖插图

Javac编译器

概念

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

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

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

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

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

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

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

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

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

javac 编译器的执行过程

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

JavaCompiler类核心方法

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

IDEA配置

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

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

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

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

IDEA配置

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

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

Java代码调试

可以看出来,因为 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");
}

参考文献

Java 泛型

Javac 源码调试教程

《深入理解Java虚拟机》

泛型

本作品采用 知识共享署名-非商业性使用 4.0 国际许可协议 进行许可
标签: Java JVM 编译器
最后更新:2022年5月8日

hresh

这是一个专注于IT技术学习交流的个人技术博客网站,包括Java学习、Python爬虫、Web开发实践等领域,深耕Java领域,内容涵盖Java基础、Java并发编程、Java虚拟机、Java面试等核心知识点。

点赞
< 上一篇
下一篇 >

文章评论

取消回复

hresh

这是一个专注于IT技术学习交流的个人技术博客网站,包括Java学习、Python爬虫、Web开发实践等领域,深耕Java领域,内容涵盖Java基础、Java并发编程、Java虚拟机、Java面试等核心知识点。

文章目录
  • Javac编译器
    • 概念
    • 小试牛刀
  • Java语法糖
    • 定义
    • 泛型与类型擦除
    • 自动装箱、拆箱
    • 增强for循环
    • 条件编译
  • 参考文献
最新 热点 随机
最新 热点 随机
后端必知:遵循Google Java规范并引入checkstyle检查 Spring Security结合Redis实现缓存功能 Spring Security结合JWT实现认证与授权 Spring Security自定义认证逻辑实现图片验证码登录 Spring Security进阶学习 Spring Security入门学习
Angular之Rxjs学习 Java面试准备之JVM系列三 Spring Boot注解学习之@SpringBootApplication(一) Python多线程之Queue String类之字符串常量池与intern方法 Spring Security入门学习

COPYRIGHT © 2022 hao同学的技术博客. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang

鄂ICP备2022007381号

鄂公网安备 42010302002449号