深入理解 Java 虚拟机(十一)程序编译与代码优化

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/u011330638/article/details/82817229

编译期优化

Java 语言的编译期其实是一段不确定的过程,可以是前端编译器 (Javac) 把 java 文件编译为 class 文件的过程,也可能值虚拟机的后端运行期编译器 (JIT 编译器,Just In Time Compiler) 把字节码转变为机器码的过程;还可能是指使用静态提前编译器 (AOT 编译器:Ahead Of Time Compiler) 直接把 java 文件编译为本地机器代码的过程。

Javac 的编译过程如下:

编译过程

解析包括经典编译原理中的词法分析和语法分析两个过程。

语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,如类型审查。

语义分析与字节码生成的步骤为:

  1. 标注检查,如变量使用前是否已声明,变量与赋值之间的数据类型是否能够匹配等
  2. 数据及控制流分析,对程序上下文逻辑更进一步的验证,比如局部变量使用前是否有赋值、是否所有的受检查异常都被正确处理等
  3. 解语法糖
  4. 字节码生成

运行期优化

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

HotSpot 虚拟机内的即时编译器

解释器与编译器

解释器与编译器两者各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提高效率。

HotSpot 虚拟机内置了两个即时编译器,分别称为 Client Compiler 和 Server Compiler,或者简称为 C1 编译器和 C2 编译器。

解释器与编译器搭配使用的方式在虚拟机中成为混合模式。

由于即时编译器编译本地代码需要占用程序运行时间,同时解释器可能还要题编译器收集性能相关的信息,这对解释执行的速度也有影响,为了在程序响应速度和运行效率之间达到平衡,HotSpot 虚拟机启用了分层编译的策略:

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

编译对象与触发条件

在运行过程中会被即时编译器编译的热点代码有两类:

  1. 被多次调用的方法
  2. 被多次调用的循环体

对第一种情况,编译器会把整个方法作为编译对象,这种编译也是虚拟机中标准的 JIT 编译方式。而对后一种情况,编译器依然会把整个方法作为编译对象,因为编译发生在放行执行过程中,因此这种编译方式称为栈上替换(On Stack Replacement,简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。

判断一段代码是不是热点代码,是否需要触发即时编译,这样的行为称为热点探测,目前主要的热点探测判定方式有两种:

  1. 基于采样的热点探测。虚拟机会周期性地检查各个线程的栈顶,如果某些方法经常出现在栈顶,那这个方法就是热点方法。这种探测方式的好处是简单、高效,可以很容易地获取方法调用关系,缺点是很难精确地确认一个方法的热度,容易受线程阻塞等外界因素的干扰。

  2. 基于计数器的热点探测。虚拟机会为每个方法(甚至代码块)建立计数器,执行次数超过一定的阈值就认为是热点方法。这种探测方式实现起来稍微麻烦一些,而且不能获取到方法的调用关系,但它的统计结果更加精确和眼睛。

HotSpot 虚拟机使用的是第二种方式,它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)、回边计数器(Back Edge Counter)。这两个计数器都有一个确定的阈值,超过阈值就会触发 JIT 编译。

方法调用计数器用于统计方法被调用的次数,默认阈值在 Clinet 模式下是 1500 次,在 Server 模式下是 10000 次。执行流程如下:

方法调用计数器执行流程

如果不做任何设置,那么方法调用计数器统计的不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数,如果超时,那么计数器就会被减少一半,这个过程称为热度的衰减,这段时间称为半衰周期。

回边计数器用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边(Back Edge),可以通过参数 -XX:OnStackReplacePercentage 来间接调整回边计数器的阈值,计算公式见书籍 P。回边指令的执行流程如下:

回边计数器执行流程

提交 OSR 编译请求后还需要调整回边计数器值,以便继续在解释器中执行循环。

编译优化技术

以编译方式执行本地代码比解释方式快的原因是虚拟机解释字节码时需要额外消耗时间,且虚拟机设计团队几乎把堆代码的所有优化措施都集中在了即时编译器之中,因此,一般来说,即时编译器产生的本地代码比 Javac 产生的字节码优秀。

即时编译器优化技术有很多种,详见书籍 P,下面将演示一个代码优化的例子。

优化前的原始代码:

static class B{
    int value;
    final int get() {
        return value;
    }
}

public void foo() {
    y = b.get();
    z = b.get();
    sum = y + z;
}

首先进行方法内联,方法内联的重要性高于其他优化措施,它的主要目的有两个,一个去除方法调用的成本(如建立栈帧等),二是为其它优化建立良好的基础。内联后的代码如下:

public void foo() {
    y = b.value;
    z = b.value;
    sum = y + z;
}

然后是冗余访问消除:

public void foo() {
    y = b.value;
    z = y;
    sum = y + z;
}

接着进行复写传播:

public void foo() {
    y = b.value;
    y = y;
    sum = y + y;
}

最后是无用代码消除:

public void foo() {
    y = b.value;
    sum = y + y;
}

接下来介绍几项最有代表性的优化技术:

  1. 语言无关的经典优化技术之一:公共子表达式消除。公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,它的含义是:如果一个表达式 E 已经被计算过,且从先前的计算到现在 E 中所有变量的值都没有发生变化,则 E 就成为了公共子表达式,无需重新计算,只要拿上次计算的结果即可。

  2. 语言相关的经典优化技术之一:数组范围检查消除。 Java 语言中访问数组元素时将会自动进行上下边界的范围检查,对于拥有大量数组访问的程序代码,无疑会对性能造成一定的负担。但数组边界检查是不是必须在运行期间一次不漏地检查则是可以商量的事情,比如当数组下标是一个常量时,只需在编译期根据数据流分析比较该下标和数组长度,即可确定是否越界,运行期就无需判断了。

  3. 最重要的优化技术之一:方法内联。如上,方法内联是编译器最重要的优化技术手段之一,看起来很简单,但实际上,Java 语言中默认的实例方法都是虚方法,因此编译期做内联的时候根本无法确定应该使用哪个版本。 Java 虚拟机设计团队因此引入了名为“类型继承关系技术(Class Hierarchy Analysis, CHA)”的技术,用于确定当前已加载的类中,某个接口是否有多于一种的实现……具体执行过程见书籍 P。

  4. 最前沿的优化技术之一:逃逸分析。逃逸分析用于分析对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,称为方法逃逸;也可能被外部线程访问到,称为线程逃逸。如果能确定一个对象不会逃逸到外部方法或线程中,则可以进行一些高效的优化:

    a) 栈上分配。Java 虚拟机中,几乎所有对象都是分配在堆中的,而回收堆中的对象时,无论是筛选可回收对象的过程,还是回收对象、整理内存的过程,都需要耗费时间,因此确定一个对象不会逃逸出方法之外时,在栈上分配这个对象的内存是一个很好的主意,对象会随着方法执行结束而自动销毁。

    b) 同步消除。

    c) 标量替换。标量指无法再分解的的数据,比如 int、long 等,相反,如果可以分解,则是聚合量。Java 中的对象就是聚合量,把一个对象拆散,将其使用到的成员变量恢复为原始类型来访问就叫做标量替换。

    逃逸分析是一个很好的技术,主要问题是不能保证逃逸分析的性能收益高于它的消耗,如果分析后发现没有几个不逃逸的对象,那么分析所耗费的时间就被浪费了。

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

Java 虚拟机的即时编译器和 C/C++ 的静态优化编译器相比,可能会由于以下原因而导致输出的本地代码有一些劣势:

  1. 即时编译器占用的是用户程序运行的时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。如果编译速度达不到要求,则用户会在程序的运行期间察觉到某些延迟,这点使得编译器不敢随便引入大规模的优化技术。

  2. Java 语言是动态的类型安全语言,这就意味着需要由虚拟机来确保不会违反语言语义或访问非结构化内存。从实现层面上看,虚拟机必须频繁地进行动态检查,比如实例方法访问时检查空指针、数组元素访问时检查上下界范围、类型转换时检查继承关系等。

  3. Java 语言虽然没有 virtual 关键字,但是使用虚方法的频率却远大于 C/C++ 语言,这意味着运行时对方法接收者进行多态选择的频率要远大于 C/C++ 语言,也意味着即时编译器在进行一些优化(如方法内联)时的难度要远大于 C/C++ 语言。

  4. Java 是可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,这使得很多全局的优化都难以进行,因为编译器无法看见程序的全貌,许多全局的优化措施都只能以激进优化的方式来完成,编译器不得不时刻注意并随着类型的变化而在运行时撤销或重新进行一些优化。

  5. Java 语言中对象的内存分配都是在堆上进行的,只有方法中的局部变量才能在栈上分配,而 C/C++ 的对象则有多重内存分配方式,既可以在堆上分配,也能在栈上分配,如果可以在栈上分配线程私有的对象,将减轻内存回收的压力。另外,C/C++ 主要由用户程序代码来回收分配的内存,不存在筛选可回收对象的过程,因此效率上也比垃圾回收机制高。

总结

思维导图

在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/u011330638/article/details/82817229
今日推荐