【笔记】深入理解 Java 虚拟机:早期(编译期)优化

文章目录

  • 概述
  • javac 编译器
    • javac 编译过程
    • 解析与填充符号表
    • 注解处理器
    • 语义分析与字节码生成
      • 标注检查
      • 数据及控制流分析
      • 解语法糖
      • 字节码生成
  • Java 语法糖的味道
    • 泛型与类型擦除
    • 自动装箱、拆箱与遍历循环
    • 条件编译
  • 实战:插入式注解处理器
  • 总结

概述

在 Java 里有三类比较有代表性的编译器:

  1. 前端编译器:把 .java 源码文件,转化成字节码的 .class 文件。比如 Sun 的 javac,Eclipse JDT 中的增量式编译器(ECJ)。
  2. JIT 即时编译器:把字节码转变成机器码,比如 Hotspot VM 的 C1、C2 编译器。
  3. AOT 编译器:直接把 .java 文件编译成本地机器代码,比如 GNU Compiler For Java(GCJ),Excelsior JET。

这一章主要讲第一类编译器的编译过程,第二类在下一章讲。虚拟机设计团队把对性能的优化集中到了后端的即使编译器中,这样可以让那些不是由 javac 产生的 class 文件也能享受到编译优化带来的好处。但是 javac 做了许多针对 Java 语言编码过程中的优化措施来改善程序员的编码风格和提高编码效率。相当多新生的 Java 语法特性,都是靠编译器的语法糖来实现的,而不是依靠虚拟机的底层改进来支持。可以说,Java 中即使编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化对程序编码来说关系更加密切。

javac 编译器

javac 编译过程

分析源码是了解一项技术实现内幕最有效的手段,javac 编译器不像 Hotspot 虚拟机那样使用 C++ 语言(含有少量 C)实现,本身就是一个由 Java 语言编写的程序,为我们的学习带了便利。javac 的编译过程大致分为三个步骤:

  1. 解析与填充符号表过程。
  2. 插入式注解处理器的注解处理过程。
  3. 分析与字节码生成过程。

这三个步骤之间的交互顺序如下图所示:

javac编译过程

javac 编译动作的入库是 JavaCompiler 类,上述三个过程的代码逻辑集中在这个类的 compile() 方法中,其主要代码如下图所示,整个编译最关键的处理就由图中标注的 8 个方法来完成。

【笔记】深入理解 Java 虚拟机:早期(编译期)优化_第1张图片

解析与填充符号表

解析步骤由 parseFiles 方法完成,包括词法分析和语法分析两个过程。

词法分析是将源代码中的字符流转变成标记(Token)集合,单个字符是程序编写过程的最小元素,而标记则是编译过程的最小元素,关键字、变量名、字面量、运算符都可以成为标记。比如 int a=b+2 这句包含了 6 个标记,分别是 int、a、b、2、=、+。在 javac 的源码中,词法分析是由 Scanner 类来完成的。

语法分析是根据 Token 序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,语法树的每一个节点都代表着程序代码中的一个语法结构,例如包、类型、修饰符、运算符、接口、返回值甚至代码注释等都可以是一个语法结构。在 javac 源码中,语法分析过程由 Parser 类实现。

完成了词法分析和语法分析之后,下一步就是填充符号表的过程,也就是 enterTrees() 方法所做的事情。符号表是由一组符号地址和符号信息构成的表格,读者可以把它想象成哈希表中 K-V 值对儿的形式。符号表中登记的信息在编译的不同阶段都要用到,在语义分析中,符号表所登记的内容要用于语义检查和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

注解处理器

在 JDK1.6 中,提供了一组插入式注解处理器的标准 API,在编译期间对注解进行处理,可以被看做是一组编译器的插件。在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止。

有了编译器注解处理的标准 API 后,我们才能干涉编译器的行为,由于语法树中的任意元素,包括注释都可以在插件中访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。后面会给出一个使用插入式注解处理器的实战案例。

语义分析与字节码生成

语法分析之后,编译器获得了程序代码的抽象语法树表示,语法树能表示一个结构正确的源程序的抽象,但无法保证源程序是符合逻辑的。而语义分析的主要任务就是对结构式正确的源程序进行上下文有关性质的审查,如进行类型检查。举个例子,对于下面的 3 个变量:

int a = 1;
boolean b = false;
char c = 2;

后续可能出现的赋值运算如下所示:

int d1 = a + c;
int d2 = b + c;
char d3 = a + c;

在 Java 中,只有第一句能够通过编译,而 C 语言中三句都可以通过编译。

标注检查

在 Java 编译过程中,语义分析可以分为标注检查、数据及控制流分析两个步骤,分别由 attribute()、flow() 方法完成。标注检查步骤检查的内容包含变量使用前是否已声明、变量与赋值之间的数据类型是否匹配等。在标注检查步骤中,还有一个重要的动作称为常量折叠,如果代码中有以下定义:

int a = 1 + 2;

那么语法树上仍然能够看到字面量 1、2 以及操作符 +,但是经过常量折叠后,他们将会被折叠为字面量 3。标注检查在 javac 源码中是由 Attr 和 Check 类完成的。

数据及控制流分析

数据及控制流分析是对程序上下文逻辑更进一步的验证,可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被处理了等问题。编译时期的数据及控制流分析与类加载时的数据及控制流分析的目的基本上是一致的,但校验范围有所区别,有一些校验项只在编译期或运行期才能进行。下面举一个关于 final 修饰符的例子:

// 方法一 带有 final 修饰符
public void foo(final int arg) {
	final int var = 0;
	// do something
}

// 方法二 没有 final 修饰符
public void foo(int arg) {
	int var = 0;
	// do something
}

在这两个方法中,第一个方法的参数和局部变量使用了 final 修饰符,而第二个方法则没有。在代码编写时,程序肯定会受到 final 修饰符的影响,不能再改吧 arg 和 var 的值。但是,这两段代码编译出来的 Class 文件是没有任何一点儿区别的。在第 6 章讲 Class 类文件结构时说过,字段(实例变量、类变量)在常量池中有 CONSTANT_Fieldref_info 符号引用,而局部变量是没有的,同样没有的还有访问标志信息,自然也就没有 final 信息。因此,将局部变量声明为 final,对运行期是没有影响的,变量在不变性仅仅由编译器在编译期间保障。在 javac 源码中,数据以及控制流分析具体是由 Flow 类实现的。

解语法糖

语法糖,也称糖衣语法,是指在计算机语言中添加的某种语法,这种语法对功能没有影响,但更方便程序员使用。通常来说,使用语法糖能够增加程序的可读性,从而减少程序代码出错的机会。

Java 在现代语言中属于“低糖语言”,其常见的语法糖有:泛型、变长参数、自动装箱拆箱等。虚拟机在运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。在 Java 源码中,解语法糖由 desugar() 方法触发,在 TransTypes 类和 Lower 类中完成。

字节码生成

字节码生成是 Java 编译过程最后一个阶段,在 javac 源码里有 Gen 类来完成。该阶段不仅仅是把前面各个步骤所生成的信息转化成字节码写到磁盘中,编译器还进行了少量的代码添加和转化工作。例如,前面章节提到的实例构造器和类构造器就是在这个阶段添加到语法树之中的。完成对语法树的遍历和调整后,就会把填充了所需信息的符号表交给 ClassWriter 类,由这个类的 writeClass() 方法输出字节码生成最终的 Class 文件。

Java 语法糖的味道

每种语言都会或多或少提供一些语法糖方便程序员的代码开发,这些语法糖虽然不会提供实质性的功能改进,但是他们或能提高效率,或能提升语法的严谨性,或能减少编码出错的机会。不过,也有观点认为语法糖并不一定是有益的,大量添加和使用含“糖”的语法,容易让程序员产生依赖,语法看清语法糖背后程序的真面目。

总之,语法糖可以看做是编译器的一些“小把戏”,使用它们可以使得效率得到提升,但我们也应该去了解它们背后的原理。

泛型与类型擦除

泛型是 JDK1.5 的新增特性,它的本质是参数化类型的应用,也就是所操作的数据类型被指定为一个参数,这种参数类型可以用在类、接口、方法的创建中。

泛型思想早在 C++ 语言的模板中就开始生根发芽,在 Java 语言还处于没有泛型的版本时,只能通过 Object 是所有类型的父类和类型强制转化这两个特点来配合实现类型泛化。

在 C# 里,泛型无论是在源代码、还是编译后、运行期,都是切实存在的,List< int> 和 List< String> 就是两种不同的类型,它们在系统运行期生成、有自己的虚方法表和类型数据,这种实现称为类型膨胀,基于这种方法实现的泛型称为真实泛型。

而 Java 语言中的泛型则不同,它只存在于程序源码中,在编译后的字节码文件中已经替换为原生类型,并在相应的地方插入了强制转化代码。因此,对于运行期 Java 来说,ArrayList< int> 和 ArrayList< String> 是同一个类型,所以泛型技术实际上是 Java 语言的一颗语法糖,这种泛型实现方法被称为类型擦除,基于这种方法实现的泛型称为伪泛型。

在 Class 文件格式一章也讲到过,虽然 Java 采用擦除法实现泛型,但元数据中还是保留了泛型信息,主要依赖 Signature、LocalVariableTypeTable 等新引入的属性实现泛型参数类型的识别。

自动装箱、拆箱与遍历循环

从技术角度上讲,自动装箱、拆箱与遍历循环这些语法糖,虽然实现上和思想上都比泛型简单多了,但是它们是 Java 语言里使用最多的语法糖。

条件编译

许多程序设计语言都提供了条件编译的途径,比如 C、C++ 里使用预处理器指示符(#ifdef)来完成条件编译。C、C++ 里的预处理器最初的任务是解决编译时的代码依赖关系(比如常用的 #include)。而 Java 语言并没有预处理器,因为 Java 语言编译时是把所有编译单元的语法树顶级节点输入到待处理列表后再编译的,无需使用预处理器。

Java 语言里也可以使用条件编译,方法就是使用条件为常量的 if 语句,代码如下所示:

public static void main(String[] args) {
	if (true) {
		System.out.println("block 1");
	} else {
		System.out.println("block 2");
	}
}

编译之后的 class 文件里,只剩下了 “block 1” 这句:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String block 1
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 8: 0
        line 12: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;

只能使用条件为常量的 if 语句才能达到上面的效果,如果使用常量与其他带有条件判断的语句搭配,则可能在控制流分析中提示错误,被拒绝编译。比如下面的代码,编译器会提示“unreachable code”:

public static void main(String[] args) {
	while (false) {
		System.out.println("block 1");
	}
}

Java 语言里条件编译的实现,也是 Java 语言的一颗语法糖,根据布尔常量值的真假,编译器会把分支中不成立的代码块消除掉,这一工作将在编译器解除语法糖阶段完成。

实战:插入式注解处理器

这一节,我们使用注解处理器 API 来编写一款拥有自己编码风格的校验工具,要求:

  1. 类或接口:符合驼峰式命名法,首字母大写。
  2. 方法:符合驼峰式命名法,首字母小写。

要通过注解处理器 API 实现一个编译插件,首先需要了解这组 API 的基本知识,我们实现注解处理器的代码需要继承抽象类 javax.annotation.processing.AbstractProcessor。该抽象类只有一个必须覆盖的 abstract 方法:process(),它是 javac 编译器在执行注解处理器代码时要调用的过程。

    public abstract boolean process(Set<? extends TypeElement> annotations,
                                    RoundEnvironment roundEnv);

我们可以从 process 方法的第一个参数中获取此注解处理器所要处理的注解集合,从第二个参数 roundEnv 中可以访问到当前这个 Round 的语法树节点,每个语法树节点在这里表示一个 Element。下面可以看一下用于编码风格检查的 NameCheckProcessor 的具体代码:

@SupportedAnnotationTypes("*")
@SupportedSourceVersion(SourceVersion.RELEASE_8)
public class NameCheckProcessor extends AbstractProcessor {
    private NameChecker nameChecker;

    @Override
    public synchronized void init(ProcessingEnvironment processingEnv) {
        super.init(processingEnv);
        nameChecker = new NameChecker(processingEnv);
    }

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {

        if (!roundEnv.processingOver()) {
            for (Element element : roundEnv.getRootElements()) {
                nameChecker.checkNames(element);
            }
        }

        return false;
    }
}

NameCheckProcessor 主要是调用了 NameChecker 来进行代码检查,NameChecker 里有一个继承了 javax.lang.model.util.ElementScanner6 的 NameCheckScanner 类,以 Visitor 模式完成对语法树的遍历,分表执行 visitType、visitExecutable 来访问类、方法进行检查。

public class NameChecker {
    private final Messager messager;
    private NameCheckScanner nameCheckScanner = new NameCheckScanner();

    NameChecker(ProcessingEnvironment processingEnvironment) {
        messager = processingEnvironment.getMessager();
    }

    public void checkNames(Element element) {
        nameCheckScanner.scan(element);
    }

    private class NameCheckScanner extends ElementScanner6<Void, Void> {
        @Override
        public Void visitType(TypeElement e, Void aVoid) {
            scan(e.getTypeParameters(), aVoid);
            checkCamelCase(e, true);
            return super.visitType(e, aVoid);
        }

        @Override
        public Void visitExecutable(ExecutableElement e, Void aVoid) {
            if (e.getKind() == ElementKind.METHOD) {
                Name name = e.getSimpleName();
                if (name.contentEquals(e.getEnclosingElement().getSimpleName())) {
                    messager.printMessage(Diagnostic.Kind.WARNING, "一个普通方法 " + name + " 不应当和类名一样,避免和构造函数冲突", e);
                    checkCamelCase(e, false);
                }
            }

            return super.visitExecutable(e, aVoid);
        }

        private void checkCamelCase(Element e, boolean initialCaps) {
            String name = e.getSimpleName().toString();
            boolean previousUpper = false;
            boolean conventional = true;
            int firstCodePoint = name.codePointAt(0);
            if (Character.isUpperCase(firstCodePoint)) {
                previousUpper = true;
                if (!initialCaps) {
                    messager.printMessage(Diagnostic.Kind.WARNING, "名称" + name + " 应当以小写字母开头");
                    return;
                }
            } else if (Character.isLowerCase(firstCodePoint)) {
                if (initialCaps) {
                    messager.printMessage(Diagnostic.Kind.WARNING, "名称" + name + " 应当以大写字母开头", e);
                    return;
                }
            } else {
                conventional = false;
            }

            if (conventional) {
                int cp = firstCodePoint;
                for (int i = Character.charCount(cp); i < name.length(); i += Character.charCount(cp)) {
                    cp = name.codePointAt(i);
                    if (Character.isUpperCase(cp)) {
                        if (previousUpper) {
                            conventional = false;
                            break;
                        }

                        previousUpper = true;
                    } else {
                        previousUpper = false;
                    }
                }
            }

            if (!conventional) {
                messager.printMessage(Diagnostic.Kind.WARNING, "名称" + name + " 应当符合驼峰式命名法", e);
            }
        }
    }
}

整个注解处理器只需 NameCheckProcessor 和 NameChecker 两个类就可以全部完成,为了验证我们的实战成果,下面给出一段命名规范的“反教材”代码:

public class BADLY_NAMED_CODE {
    protected void BADLY_NAMED_CODE() {
        return;
    }

    public void NOTcamelMethod() {
        return;
    }
}

运行代码,可以看到编译时对命名不规范给出了警告。

admindeMacBook-Pro-8:java$ javac com/annotation/processor/test/NameChecker.java
admindeMacBook-Pro-8:java$ javac com/annotation/processor/test/NameCheckProcessor.java
admindeMacBook-Pro-8:java$ javac -processor com.annotation.processor.test.NameCheckProcessor com/annotation/processor/test/BADLY_NAMED_CODE.java
com/annotation/processor/test/BADLY_NAMED_CODE.java:7: 警告: 名称BADLY_NAMED_CODE 应当符合驼峰式命名法
public class BADLY_NAMED_CODE {
       ^
com/annotation/processor/test/BADLY_NAMED_CODE.java:8: 警告: 一个普通方法 BADLY_NAMED_CODE 不应当和类名一样,避免和构造函数冲突
    protected void BADLY_NAMED_CODE() {
                   ^
警告: 名称BADLY_NAMED_CODE 应当以小写字母开头
3 个警告

这里的实战只演示了注解处理器 API 一部分功能,基于这组 API 支持的项目还有用于校验 Hibernate 标签使用准确性的 Hibernate Validator、自动生成 getter 和 setter 方法的 Lombok。

总结

这一章,我们了解了编译器将源代码编译成字节码的主要过程,分析了 Java 语言中泛型、自动装箱/拆箱、条件编译等多种语法糖的前因后果,并实战练习了如何使用插入式注解处理器来完成一个检查程序命名规范的编译器插件。

你可能感兴趣的:(Java)