• 深入理解Java虚拟机(第3版)学习笔记——前端编译与优化(超详细)


    第十章 前端编译与优化

    10.1 概述

    Java技术下讨论“编译期”需要结合具体上下文语境,因为它可能存在很多种情况:

    • 前端编译器(叫“编译器的前端”更准确一些)把==*.java文件转变成*.class文件==的过程

      JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)

    • 即时编译器(常称JIT编译器,Just In Time Compiler)运行期把字节码转变成本地机器码的过程

      HotSpot虚拟机的C1、C2编译器,Graal编译器

    • 提前编译器(常称AOT编译器,Ahead Of Time Compiler)直接把程序编译成与目标机器指令集相关的二进制代码的过程

      JDK的Jaotc、GNU Compiler for the Java(GCJ)、Excelsior JET 。

    本章标题中的“前端”指的是由前端编译器完成的编译行为,对于前端编译优化,有以下说法:

    1. 前端编译器对代码的运行效率几乎没有任何优化措施可言

    2. Java虚拟机设计团队选择把对性能的优化全部集中到运行期的即时编译器

      这样可以让那些不是由Javac产生的Class文件也同样能享受到编译器优化措施所带来的性能红利

    3. 相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖字节码或者Java虚拟机的底层改进来支持。

    4. Java中即时编译器在运行期的优化过程,支撑了程序执行效率的不断提升;

    5. 前端编译器在编译期的优化过程,支撑着程序员的编码效率和语言使用者的幸福感的提高

    10.2 Javac编译器

    10.2.1 Javac的源码和调试

    从Javac源代码的总体结构来看,编译过程大致可以分为1个准备过程和3个处理过程,它们分别如下所示:

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

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

      • 词法、语法分析:将源代码的字符流转变为标记集合,构造出抽象语法树
      • 填充符号表:产生符号地址和符号信息
    3. 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段

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

      • 标注检查:对语法的静态信息进行检查。
      • 数据流及控制流分析:对程序动态运行过程进行检查。
      • 解语法糖:将简化代码编写的语法糖还原为原有的形式。
      • 字节码生成:将前面各个步骤所生成的信息转化成字节码。
    5. 对于以上过程:执行插入式注解时又可能会产生新的符号,如果有新的符号产生,就必须转 回到之前的解析、填充符号表的过程中重新处理这些新符号

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KKk48wI2-1658907802375)(D:\note\笔记仓库\图片\image-20220727105014251.png)]

    6. 整个编译过程主要的处理由图中标注的8个方法来完成

      [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IlHuf8qp-1658907802376)(D:\note\笔记仓库\图片\image-20220727105045637.png)]

    10.2.2 解析和填充符号表

    1. 词法、语法分析

    词法分析:词法分析是将源代码的字符流转变为标记(Token)集合的过程。

    • 单个字符是程序编写时的最小元素

    • 标记才是编译时的最小元素

    • 关键字、变量名、字面量、运算符都可以作为标记

      如“int a=b+2”这句代码中就包含了6个标记,分别是int、a、=、b、+、2

    • 在Javac的源码中,词法分析过程由 com.sun.tools.javac.parser.Scanner类来实现。

    语法分析:语法分析是根据标记序列构造抽象语法树的过程

    • 抽象语法树:抽象语法树(Abstract Syntax Tree,AST)是一 种用来描述程序代码语法结构的树形表示方式,抽象语法树的每一个节点都代表着程序代码中的一个语法结构

      包、类型、修饰符、运算符、接口、返回值甚至连代码注释等都可以是一种特定的语法结构。

    • 抽象语法树可通过Eclipse AST View插件查看,抽象语法树是以com.sun.tools.javac.tree.JCTree 类表示的

    • 经过词法和语法分析生成语法树以后,编译器就不会再对源码字符流进行操作了,后续的操作都建立在抽象语法树之上

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fcEFwqtU-1658907802376)(D:\note\笔记仓库\图片\image-20220727110009066.png)]

    2. 填充符号表

    符号表(Symbol Table)是由一组符号地址和符号信息构成的数据结构(可以理解成哈希表中的键值对的存储形式)

    符号表中所登记的信息在编译的不同阶段都要被用到:

    • 语义分析的过程中,符号表所登记的内容将用于语义检查 (如检查一个名字的使用和原先的声明是否一致)和产生中间代码
    • 目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的直接依据。

    在Javac源代码中,填充符号表的过程由com.sun.tools.javac.comp.Enter类实现,该过程的产出物是一个待处理列表,其中包含了每一个编译单元的抽象语法树的顶级节点,以及package-info.java(如果 存在的话)的顶级节点

    10.2.3 注解处理器

    JDK 5之后,Java语言提供了对注解的支持,注解在设计上原本是与普通的Java代 码一样,都只会在程序运行期间发挥作用的。

    但在JDK 6之后,设计了一组称为**“插入式注解处理器”**的标准API。可以==提前至编译期对代码中的特定注解进行处理, 从而影响到前端编译器的工作过程==。

    注解处理器的工作流程:

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

    有了编译器注解处理的标准API后,程序员的代码才有可能干涉编译器的行为,由于语法树中的任意元素,甚至包括代码注释都可以在插件中被访问到,所以通过插入式注解处理器实现的插件在功能上有很大的发挥空间。

    程序员能使用插入式注解处理器来实现许多原本只能在编码中由人工完成的事情:例如Java著名的编码效率工具Lombok,就是利用了这一点。

    10.2.4 语义分析与字节码生成

    语义分析的主要任务是对结构上正确的源程序进行上下文相关性质的检查,譬如进行类型检查、控制流检查、数据流检查,等等

    1.标注检查

    标注检查步骤要检查的内容包括诸如变量使用前是否已被声明、变量与赋值之间的数据类型是否能够匹配等等。

    常量折叠优化:Javac编译器会对源代码做的极少量优化措施之一(代码优化几乎都在即时编译器中进行)

    例如:如下的变量定义:

    int a = 1 + 2
    
    • 1

    则在抽象语法树上仍然能看到字面量==“1”“2”和操作符“+”号==,但是在经过常量折叠优化之后,它们将会被折叠为字面量“3”

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-W9uQcQCA-1658907802377)(D:\note\笔记仓库\图片\image-20220727124743414.png)]

    2.数据及控制流分析

    数据流分析和控制流分析是对程序上下文逻辑更进一步的验证,与类加载时的数据与控制流分析的目的基本一致。

    可以检查出诸如程序局部变量在使用前是否有赋值、方法的每条路径是否都有返回值、是否所有的受查异常都被正确处理了等问题

    例如:常量的不变性就是利用编译器数据及控制流分析来实现的(局部常量和普通变量编译出来的字节码是没有区别的,在编译期通过编译期去识别final标志)

    3.解语法糖

    语法糖:指的是在计算机语言中添加的某种语法,这种语法对语言的编译结果和功能并没有实际影响, 但是却能更方便程序员使用该语言。

    引入语法糖的优点以及缺点

    • 优点:能提高效率,或能提升语法的严谨性,或能减少编码出错的机会;
    • 缺点:容易让程序员产生依赖,无法看清语法糖的糖衣背后,程序代码的真实面目。

    Java中最常见的语法糖包括了泛型、变长参数、自动装箱拆箱,等等。

    解语法糖:Java虚拟机运行时并不直接支持这些语法,它们在编译阶段被还原回原始的基础语法结构,这个过程就称为解语法糖

    4.字节码生成

    字节码生成阶段不仅仅是把前面各个步骤所生成的信息(语法树、符号表)转化成字节码指令写到磁盘中,编译器还进行了少量的代码添加和转换工作。

    实例构造器()方法和类构造器()方法就是在这个阶段被添加到语法树之中的。

    注意()方法和()方法这两个构造器的产生实际上是一种代码收敛的过程:

    • 编译器会把语句块(对于实例构造器而言是“{}”块,对于类构造器而言是“static{}”块)、变量初始化(实例变量和类变量)、调用父类的实例构造器(仅仅是实例构造器,(()方法中无须调用父类的(()方法,Java虚拟机会自动保证父类构造器的正确执行,但在()方法中经常会生成调用java.lang.Object()方法的代码)等操作收敛到()和 ()方法之中,并且保证无论源码中出现的顺序如何,都一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行,上面所述的动作由Gen::normalizeDefs()方法来实现。

    如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、可访问性(public、protected、private或)与当前类型一致的默认构造函数,这个工作在填充符号表阶段中就已经完成。

    完成了对语法树的遍历和调整之后,就会把填充了所有所需信息的符号表交到 com.sun.tools.javac.jvm.ClassWriter类手上,由这个类的**writeClass()**方法输出字节码,生成最终的Class 文件,到此,整个编译过程宣告结束

    10.3 Java语法糖的味道

    10.3.1 泛型

    Java中泛型的本质是参数化类型或者参数化多态的应用,即可以将操作的数据类型指定为方法签名中的一种特殊参数

    1. Java与C#的泛型的区别
    • Java选择的泛型实现方式叫作“类型擦除式泛型:Java语言中的泛型只在程序源码中存在,在编译后的字节码文件中,全部泛型都被替换为原来的裸类型了,并且在相应的地方插入了强制转型代码

      因此对于运行期的Java语言来说,ArrayList与ArrayList其实是同一个类型,由此读者可以想象“类型擦除”这个名字的含义和来源

    • C#选择的泛型实现方式是“具现化式泛型:C#里面泛型无论在程序源码里面、编译后的中间语言表示(Intermediate Language,这时候泛型是一个占位符)里面,抑或是运行期的CLR里面都是切实存在的

    • Java的类型擦除式泛型无论在使用效果上还是运行效率上,几乎是全面落后于C#的具现化式泛型

    • Java的类型擦除式泛型的唯一优势是在于实现这种泛型的影响范围上:擦除式泛型的实现几乎只需要在Javac编译器上做出改进即可,不需要改动字节码、不需要改动Java虚拟机,也保证了以前没有使用泛型的库可以直接运行在Java 5.0之上

    2. 泛型的历史背景

    当时设计者们在泛型的设计上大体有两种方式可以选择:

    1. 需要泛型化的类型(主要是容器类型),以前有的就保持不变,然后平行地加一套泛型化版本的新类型。
    2. 直接把已有的类型泛型化,即让所有需要泛型化的已有类型都原地泛型化,不添加任何平行于已有类型的泛型版。

    考虑到遗留代码规模的问题(当时java已有10年历史,而C#只有两年的历史),java选择了第二种方式,C#选择了第一种方式。

    3. 类型擦除

    “裸类型”(Raw Type)的概念:裸类型应被视为所有该类型泛型化实例的共同父类型(Super Type)

    代码清单10-4 裸类型赋值

    ArrayList<Integer> ilist = new ArrayList<Integer>();
    ArrayList<String> slist = new ArrayList<String>();
    ArrayList list; // 裸类型
    list = ilist;
    list = slist;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    如何实现裸类型

    直接在编译时把ArrayList通过类型擦除还原回ArrayList,只在元素访问、修改时自动插入一些强制类型转换和检查指令

    代码清单10-5是一段简单的Java泛型例子,我们可以看一下它编译后的实际样子是怎样的:

    代码清单10-5 泛型擦除前的例子

    public static void main(String[] args) {
        Map<String, String> map = new HashMap<String, String>();
        map.put("hello", "你好");
        map.put("how are you?", "吃了没?");
        System.out.println(map.get("hello"));
        System.out.println(map.get("how are you?"));
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    把这段Java代码编译成Class文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了

    public static void main(String[] args) {
        Map map = new HashMap();
        map.put("hello", "你好");
        map.put("how are you?", "吃了没?");
        System.out.println((String) map.get("hello"));
        System.out.println((String) map.get("how are you?"));
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    想一想,对于一些值类型的参数在类型擦除后,它能转化为Object接收吗?

    代码清单10-7 原始类型的泛型(目前的Java不支持

    ArrayList<int> ilist = new ArrayList<int>();
    ArrayList<long> llist = new ArrayList<long>();
    ArrayList list;
    list = ilist;
    list = llist;
    
    • 1
    • 2
    • 3
    • 4
    • 5

    因为不支持int、long与Object之间的强制转型,所以答案是不能的

    java给出的解决方案是:

    • 不支持原生类型的泛型,只能使用一些原生类型的包装类作为参数,例如用ArrayList、ArrayList,遇到原生类型是就自动装箱与拆箱。
    想一想,当泛型遇到重载时,还能够正常编译吗?

    代码清单10-9 当泛型遇见重载1

    public class GenericTypes {
        public static void method(List<String> list) {
        	System.out.println("invoke method(List list)");
        }
        public static void method(List<Integer> list) {
        	System.out.println("invoke method(List list)");
        }
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aNoDGlkl-1658907802378)(D:\note\笔记仓库\图片\image-20220727153442683.png)]

    这段代码是不能被编译的,因为参数List和List编译之后都被擦除了,变成了同一种的裸类型List, 类型擦除导致这两个方法的特征签名变得一模一样。

    由于Java泛型的引入,各种场景(虚拟机解析、反射等)下的方法调用都可能对原有的基础产生影响并带来新的需求,所以JCP组织对《Java虚拟机规范》做出了相应的修改:引入了诸如SignatureLocalVariableTypeTable等新的属性用于解决伴随泛型而来的参数类型的识别问题:

    • Signature是其中最重要的一项属性,它的作用就是存储一个方法在字节码层面的特征签名

      这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息

    • 从Signature属性的出现我们还可以得出结论:擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们在编码时能通过反射手段取得参数化类型的根本依据。

    • 修改后的虚拟机规范要求所有能识别49.0(JDK 5)以上版本的Class文件的虚拟机都要能正确地识别Signature参数。

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

    代码清单10-11 自动装箱、拆箱与遍历循环

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

    代码清单10-12 自动装箱、拆箱与遍历循环编译之后

    public static void main(String[] args) {
        List list = Arrays.asList( new Integer[] {
        Integer.valueOf(1),
        Integer.valueOf(2),
        Integer.valueOf(3),
        Integer.valueOf(4) });
        int sum = 0;
        for (Iterator localIterator = list.iterator(); localIterator.hasNext(); ) {
            int i = ((Integer)localIterator.next()).intValue();
            sum += i;
        }
        System.out.println(sum);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    代码清单10-11中一共包含了泛型、自动装箱、自动拆箱、遍历循环与变长参数5种语法糖,代码 清单10-12则展示了它们在编译前后发生的变化。

    10.3.3 条件编译

    Java语言天然的编译方式(编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译,因此各个文件之间能够互相提供符号信息)就无须使用到预处理器。

    代码清单10-14 Java语言的条件编译

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

    该代码编译后Class文件的反编译结果:

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

    只能使用条件为常量if语句才能达到上述效果:根据布尔常量值的真假,编译器将会把分支中不成立的代码块消除掉

  • 相关阅读:
    2022/07/04学习记录
    Golang JWT 认证 (三)-添加token自动刷新机制
    内网渗透学习-环境搭建
    mysql 数字类型和数字函数
    无效的训练数据。序列响应必须与对应的预测变量具有相同的序列长度。哪里出了问题
    make与makefile
    《PyTorch深度学习实战》学习小结
    基于Appian低代码平台开发一个SpaceX网站
    电商运营管理——广告系统
    什么是内存泄漏,为什么threadlocal会造成内存泄漏?
  • 原文地址:https://blog.csdn.net/qq_53578500/article/details/126016352