JVM之前端编译与优化
编译期是一个很广泛的含义,它可能是指前端编译器(即“编译器的前端”)把.java
文件转换为.class
文件的过程。也可能是Java虚拟机的即时编译器(JIT编译器,Just In Time)运行期把字节码转变成本地机器码的过程。也有可能是静态的提前编译器(AOT编译器,Ahead Of Time Compile)直接把程序编译成目标机器指令集相关的二进制代码的过程。
- 前端编译器:JDK的Javac、Eclipse JDT中的增量式编译器(ECJ)。
- 即时编译器:HotSpot的C1、C2编译器,Graal编译器。
- 提前编译器:JDK的Jaotc、GUN Compile for the Java(GCJ)、Excelsior JET。
本章主要讨论的是第一种,前端编译器。
1. Javac编译器
openJDK源码。jdk9之后,javac位于src/jdk.compiler/share/classes/com/sun/tools/javac
目录下。源码查看
1 |
|
从javac代码总体结构来看,编译过程大致分为1个准备过程和3个处理过程。
准备过程:0. 初始化插入式注解处理器。
处理过程:1. 解析与填充符号表。2. 插入式注解处理器的注解处理。3. 分析与字节码生成。
如果注解处理器在处理注解期间对语法树进行了修改,编译器将回到解析和填充符号表的过程进行重新处理,直到注解处理器没有再对语法树进行修改为止。如下图所示。
解析与填充符号表
词法、语法分析。将源码的字符流转变为标记集合,构造出抽象语法树。
填充符号表。产生符号地址和符号信息。
插入式注解处理器的注解处理
分析与字节码生成
标注检查。检查语法静态信息。
数据流及控制流分析。检查程序动态运行过程。
解语法糖。将简化代码编写的语法糖还原为原有形式。
字节码生成。将前面各个步骤生成的信息转化为字节码。
1.1 解析与填充符号表
Parse()解析字符流
1 |
|
词法分析
将源代码的字符流转换为标记(Token)集合。
语法分析
根据Token集合构造抽象语法树。语法树每一个节点代表着代码中的语法结构,比如包、类型、运算符等。
填充符号表
符号表=<符号地址, 符号信息>,它类似于哈希表。符号表中登记的信息在在编译的不同阶段都会用到。填充完后会产生一个待处理列表,其中包含了每一个抽象语法树的顶级节点以及package-info.java的顶级节点。
1 |
|
1.2 注解处理器
插入式注解处理器,可以看作是一组编译器的插件。当这些插件工作时,允许读取、修改、增加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析及填充符号表的过程重新处理,直到是所有的注解处理器都不再对语法树进行修改为止。
著名的效率工具Lombok就是通过注解来实现对编译过程中的字段的进行getter/setter等处理的。
1.3 语义分析与字节码生成
int a = 1;boolean b = true;char c = a + b;
这3条语句,在词法分析语法分析时都没有问题,可以生成合法的语法树,但是语句3是错误的。因此必须对抽象语法树进行语义分析。
语义分析分为标注检查和数据流控制流分析2步。
标注检查
这一步主要做的工作如下。
检查:变量使用前是否已经被声明、变量与赋值之间的数据类型是否匹配
优化:常量折叠。比如int a = 1 + 2;
会优化成int a = 3;
数据流及控制流分析
检查:局部变量在使用前是否已经赋值,方法的每条路径是否有返回值、受检异常是否做了正确处理等。
这一步检查和类加载时的数据和控制流检查的目的是一致的,但是有些检查只能在语义分析时检查出来。比如
1 |
|
以上2个方法的编译出来的字节码是一样的,因为局部变量在常量池中没有CONSTAN_Fieldref_info的引用,因此不可能存访问标志信息,自然在Class文件中不可能判断一个局部变量是否被声明为final了。变量的不变性只能由javac在编译意见进行数据流及控制流分析的时候来保证。
解语法糖
语法糖其实是对语法的简化,Java最常见的语法糖是泛型、变长参数、自动装拆箱等。java虚拟机在运行时并不直接支持这些语法,因此在编译阶段他们必须被还原成基础语法结构。这个还原的过程被称为解语法糖。具体如何解请看下一节。
字节码生成
该阶段主要是把前面各个步骤生成的信息语法树和符号表转化为字节码指令写到磁盘中。此外,还会进行少量的代码添加和转换工作。
比如,实例构造器的<init>()
和类构造器的<clinit>()
方法就是在这个阶段被添加的。注意:这不是默认的构造函数,默认构造函数生成这一步在填充符号表阶段就已经完成。
<init>()
和<clinit>()
实际上是代码收敛的过程。编译器会把语句块{}
、实例变量初始化、调用父类的实例构造器等操作收敛到<init>()
中,把语句块static{}
、类变量初始化、调用java.lang.lang.Object的<init>
方法等操作都收敛到<clinit>()
中,并且保证无论源码中顺序出现如何,都一定按照先执行父类的实例构造器、然后初始化变量、最后执行语句块的顺序进行。
再比如,吧字符串的+
操作替换为StringBuffer或StringBuilder的append操作。
完成了对语法树的遍历和调整之后,就会把填充了所需信息的符号交到类com.sun.tools.javac.jvm.ClassWriter
中,有这个类的writeClass()
方法输出字节码,生成最终的Class文件,到此,整个编译过程结束。
2. Java语法糖
2.1 泛型
泛型,就是”参数化类型“,将类型由原来的具体的类型参数化,类似于变量参数。
泛型类就是把泛型定义在类上,用户使用该类的时,才把类型明确下来。
Java中的集合是支持协变的,泛型出现之前就有ArrayList、HashMap等类型,这类集合里面,可以存放不同类型的值,比如ArrayList list = new ArrayList();list.add(1);list.add("x");
程序运行没有任何问题。但是在取数据时,必须对每一个数据进行类型转换,如果转换出错容易造成程序崩溃,比如(Integer)list.get(i)
对第一个元素没问题,取第二个元素时会报错。因此,为了保证集合的类型安全,消除强制类型转换,提高jvm性能,诞生了泛型这个概念。
java要求引入泛型必须对原有的JDK向下兼容,为了满足这个条件,引入泛型有2种兼容方式
- 需要泛型化的类型(主要是容器),原有的保持不变,然后平行的加一套泛型化版本的新类型。
- 直接把已有的类型泛型化,即原地泛型化。
C#选择了第一种,System.Collections和System.Collections.Specialized容器类并行存在。而java则选择了第2种。因为当时由于历史原因,java中存在Vecto、ArrayList等新旧集合类,如果采用方式一的话,则会出现Vector
、ArrayList
、Vector<T>
和ArrayList<T>
等,一个类型出现4种类这样的容器集合,会被开发者骂死吧。
基于此java选择了上述第二种原地泛型化,实现上采用了类型擦除。
类型擦除
已有类型泛型化,为了兼容性,保证ArrayList
和ArrayList<T>
在同一个容器里面,因此必须使得泛型化类ArrayList<T>
是裸类型ArrayLsit
的子类,否则类型转换就是不安全的。
裸类型如何实现呢?有2种方式,第1种是运行期由java虚拟机来自动、真实的构造出ArrayList<Integer>
这样的类型,并且自动实现从ArrayList<Integer>
继承自ArrayList
来满足裸类型定义。第2种是直接在编译时,把ArrayList<Integer>
还原成ArrayList
,只在元素访问、修改时自动插入一些强制类型转换和检查指令。java选择了后者,也就是类型擦除。
比如以下代码
1 |
|
类型擦除的缺点
泛型慢
我们知道<ArrayList<int>>
这种写法是不允许的,编译器会直接报错。那是因为如果允许的话,比如以下代码,由于泛型信息被擦除了,由于java不支持int、long和Object之间的类型转换,所以强制转换无法进行。为此,设计者干脆设计成泛型必须使用Integer等包装类型,不支持原生类型的泛型。
1 |
|
设计者觉得既然都做了自动类型的强制转换,那就把原生类型的装箱、拆箱都做了,于是泛型出现了无数的拆箱、包装的开销,这也是导致java泛型慢的重要原因。
运行期无法获取泛型信息
由于类型擦除,运行期是无法获取泛型信息的,这样会导致一些代码变得很复杂。比如
1 |
|
对重载也会产生影响。
1 |
|
java泛型的引入,对虚拟机的解析、反射等都有可能产生影响,为此JCP修改了java虚拟机规范,引入了Signature、LocalVariableTypeTable
等新的属性,用于解决伴随泛型而来的参数类型的识别问题。
2.2 自动装箱、拆箱和遍历循环
自动拆箱、装箱在编译之后会被转换为对应的包装Integer.valueOf(a)
和还原方法int i = (Integer)var4.next();
。遍历则是把代码还原成迭代器的实现,所以我们可以看到可以遍历循环的集合全部都实现了Iterable接口。ArrayList implements List extends Collection extends Iterable
1 |
|
1 |
|
上述abcdefg在定义时就会发生装箱行为,Integer a = Integer.valueOf(1)
,先看valueOf和intValue代码:
1 |
|
从代码我们可以看到,默认情况下,-127到128这256个数字,是存放在IntegerCache的cache[]数组里面的,因此在范围内的数字,实际上都是取得数组里的值,范围之外的是新对象,因此#1为true,#2为false。
其他Long、Short、Boolean、Byte和Character的valueOf类似于Integer,因为是有限的。而Double和FLoat则是无法确定范围内数字的个数,因此每一个Double和Float都是一个新的对象。
当 “==”运算符的两个操作数都是 包装器类型的引用,则是比较指向的是否是同一个对象,而如果其中有一个操作数是表达式则比较的是数值(即会触发自动拆箱的过程)。因此#3、#5均为true。
从equals的代码可以看出,不仅比较数值,还比较类型。因此#4 true,#6 false。
2.3 条件编译
条件编译是指:编译器只对满足条件的代码进行编译,将不满足条件的代码舍弃。比如C的 #ifdef
java的条件编译只发生在条件为常量的if语句中。比如
1 |
|
本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!