zoukankan      html  css  js  c++  java
  • 15-后端编译与优化(待补充)

    如果我们把字节码看作是程序语言的一种中间表示形式(Intermediate Representation,IR)的话,那编译器无论在何时、在何种状态下把 Class 文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码,它都可以视为整个编译过程的后端。

    本章中所提及的即时编译器都是特指 HotSpot 虚拟机内置的即时编译器,虚拟机也是特指 HotSpot 虚拟机。

    1. 即时编译器

    目前主流的两款商用 Java 虚拟机(HotSpot、OpenJ9)里,Java 程序最初都是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁,就会把这些代码认定为“热点代码”(Hot Spot Code),为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,运行时完成这个任务的后端编译器被称为「即时编译器」。

    1. 为何 HotSpot 虚拟机要使用解释器与即时编译器并存的架构?
    2. 为何 HotSpot 虚拟机要实现两个(或三个)不同的即时编译器?
    3. 程序何时使用解释器执行?何时使用编译器执行?
    4. 哪些程序代码会被编译为本地代码?如何编译本地代码?
    5. 如何从外部观察到即时编译器的编译过程和编译结果?

    1.1 解释器与编译器

    尽管并不是所有的 Java 虚拟机都采用解释器与编译器并存的运行架构,但目前主流的商用 Java 虚拟机,譬如 HotSpot、OpenJ9 等,内部都同时包含解释器与编译器,解释器与编译器两者各有优势:

    当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即运行。当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码,这样可以减少解释器的中间损耗,获得更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统中和大部分的 JavaCard 应用中就只有解释器的存在),反之可以使用编译执行来提升效率。

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

    即时编译器(JIT)与解释器的区别:

    • 解释器是将字节码解释为机器码,下次即使遇到相同的字节码,仍会执行重复的解释;JIT 是将一些字节码编译为机器码,并存入 Code Cache,下次遇到相同的代码,直接执行,无需再编译
    • 解释器是将字节码解释为针对所有平台都通用的机器码;JIT 会根据平台类型,生成平台特定的机器码

    对于占据大部分的不常用的代码,我们无需耗费时间将其编译成机器码,而是采取解释执行的方式运行;另一方面,对于仅占据小部分的热点代码,我们则可以将其编译成机器码,以达到理想的运行速度。 执行效率上简单比较一下 Interpreter < C1 < C2,总的目标是发现热点代码(HotSpot 名称的由来),然后优化。

    1.2 多个即时编译器

    HotSpot 虚拟机中内置了两个(或三个)即时编译器,其中有两个编译器存在已久,分别被称为“客户端编译器”(Client Compiler)和“服务端编译器”(Server Compiler),或者简称为「C1 编译器」和「C2 编译器」,第三个是在 JDK10 时才出现的、长期目标是代替 C2 的 Graal 编译器。

    在分层编译(Tiered Compilation)的工作模式出现以前,HotSpot 虚拟机通常是采用解释器与其中一个编译器直接搭配的方式工作,程序使用哪个编译器,只取决于虚拟机运行的模式,HotSpot 虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,用户也可以使用“-client”或“-server”参数去强制指定虚拟机运行在客户端模式还是服务端模式。

    无论采用的编译器是客户端编译器还是服务端编译器,解释器与编译器搭配使用的方式在虚拟机中被称为“混合模式”(Mixed Mode),用户也可以使用参数“-Xint”强制虚拟机运行于“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码都使用解释方式执行。另外,也可以使用参数“-Xcomp”强制虚拟机运行于“编译模式”(Compiled Mode),这时候将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程。

    可以通过虚拟机的“-version”命令的输出结果显示出这三种模式,内容如下所示:

    $java -version
    java version "11.0.3" 2019-04-16 LTS
    Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
    Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, mixed mode)
    $java -Xint -version
    java version "11.0.3" 2019-04-16 LTS
    Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
    Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, interpreted mode)
    $java -Xcomp -version
    java version "11.0.3" 2019-04-16 LTS
    Java(TM) SE Runtime Environment 18.9 (build 11.0.3+12-LTS)
    Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11.0.3+12-LTS, compiled mode)
    

    由于即时编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。

    为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译的功能[2],分层编译的概念其实很早就已经提出,但直到 JDK6 时期才被初步实现,后来一直处于改进阶段,最终在 JDK7 的服务端模式虚拟机中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

    • 【第 0 层】程序纯解释执行,并且解释器不开启性能监控功能(Profiling,指在运行过程中收集一些程序执行状态的数据,例如方法的调用次数、循环的回边次数等)。
    • 【第 1 层】使用客户端编译器(C1)将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能
    • 【第 2 层】仍然使用客户端编译器(C1)执行,仅开启方法及回边次数统计等有限的性能监控功能
    • 【第 3 层】仍然使用客户端编译器(C1)执行,开启全部性能监控,除了第 2 层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
    • 【第 4 层】使用服务端编译器(C2)将字节码编译为本地代码,相比起客户端编译器,服务端编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。

    以上层次并不是固定不变的,根据不同的运行参数和版本,虚拟机可以调整分层的数量。各层次编译之间的交互、转换关系如图所示。

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

    1.3 编译对象与触发条件

    1.4 编译过程

    2. 提前编译器

    3. 编译器优化技术

    编译器的目标虽然是做由程序代码翻译为本地机器码的工作,但其实难点并不在于能不能成功翻译出机器码,输出代码优化质量的高低才是决定编译器优秀与否的关键。

    这里通过大家熟悉的 Java 代码变化来展示其中几种优化技术是如何发挥作用的。不过首先需要明确一点,即时编译器对这些代码优化变换是建立在代码的中间表示或者是机器码之上的,绝不是直接在 Java 源码上去做的,这里只是笔者为了方便讲解,使用了 Java 语言的语法来表示这些优化技术所发挥的作用。

    1. 从原始代码开始。首先,第一个要进行的优化是方法内联,它的主要目的有两个:① 去除方法调用的成本(如查找方法版本、建立栈帧等);② 为其他优化建立良好的基础。方法内联膨胀之后可以便于在更大范围上进行后续的优化手段,可以获取更好的优化效果。因此各种编译器一般都会把内联优化放在优化序列最靠前的位置。内联后的代码见注释。

      static class B {
          int value;
          final int get() {
              return value;
          }
      }
      public void foo() {
          y = b.get(); // y = b.value;
          // ...do stuff...
          z = b.get(); // z = b.value;
          sum = y + z;
      }
      
    2. 进行冗余访问消除(Redundant Loads Elimination),假设代码中间注释掉的“…do stuff…”所代表的操作不会改变 b.value 的值,那么就可以把“z=b.value”替换为“z=y”,因为上一句“y=b.value”已经保证了变量 y 与 b.value 是一致的,这样就可以不再去访问对象 b 的局部变量了。如果把 b.value 看作一个表达式,那么也可以把这项优化看作一种公共子表达式消除(Common Subexpression Elimination),优化后的代码如下所示。

      public  void foo() {
          y = b.value;
          // ...do stuff...
          z = y;
          sum = y + z;
      }
      
    3. 进行复写传播(Copy Propagation),因为这段程序的逻辑之中没有必要使用一个额外的变量 z,它与变量 y 是完全相等的,因此我们可以使用 y 来代替 z。复写传播之后的程序如下所示。

      public  void foo() {
          y = b.value;
          // ...do stuff...
          y = y;
          sum = y + y;
      }
      
    4. 进行无用代码消除(Dead Code Elimination),无用代码可能是永远不会被执行的代码,也可能是完全没有意义的代码。因此它又被很形象地称为“Dead Code”,在上述代码中,“y=y”是没有意义的,把它消除后的程序如下所示。

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

    经过四次优化之后,step1 所示代码与 step4 所示代码所达到的效果是一致的,但是前者比后者省略了许多语句,体现在字节码和机器码指令上的差距会更大,执行效率的差距也会更高。

    接下来,笔者挑选了 4 项有代表性的优化技术,与大家一起观察它们是如何运作的。它们分别是:

    • 最重要的优化技术之一:方法内联
    • 最前沿的优化技术之一:逃逸分析
    • 语言无关的经典优化技术之一:公共子表达式消除
    • 语言相关的经典优化技术之一:数组边界检查消除

    3.1 方法内联

    3.1.1 review 方法调用

    要搞清楚为什么方法内联有用,首先要知道当一个函数被调用的时候发生了什么:

    1. 首先会有个执行栈,存储目前所有活跃的方法,以及它们的本地变量和参数;
    2. 当一个新的方法被调用了,一个新的栈帧会被加到栈顶,分配的本地变量和参数会存储在这个栈帧;
    3. 跳到目标方法代码执行;
    4. 方法返回的时候,本地方法和参数会被销毁,栈顶被移除;
    5. 返回原来地址执行;

    这就是通常说的函数调用的压栈和出栈过程,因此,函数调用需要有一定的时间开销和空间开销,当一个方法体不大,但又频繁被调用时,这个时间和空间开销会相对变得很大,变得非常不划算,同时降低了程序的性能。根据二八原则,80% 的性能消耗其实是发生在 20% 的代码上,对热点代码的针对性优化可以提升整体系统的性能。

    3.1.2 内联和虚方法的矛盾

    方法内联的优化行为理解起来是没有任何困难的,不过就是把目标方法的代码原封不动地“复制”到发起调用的方法之中,避免发生真实的方法调用而已。

    但实际上 Java 虚拟机中的内联过程却远没有想象中容易,甚至如果不是即时编译器做了一些特殊的努力,按照经典编译原理的优化理论,大多数的 Java 方法都无法进行内联。

    无法内联的原因其实在讲解《Java 方法解析和分派调用》的时候就已经解释过:只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法和使用 invokestatic 指令调用的静态方法才会在编译期进行解析。除了上述 4 种方法之外(最多再除去被 final 修饰的方法这种特殊情况,尽管它使用 invokevirtual 指令调用,但也是非虚方法,《Java 语言规范》中明确说明了这点),其他的 Java 方法调用都必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者,简而言之,Java 语言中默认的实例方法是虚方法。

    对于一个虚方法,编译器静态地去做内联的时候很难确定应该使用哪个方法版本,以举例中的 b.get() 直接内联为 b.value 为例,如果不依赖上下文,是无法确定 b 的实际类型是什么的。假如有 ParentB 和 SubB 是两个具有继承关系的父子类型,并且子类重写了父类的 get() 方法,那么 b.get() 是执行父类的 get() 方法还是子类的 get() 方法,这应该是根据实际类型动态分派的,而实际类型必须在实际运行到这一行代码时才能确定,编译器很难在编译时得出绝对准确的结论。

    更糟糕的情况是,由于 Java 提倡使用面向对象的方式进行编程,而 Java 对象的方法默认就是虚方法,可以说 Java 间接鼓励了程序员使用大量的虚方法来实现程序逻辑。根据上面的分析可知,内联与虚方法之间会产生“矛盾”。

    那是不是为了提高执行性能,就应该默认给每个方法都使用 final 关键字去修饰呢?C 和 C++ 语言的确是这样做的,默认的方法是非虚方法,如果需要用到多态,就用 virtual 关键字来修饰,但 Java 选择了在虚拟机中解决这个问题。

    3.1.3 类型继承关系分析

    为了解决虚方法的内联问题,Java 虚拟机首先引入了一种名为〈类型继承关系分析(Class Hierarchy Analysis,CHA)〉的技术,这是整个应用程序范围内的类型分析技术,用于确定在目前已加载的类中,某个接口是否有多于一种的实现、某个类是否存在子类、某个子类是否覆盖了父类的某个虚方法等信息。

    这样,编译器在进行内联时就会分不同情况采取不同的处理:

    所以说,在多数情况下 Java 虚拟机进行的方法内联都是一种激进优化。事实上,激进优化的应用在高性能的 Java 虚拟机中比比皆是,极为常见。除了方法内联之外,对于出现概率很小(通过经验数据或解释器收集到的性能监控信息确定概率大小)的隐式异常、使用概率很小的分支等都可以被激进优化“移除”,如果真的出现了小概率事件,这时才会从“逃生门”回到解释状态重新执行。

    3.2 逃逸分析

    逃逸分析(Escape Analysis)与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。

    3.2.1 基本原理

    分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为「方法逃逸」;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为「线程逃逸」;从不逃逸、方法逃逸到线程逃逸,称为“对象由低到高的不同逃逸程度”。

    3.2.2 优化举措

    如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:

    1. 栈上分配(Stack Allocations)

    在 Java 虚拟机中,Java 堆上分配创建对象的内存空间几乎是 Java 程序员都知道的常识,Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问到堆中存储的对象数据。虚拟机的垃圾收集子系统会回收堆中不再使用的对象,但回收动作无论是标记筛选出可回收对象,还是回收和整理内存,都需要耗费大量资源。

    如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。栈上分配可以支持方法逃逸,但不能支持线程逃逸。

    2. 标量替换(Scalar Replacement)

    若一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型及 reference 类型等)都不能再进一步分解了,那么这些数据就可以被称为〈标量〉。相对的,如果一个数据可以继续分解,那它就被称为〈聚合量(Aggregate)〉,Java 中的对象就是典型的聚合量。如果把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为“标量替换”。

    假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,很大机会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

    3. 同步消除(Synchronization Elimination)

    线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以安全地消除掉

    3.2.3 举例

    下面笔者将通过一系列 Java 伪代码的变化过程来模拟逃逸分析是如何工作的,向读者展示逃逸分析能够实现的效果。初始代码如下所示:

    // 完全未优化的代码
    public int test(int x) {
        int xx = x + 2;
        Point p = new Point(xx, 42);
        return p.getX();
    }
    
    1. 将 Point 的构造函数和 getX() 方法进行内联优化:
      // 步骤1:构造函数内联后的样子
      public int test(int x) {
          int xx = x + 2;
          Point p = point_memory_alloc();   // 在堆中分配P对象的示意方法
          p.x = xx;                         // Point构造函数被内联后的样子
          p.y = 42
          return p.x;                       // Point::getX()被内联后的样子
      }
      
    2. 经过逃逸分析,发现在整个 test() 方法的范围内 Point 对象实例不会发生任何程度的逃逸,这样可以对它进行标量替换优化,把其内部的 x 和 y 直接置换出来,分解为 test() 方法内的局部变量,从而避免 Point 对象实例被实际创建,优化后的结果如下所示:
      public int test(int x) {
          int xx = x + 2;
          int px = xx;
          int py = 42
          return px;
      }
      
    3. 通过数据流分析,发现 py 的值其实对方法不会造成任何影响,那就可以放心地去做无效代码消除得到最终优化结果,如下所示:
      // 步骤3:做无效代码消除后的样子
      public int test(int x) {
          return x + 2;
      }
      

    3.3 公共子表达式消除

    公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就称为「公共子表达式」。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替 E。

    如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。

    3.4 数组边界检查消除

    数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。我们知道 Java 语言是一门动态安全的语言,对数组的读写访问也不像 C、C++ 那样实质上就是裸指针操作。如果有一个数组 foo[],在 Java 语言中访问数组元素 foo[i] 的时候系统将会自动进行上下界的范围检查,即 i 必须满足“i>=0&&i<foo.length”的访问条件,否则将抛出一个运行时异常:
    ArrayIndexOutOfBoundsException。这对软件开发者来说是一件很友好的事情,即使程序员没有专门编写防御代码,也能够避免大多数的溢出攻击。但是对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这必定是一种性能负担。

    无论如何,为了安全,数组边界检查肯定是要做的,但数组边界检查是不是必须在运行期间一次不漏地进行则是可以“商量”的事情。例如下面这个简单的情况:数组下标是一个常量,如 foo[3],只要在编译期根据数据流分析来确定 foo.length 的值,并判断下标“3”没有越界,执行的时候就无须判断了。更加常见的情况是,数组访问发生在循环之中,并且使用循环变量来进行数组的访问。如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间 [0,foo.length) 之内,那么在循环中就可以把整个数组的上下界检查消除掉,这可以节省很多次的条件判断操作。

    3.5 反射优化

    ...

  • 相关阅读:
    sw
    ++1
    test
    为了
    发送邮件
    新建121212
    29012
    pthread_create/join函数
    recv函数学习
    socketpair用法学习
  • 原文地址:https://www.cnblogs.com/liujiaqi1101/p/14793982.html
Copyright © 2011-2022 走看看