《深入理解Java虚拟机》第三版 - 08 - 程序编译与代码优化

Scroll Down

1. 编译器分类

  • 前端编译器:.java文件转变为.class文件的过程(比如Javac)
  • 后端编译器:
    • 即时编译器(JIT编译器,Just In Time Compiler):运行期把字节码转变成本地机器码的过程(比如HotSpot的C1、C2编译器,Graal编译器)
    • 提前编译器:把程序编译成与目标机器指令集相关的二进制代码的过程(比如GCJ)

2. 前端编译器&优化

2.1. Javac编译器

Untitled

从Javac代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程:

  1. 准备过程:初始化插入式注解处理器。
  2. 解析与填充符号表过程,包括:
    1. **词法、语法分析。**将源代码的字符流转变为标记集合,构造出抽象语法树。
    2. **填充符号表。**产生符号地址和符号信息。
  3. **插入式注解处理器的注解处理过程。**执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转回到之前的解析、填充符号表的过程中重新处理这些新符号。
  4. 语义分析,解语法糖与字节码生成过程,包括:
    1. **标注检查。**对语法的静态信息进行检查。
    2. **数据流及控制流分析。**对程序动态运行过程进行检查。
    3. **解语法糖。**将简化代码编写的语法糖还原为原有的形式。
    4. **字节码生成。**将前面各个步骤所生成的信息转化成字节码。

源码com.sun.tools.javac.main.JavaCompiler类:

Untitled

2.1.1. 解析与填充符号表

包括经典程序编译原理中的词法分析和语法分析两个步骤:

  • 词法、语法分析(#parseFiles方法)

    **词法分析是将源代码的字符流转变为标记(Token)集合的过程,单个字符是程序编写时的最小元素,但标记才是编译时的最小元素。**关键字、变量名、字面量、运算符都可以作为标记,如“int a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2,虽然关键字int由3个字符构成,但是它只是一个独立的标记,不可以再拆分。在Javac的源码中,词法分析过程由com.sun.tools.javac.parser.Scanner类来实现。语法分析是根据标记序列构造抽象语法树的过程,抽象语法树(Abstract Syntax Tree,AST)是一种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构(Syntax Construct),例如包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

  • 填充符号表(#enterTrees方法)

    **符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构。符号表中所登记的信息在编译的不同阶段都要被用到。**譬如在语义分析的过程中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的声明是否一致)和产生中间代码,在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。

2.1.2. 插入式注解处理器

“插入式注解处理器”是一个标准API,由与一般只在程序运行期间发挥作用的注解不同,这个API可以提前至编译期对代码中的特定注解进行处理,从而影响到前端编译器的工作过程。

插入式注解处理器初始化由#initPorcessAnnotations方法完成,处理过程则由#processAnnotations过程完成。

我们可以把插入式注解处理器看作是一组编译器的插件,当这些插件工作时,允许读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行过修改,编译器将回到解析及填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止,每一次循环过程称为一个轮次(Round),这也就对应着2.1.图中的那个回环过程。

最典型的一个应用:Lombok。

2.1.3. 语义分析,解语法糖与字节码生成

经过上述步骤,编译器得到了程序代码的AST(抽象语法树)的表示,但是源程序语义未必符合逻辑,因此需要对源程序进行检查,这就是语义分析的任务。

  • 语义分析分为标注检查&数据及控制流分析两个过程
    • 标注检查(#attribute方法)
      • 检查比如变量使用前是否被声明,变量与赋值之间数据类型是否匹配等问题
      • 常量折叠优化(int a = 1 + 2 → int a = 3)
    • 数据及控制流分析(#flow方法):对程序上下文逻辑进一步验证,检查比如程序局部变量在使用前是否有赋值、是否所有受查异常都被正确处理等
  • 解除语法糖(Syntactic Sugar)由#desugar方法完成。
  • 字节码生成由com.sun.tools.javac.jvm.Gen类完成。字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。(比如实例构造器()方法和类构造器()方法就是在这个阶段被添加到语法树之中的)

2.2. Java语法糖

语法糖(Syntactic Sugar)能够减少代码量、增加程序可读性,但对编译结果和功能并没有实际影响。

2.2.1. 范型

泛型的本质是参数化类型(Parameterized Type)或者参数化多态(Parametric Polymorphism)的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数,这种参数类型能够用在类、接口和方法的创建中,分别构成泛型类、泛型接口和泛型方法。泛型让程序员能够针对泛化的数据类型编写相同的算法,这极大地增强了编程语言的类型系统及抽象能力。

泛型的实现方式:

  • 类型擦除式(Type Erasure Generics),比如Java,只在程序源码中存在,编译后的字节码文件中全被替换为裸类型(Raw Type),在相应地方插入了强制转型代码。
  • 具现化式范型(Reified Generics),比如C#,无论是程序源码还是编译后的中间语言(Intermediate Language),范型都切实存在。

关于范型擦除:

  • 例子:

    package com.masaiqi;
    
    import java.util.HashMap;
    import java.util.Map;
    
    /**
     * test type erasure generics
     *
     *@author<a href="mailto:masaiqi.com@gmail.com">masaiqi</a>
    *@date2022/7/25 20:24
     */
    public class TestTypeErasureGenerics {
    
        public static void main(String[] args) {
            Map<String, String> map = new HashMap<>();
            map.put("1","a");
            map.put("2","b");
            System.out.println(map.get("1"));
            System.out.println(map.get("2"));
        }
    }
    

    对编译后的代码执行反编译:

    Untitled

    范型消失了,都变回了裸类型,在访问的位置加入了强制转换代码。

  • 因为涉及类型转换,因此Java范型不支持原生类型(非一个可转换的类)。

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

Demo:

package com.masaiqi;

import java.util.Arrays;
import java.util.List;

/**
 *@author<a href="mailto:masaiqi.com@gmail.com">masaiqi</a>
*@date2022/7/25 20:33
 */
public class Test2 {

    public static void main(String[] args) {
        List<Integer> list = Arrays.asList(1, 2, 3, 4);
        int sum = 0;
        for (Integer i : list) {
            sum += i;
        }
        System.out.println(sum);
    }
}

编译后反编译:

Untitled

可以看到:

  • 原生类型数字1,2,3,4在遍历时,被自动装箱(转换)为Integer类。
  • for-each loop被转换为了迭代器遍历。

3. 后端编译&优化

3.1. 即时编译器

当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并加以代码优化,这个任务由即时编译器完成。

3.1.1. 解释器与即时编译器并存架构

HotSpot虚拟机使用了解释器与即时编译器并存的架构,同时配置有多个不同的即时编译器应对不一样的使用场景。

解释器与即时编译器相互配合:

  • 当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率

  • 当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的JavaCard应用中就只有解释器的存在),反之可以使用编译执行来提升执行效率。

  • 解释器还可以作为编译器激进优化时后备的“逃生门”(如果情况允许,HotSpot虚拟机中也会采用不进行激进优化的客户端编译器充当“逃生门”的角色),让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类以后,类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行。

    Untitled

HotSpot虚拟机内置了多个即时编译器:

  • C1编译器(客户端编译器,Client Compiler)
  • C2编译器(服务端编译器,Server Compiler)
  • Graal编译器(目标代替C2,JDK10+)

HotSpot的分层编译(服务端默认开启):

解释器、客户端编译器和服务端编译器同时工作,热点代码都可能会被多次编译,用客户端编译器获取更高的编译速度,用服务端编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在服务端编译器采用高复杂度的优化算法时,客户端编译器可先采用简单优化来为它争取更多的编译时间。

3.2. 提前编译器

提前编译器可以把程序编译成与目标机器指令集相关的二进制代码。

提前编译器两种实现:

  • 在程序运行前把代码编译成机器码,这样会破坏Java的平台无关特性,但是会换取执行效率的提升,否则只通过即时编译器优化,则要占用程序运行时间和运算资源。

  • 动态提前编译(Dynamic AOT,或叫即时编译缓存,JIT Caching)。给即时编译器做缓存加速,改善Java程序后需要预热才能达到最高性能问题。

    受益于即时编译器的优化,这样做有几个好处:

    • **性能分析制导优化(Profile-Guided Optimization,PGO)。**HotSpot的即时编译器在解释器或者客户端编译器运行过程中,会不断收集性能监控信息,根据这些性能数据为导向进行优化。
    • **激进预测性优化(Aggressive Speculative Optimization)。**即时编译器优化不同于静态优化需要和优化前完全一样,可以又一些”冒险“的优化措施,如果有问题也有一些对应的”逃生门“(比如解释器执行)挽回。
    • **链接时优化(Link-Time Optimization,LTO)。**Java语言天生就是动态链接的,一个个Class文件在运行期被加载到虚拟机内存当中,然后在即时编译器里产生优化后的本地代码。