美文网首页
Java虚拟机 -- 程序编译与代码优化

Java虚拟机 -- 程序编译与代码优化

作者: TomyZhang | 来源:发表于2019-06-25 17:14 被阅读0次

一、编译期优化

Java语言的"编译期"其实是一段"不确定"的操作过程,因为它可能是指一个前端编译器把 .java 文件转变成 .class 文件的过程;也可能是指虚拟机的后端运行期编译器(JIT编译器,Just In Time Compiler)把字节码转变成机器码的过程;还可能是指使用静态提前编译器(AOT编译器,Ahead Of Time Compiler)直接把 .java文件编译成本地机器代码的过程。
前端编译器:Sun的Javac、Eclipse JDT中的增量式编译器(ECJ)。
JIT编译器:HotSpot VM的C1、C2编译器。
AOT编译器:GNU Compiler for the Java(GCJ)、Excelsior JET。

Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译期的优化过程对于程序编码来说关系更加密切。

1.Javac编译器

虚拟机规范严格定义了Class文件的格式,并没有对如何把Java源码文件转变为Class文件的编译过程进行十分严格的定义,这导致Class文件编译在某种程度上是与具体JDK实现相关的。
Sun Javac的编译过程大致可以分为以下3个过程:

解析与填充符号表:
1.词法、语法分析
词法分析是将源代码的字符流转变为标记(Token)集合。
关键字、变量名、字面量、运算符都可以成为标记,如"int a=b+2"这句代码包含了6个标记。

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

经过这个步骤后,编译器就基本不会再对源码文件进行操作了,后续的操作都建立在抽象语法树之上。

2.填充符号表
符号表是由一组符号地址和符号信息构成的表格,符号表中所登记的信息在编译的不同阶段都要用到。在语义分析中,符号表所登记的内容将用于语义检查(如检查一个名字的使用和原先的说明是否一致)和产生中间代码。在目标代码生成阶段,当对符号名进行地址分配时,符号表是地址分配的依据。

注解处理器:
在JDK 1.5之后,Java语言提供了对注解的支持,这些注解与普通的Java代码一样,是在运行期间发挥作用的。在JDK 1.6中提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,我们可以把它看做是一组编译器的插件,在这些插件里面,可以读取、修改、添加抽象语法树中的任意元素。如果这些插件在处理注解期间对语法树进行了修改,编译器将回到解析与填充符号表的过程重新处理,直到所有插入式注解处理器都没有再对语法树进行修改为止。

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

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

int d = a + c; //√
int d = b + c; //×
char d = a + c; //×

以上代码都能构成正确的语法树,但是只有√的写法在语义上是没有问题的,能够通过编译,其余两种在Java语言中是不合逻辑的,无法编译。

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

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

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

Java中最常用的语法糖主要是泛型、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构,这个过程称为解语法糖。

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

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

2.Java语法糖

泛型与类型擦除:
泛型是JDK 1.5的一项新增特性,它的本质是参数化类型的应用,也就是说所操作的数据类型被指定为一个参数。这种参数可以用在类、接口和方法的创建中,分别称为泛型类、泛型接口和泛型方法。

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

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

自动装箱、拆箱与遍历循环:
自动装箱、拆箱与遍历循环是Java语言里使用得最多的语法糖。

private void testMethod() {
    List<Integer> list = Arrays.asList(1, 2, 3, 4); //泛型、自动装箱
    int sum = 0;
    for(int i : list) { //自动拆箱、遍历循环
        sum += i;
    }
    Log.d(TAG, "zwm, sum: " + sum);
}

条件编译:
Java语言可以进行条件编译,方法就是使用条件为常量的if语句。

private void testMethod() {
    if(true) {
        Log.d(TAG, "zwm, true");
    } else {
        Log.d(TAG, "zwm, false");
    }
}

其它语法糖:
变长参数、内部类、枚举类、断言语句、对枚举和字符串的switch支持、try语句中定义和关闭资源等。

二、运行期优化

在部分的商用虚拟机(Sun HotSpot、IBM J9)中,Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为"热点代码"。为了提高热点代码的执行效率,在运行时虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(Just In Time Compiler,JIT编译器)。

即时编译器并不是虚拟机必需的部分,Java虚拟机规范并没有规定Java虚拟机内必须要有即时编译器存在,更没有限定或指导即时编译器应该如何去实现。但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分。

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

解释器与编译器:
尽管并不是所有的Java虚拟机都采用解释器与编译器并存的架构,但许多主流的商用虚拟机,如HotSpot、J9等,都同时包含解释器与编译器。解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。

同时,解释器还可以作为编译器激进优化时的一个"逃生门",让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、出现"罕见陷阱"时可以通过逆优化退回到解释状态继续执行(部分没有解释器的虚拟机总也会采用不进行激进优化的C1编译器担任"逃生门"的角色),因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作。


解释器与编译器的交互

HotSpot虚拟机中内置了两个即时编译器,分别称为Client Compiler(简称C1编译器)和Server Compiler(简称C2编译器)。目前主流的HotSpot虚拟机中,默认采用解释器与其中一个编译器直接配合的方式工作。

由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长,而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启用分层编译的策略。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次。

  • 第0层,程序解释执行,解释器不开启性能监控功能,可触发第1层编译。
  • 第1层,也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
  • 第2层(或2层以上),也称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。

编译对象与触发条件:
在运行过程中会被即时编译器编译的"热点代码"有两类:

  • 被多次调用的方法。
  • 被多次执行的循环体。

目前主流的热点探测判定方式有两种:

  • 基于采样的热点探测。
  • 基于计数器的热点探测。

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两类计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译。


方法调用计数器触发即时编译 回边计数器触发即时编译

编译过程:
JIT编译过程是一个虚拟机中最体现技术水平也是最复杂的部分。

查看及分析即时编译结果:
虚拟机提供了一些参数用来输出即时编译和某些优化手段(如方法内联)的执行状况。

2.编译优化技术

Java程序员有一个共识,以编译方式执行本地代码比解释方式更快,之所以有这样的共识,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在即时编译器之中,因此一般来说,即时编译器产生的本地代码会比Javac产生的字节码更加优秀(本地代码与字节码两者是无法直接比较的,准确地说应当是指:由编译器优化得到的本地代码与由解释器解释字节码后实际执行的本地代码之间的对比)。

语言无关的经典优化技术之一:公共子表达式消除

语言相关的经典优化技术之一:数组范围检查消除

最重要的优化技术之一:方法内联
方法内联除了消除方法调用的成本之外,更重要的意义是为其他优化手段建立良好的基础。

最前沿的优化技术之一:逃逸分析
如果能证明方法中定义的对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化。

  • 栈上分配。
    对象在栈上分配,会随着方法的结束而自动销毁,垃圾收集系统的压力将会小很多。
  • 同步消除。
    变量的读写不会有竞争,对这个变量实施的同步措施也就可以消除掉。
  • 标量替换。
    把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问。

尽快目前逃逸分析的技术仍不是十分成熟,但是它却是即时编译器优化技术的一个重要的发展方向。

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

大多数程序员都认为C/C++会比Java语言快,甚至觉得从Java语言诞生以来"执行速度缓慢"的帽子就应当扣在它的头顶,这种观念的出现是由于Java刚出现的时候即时编译技术还不成熟,主要靠解释器执行的Java语言性能确实比较低下。但目前即时编译技术已经十分成熟了。

Java虚拟机的即时编译器与C/C++的静态优化编译器相比,可能的劣势如下:

  • 即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。
    如果编译速度不能达到要求,那用户将在启动程序或程序的某部分察觉到重大延迟,这点使得即时编译器不敢随便引入大规模的优化技术。
  • Java语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。
    从实现层面上看,这就意味着虚拟机必须频繁地进行动态检查,如实例方法访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等,尽管编译器会努力进行优化,但是总体上仍然要消耗不少的运行时间。
  • Java语言中虽然没有virtual关键字,但是使用虚方法的频率却远远大于C/C++语言。
    这意味着运行时对方法接收者进行多态选择的频率要远远大于C/C++语言,也意味着即时编译器在进行一些优化(比如方法内联)时的难度要远大于C/C++的静态优化编译器。
  • Java语言是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系。
    这使得很多全局的优化都难以进行,因为编译器无法看见程序的全貌,许多全局的优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。
  • Java语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。
    而C/C++的对象则有多种内存分配方式,既可能在堆上分配,又可能在栈上分配,如果可以在栈上分配线程私有的对象,将减轻内存回收的压力。

虽然Java语言相对C/C++语言有很多劣势,但并不是说Java就真的不如C/C++了。Java语言的这些性能上的劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些"拖后腿"的特性都为Java语言的开发效率做出了很大贡献。

相关文章

网友评论

      本文标题:Java虚拟机 -- 程序编译与代码优化

      本文链接:https://www.haomeiwen.com/subject/ulexcctx.html