前言:介绍Java程序从源码编译成字节码和从字节码编译成本地机器码的过程,Javac字节码编译器和虚拟机内的JIT编译器的执行过程合并起来其实就等同于一个传统的编译器所执行的编译过程。

一、编译期优化

  • 前端编译器:把*.java文件转变成*.class文件的过程。常见有Sun的Javac、Eclipse JDT的增量是编译器(ECJ)。
  • 后端运行期编译器:JIT编译器(Just In Time Compiler),字节码转换为机器码。常见有HotSpot VM的C1、C2编译器。
  • 静态提前编译器:AOT编译器(Ahead Of Time Compiler),直接把*.java文件编译成本地机器代码。常见的有GNU Compiler for the Java(GCJ)、Excelsior JET。

第一类前端编译器是最符合普遍对Java程序编译器的认知。Javac做了许多针对编码过程的优化措施来改善程序员的编码风格和提高编码效率。Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译器优化过程对于程序编码来说关系更加密切。

1. Javac编译器

Javac由Java语言编写,编译过程大致分为三个过程:

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

Javac的编译过程.png

解析和填充符号表

  • 词法、语法分析:
    词法分析将源代码的字符流转变为标记(Token)集合,标记时编译过程的最小元素,语法分析是根据Token序列来构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式。后续的操作都建立在抽象语法树之上。
  • 填充符号表:
    符号表是一组符号地址和符号信息构成的表格,可以理解为哈希表中的K-V值对的形式,符号表在语义分析中用于语义检查和产生中间代码,在目标代码生成阶段对符号名进行地址分配。

注解处理器

JDK 1.5之后Java提供了对注解的支持,注解也是在运行期间发挥作用的。注解处理器可以读取、修改、添加抽象语法书中的任意元素,如果插件在处理注解期间对语法书进行了修改,那么编译器将回到解析及填充符号表的过程重新处理,直到插入式注解处理器没有对语法树进行修改为止。

语义分析与字节码生成

由源程序生成的一个结构正确的抽象语法树后,还是无法保证源程序是符合逻辑的,语义分析主要任务就是对结构上正确的源程序进行上下文有关性质的审查。

  • 标注检查
    检查内容包括变量使用前是否被声明、变量与赋值之间的数据类型是否能够匹配。
  • 数据及控制流分析
    对程序的上下文逻辑更进一步的验证,例如局部变量使用前是否有赋值、方法是否有返回值、是否所有受查异常都能被正确处理等。
  • 解语法糖
    Java中常见的语法糖包括泛型、变长参数、自动装箱拆箱等,虚拟机运行时不支持这些语法,它们在编一阶段被还原回简单的基础语法结构。
  • 字节码生成
    把前面各个步骤所生成的信息转化成字节码写到磁盘中,同时也进行了少量的代码添加和转化工作。

2. Java语法糖

语法糖虽然没有实质性的功能改进,但能提高效率、提升语法严谨性、减少出错的机会,也有人认为语法糖会让程序员产生依赖,让程序员无法看清语法糖背后的程序代码的真实面目。

泛型与类型擦出

泛型的本质是参数化类型(Parameterized Type)的应用,即所操作的数据类型可以被指定为一个参数,可以用在类、接口和方法的创建中,分别为泛型类、泛型接口和泛型方法。

Java语言的泛型存在于程序源码中,在编译后的字节码文件中就被替换为原来的原生类型,Java中泛型的实现方法被称为类型擦除,这种方式实现的泛型也被叫做伪泛型,例如运行期中ArrayList和ArrayList就是用一个类。不同于C#中泛型在源码、编译后、运行期都是切实存在的真实泛型。

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

自动装箱、拆箱在编译之后被转化成了对应的包装盒还原方法,遍历循环则是把代码还原成了迭代器的实现。在实际使用中尽量避免让代码来做一些复杂的自动装箱与拆箱。

条件编译

Java语言中的条件编译不同于C、C++使用预处理器指示符来完成条件编译,Java中的条件编译使用条件为常量的if语句,这样的if语句在编译阶段就会被“运行”。这样的语法糖会在编译阶段将分支不成立的代码块消除掉。

Java中除了这些语法糖之外,还有内部类、枚举类、断言语句、支持枚举和字符串的switch、在try中定义和关闭资源等。


二、运行期优化

这部分主要描述的是HotSpot虚拟机内的即时编译器(Just In Time Compiler)的行为,与多数主流的虚拟机中的即时编译器的行为有很多相似相通之处。

1. HotSpot虚拟机内的即时编译器

主流的商用虚拟机都同时包含解释器与编译器,当程序需要迅速启动和执行的时候,解释器首先发挥作用,省去编译的时间,立即执行,当程序运行时,编译器逐渐发挥作用,把越来越度的代码编译成本地代码,可以获得更高的执行效率。

当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”,会把这些代码编译成本地平台相关的机器码提高热点代码的执行效率。HotSpot中使用的是基于计数器的热点探测方法来进行热点代码的判断。

2. 编译优化技术

以编译方式执行本地代码要比解释方式更快,很重要的一个原因就是有很多的优化措施都集中在了即时编译器之中。

即时编译优化技术有很多,详细的了解可以查阅即时编译器优化一览。这些技术大部分理解起来并不困难,比如一些很常见的冗余存储消除、复写传播、无用代码消除等,简单点就是将一些复杂的语句提取重点改写的更优雅。

编译器也会进行公共子表达式消除的操作,如果一个表达式E已经被计算过,并且先前的计算到现在E中所有变量的值都没有改变,那么E再次出现直接用前面计算过的表达式结果就可以了。并且对表达式还会进行另一种优化:代数化简,即一些代数上的交换结合变换。

除了这些,编译器还会做简单情况的数组边界检查消除、方法内联和逃逸分析。

逃逸分析的基本行为就是分析对象的动态作用域,当一个对象在方法里被定义后,它可能被外部方法所引用。作为调用参数传递到其他方法中,甚至还有可能被外部线程访问到,称为方法逃逸;赋值给类变量或可以再其他线程中访问的实体变量,称为线程逃逸。对于这些不会发生逃逸的情况有一些虚拟机优化措施,例如不会线程逃逸的对象分配到栈上、不会逃逸的变量消除掉同步措施、一个对象不会被外部访问就会被拆解为多个原始数据类型进行访问。

3. Java与C/C++的编译器对比

Java和C/C++的对比实际上也就是最经典的即时编译器和静态编译器的对比,编译器的对比很大程度上也就是他们之间性能的对比。

即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,能提供的优化手段也受制于编译成本。

Java语言是动态的类型安全语言,需要虚拟机来确保程序不会出现违反语义或者非结构化内存的情况,也就是虚拟机必须频繁地进行动态检查,例如实例方法访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等。

Java中使用虚方法的频率要远大于C/C++,即运行时对方法接收者进行多态选择的频率要更大,也意味着即时编译器在进行一些优化时的难度要远远大于C/C++的静态优化编译器。

Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,使得全局的优化都难以进行,如果激进优化的话随着类型的变化会在运行时撤销或者重新优化。

Java语言的内存分配在对上进行,只有方法中的局部变量才能在栈上分配,栈上分配的在垃圾回收的压力上要小很多,C/C++的对象就有多种内存分配方式,并且主要由用户程序代码来回收分配的内存,没有对无用对象进行筛选的过程,效率比Java垃圾回收机制要高。

所有的这些对比也可以发现,Java的性能劣势都是为了换取开发效率上的优势来的,例如动态安全、动态扩展、垃圾回收,并且Java以运行期性能监控为基础的优化措施C/C++的静态编译器都没法实现。


Ref

周志明. 深入理解Java虚拟机[M]. 机械工业出版社, 2013.