JVM理论:(四/2)编译过程——晚期(运行期)

Posted zjxiang

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM理论:(四/2)编译过程——晚期(运行期)相关的知识,希望对你有一定的参考价值。

一、解释器与编译器

  当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码” 。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT 编译器)。

  即时编译器并不是虚拟机必需的部分,但是,即时编译器编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键指标之一,它也是虚拟机内中最核心且最能体现虚拟机技术水平的部分。

解释器与编译器两者各有优势:

  当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。当程序运行环境中内存资源限制较大,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个 “逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段。

  

  HotSpot 虚拟机中内置了两个即时编译器,分别称为 Client Compiler 和 Server Compiler 或者简称为 C1 编译器和 C2 编译器。目前主流的 HotSpot 虚拟机中,默认采用解释器与其中一个编译器直接配合的方式工作,用户可以使用 “-client” 或 “-server” 参数去强制指定虚拟机运行在 Client 模式或 Server 模式。

  无论采用的编译器是 Client Compiler 还是 Server Compiler,解释器与编译器搭配使用的方式称为 “混合模式” (Mixed Mode),

  用户可以使用参数 “-Xint” 强制虚拟机运行于 “解释模式”(Interpreted Mode),这是编译器完全不介入工作,全部代码都使用解释方式执行。

  另外,也可以使用参数 “-Xcomp” 强制虚拟机运行于 “编译模式”(Compiled Mode),这时将优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程,可以通过虚拟机的 “-version” 命令的输出结果显示出这 3 种模式。

 

二、编译对象与触发条件

上文提到的“热点代码” 有两类,即:

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

  对于第一种情况,由于是由方法调用触发的编译,因此编译器理所当然地会以整个方法作为编译对象,这种编译也是虚拟机中标准的 JIT 编译方式。而对于后一种情况,尽管编译动作是由循环体所触发的,但编译器依然会以整个方法(而不是单独的循环体)作为编译对象。这种编译方式因为编译发生在方法执行过程之中,因此形象地称之为栈上替换(简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。

  

那到底多少次才算 “多次” 呢?如何统计一个方法或一段代码被执行过多少次呢?

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

  • 基于采样的热点探测:采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是 “热点方法”。简单、高效,可以容易获取方法调用关系,但很难精确地确认一个方法的热度。
  • 基于计数器的热点探测:采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是 “热点方法”。

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

1)方法调用计数器

  用于统计方法被调用的次数,它的默认阈值在 Client 模式下是 1500 次,在 Server 模式下是 10000 次,这个阈值可以通过虚拟机参数-XX:CompileThreshold来人为设定。

  当一个方法被调用时,会先检查该方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加 1,然后判断方法调用计数器与回边计数器值之和是否查过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。如果不做任何设置,执行引擎并不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被编译器编译完成。当编译工作完成之后,这个方法调用入口地址就会被系统自动改成新的,下一次调用该方法时就会使用已编译的版本。整个 JIT 编译的交互过程如下图。

  技术分享图片

   如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX: -UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用 -XX: CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

2)回边计数器

  它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为 “回边”。 在Client 模式虚拟机的回边计数器的阈值为 13995,在Server 模式下的阈值为 10700。

  当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加 1,然后判断方法调用计数器与回边计数器之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个 OSR 编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,整个执行过程如下图。

  技术分享图片

  与方法计数器不同,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

  在默认设置下,无论是方法调用产生的即时编译请求,还是 OSR 编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。用户可以通过参数 -XX: -BackgroundCompilation 来禁止后台编译,在禁止后台编译后,一旦达到 JIT 的编译条件,执行线程向虚拟机提交编译请求后将会一直等待,直到编译过程完成后再开始执行编译器输出的本地代码。

 

三、编译优化技术

  Java 程序员有一个共识,以编译方式执行本地代码比解释方式更快,之所以有这样的共识,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个很重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器之中。因此一般来说,即时编译器产生的本地代码会比 javac 产生的字节码更加优秀。  

1、公共子表达式消除

  如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间再对它进行计算,只需要直接用前面计算过的表达式结果代替 E 就可以了。

  假设存在如下代码:

  int d = (c * b) * 12 + a + (a + b * c);

  当这段代码进入到虚拟机即时编译器后,它将进行如下优化:编译器检测到 “c*b” 与 “b*c” 是一样的表达式,而且在计算期间 b 与 c 的值是不变的。因此,这条表达式就可能被视为:

  int d = E * 12 + a + (a + E);

  编译器还可能进行另外一种优化,代数化简,把表达式变为:

  int d = E * 13 + a * 2;

  表达式进行变换之后,再计算起来就可以节省一些时间了。

2、数组边界检查消除

  如果有一个数组 foo[],在 Java 语言中访问数组元素 foo[i] 的时候系统将会自动进行上下界的范围检查,即检查 i 必须满足 i >= 0 && i < foo.length 这个条件。对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判定操作,对于拥有大量数组访问的程序代码,这无疑也是一种性能负担。

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

3、方法内联

  方法内联的重要性要高于其他优化措施,它的主要目的有两个,一是去除方法调用的成本(如建立栈帧等)。二是为其他优化建立良好的基础,方法内联膨胀之后可以便于在更大范围上采取后续的优化手段,从而获取更好的优化效果。因此,各种编译器一般都会把内联优化放在优化序列的最靠前位置。

  讲Java 方法解析和分派调用的时候就已经介绍过。只有使用invokespecial 指令调用的私有方法、实例构造器、父类方法以及使用invokestatic 指令进行调用的静态方法才是在编译期进行解析的,除了上述 4 种方法之外,其他的 Java 方法调用都需要在运行时进行方法接收者的多态选择,并且都可能存在多于一个版本的方法接收者(最多再除去被 final 修饰的方法这种特殊情况,尽管它使用 invokevirtual 指令调用),简而言之,Java 语言中默认的实例方法是虚方法。

  对于一个虚方法,编译期做内联的时候根本就无法确定应该使用哪个方法版本,需要在运行期才能确定。为了解决虚方法的内联问题,Java 虚拟机设计团队引入了一种名为 “类型继承关系分析”(CHA)的技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息。  

  编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了。如果遇到虚方法,则会向 CHA 查询此方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过需要预留一个 “逃生门”。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译。

  如果向 CHA 查询出来的结果是多个版本的目标方法可供选择,则编译器会使用内联缓存来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。

4、逃逸分析

  逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化,如下。

1)栈上分配:

  Java 堆中的对象对于各个线程都是共享和可见的,只要持有这个对象的引用,就可以访问堆中存储的对象数据。虚拟机的垃圾收集系统可以回收堆中不再使用的对象,但回收动作无论是筛选可回收对象,还是回收和整理内存都需要耗费时间。如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的注意,对象所占用的内存空间就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。 

2)同步消除:

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

3)标量替换:

  标量是指一个数据已经无法再分解成更小的数据来表示了,Java 虚拟机中的原始数据类型(int、long 等数值类型以及 reference 类型等)都不能再进一步分解,它们就可以称为标量。如果把一个 Java 对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的告诉寄存器中存储),还可以为后续进一步的优化手段创建条件。

 

  如果有需要,并且确认对程序运行有益,用户可以使用参数 -XX:+DoEscapeAnalysis 来手动开启逃逸分析,开启之后可以通过参数 -XX:+PrintEscapeAnalysis 来查看分析结果。有了逃逸分析支持之后,用户可以使用参数 -XX:+EliminateAllocations 来开启标量替换,使用+XX:+EliminateLocks 来开启同步消除,使用参数-XX:+PrintEliminateAllocations 来查看标量的替换情况。

 

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

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

  第一,因为即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力,它能提供的优化手段也严重受制于编译成本。如果编译速度不能达到要求,那用户将在启动程序或程序的某部分察觉到重大延迟,这点使得即时编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点。

  第二,Java 语言是动态的类型安全语言,这就意味着需要由虚拟机来确保程序不会违反语言语义或访问非结构化内存。从实现层面上看,这就意味着虚拟机必须频繁地进行动态检查,如实例方法访问时检测空指针、数组元素访问时检测上下文范围、类型转换时检测继承关系等。对于这类程序代码没有明确写出的检查行为,尽管编译器会努力进行优化,但是总体上仍然要消耗不少的运行时间。

  第三,Java 语言使用虚方法的频率却远远大于 C/C++ 语言,这意味着运行时对方法接收者进行多态选择的频率要远远大于 C/C++ 语言,也意味着即时编译在进行一些优化(如前面提到的方法内联)时的难度要远大于 C/C++ 的静态优化编译器。

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

  第五,Java 语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。而 C/C++ 的对象则有多种内存分配方式,既可能在堆上分配,又可能在栈上分配,如果可以在栈上分配线程私有的对象,将减轻内存回收的压力。另外,C/C++ 中主要由用户程序代码来回收分配的内存,这就不存在无用对象筛选的过程,因此效率上(仅指运行效率,排除了开发效率)也比垃圾收集机制要高。

 

以上是关于JVM理论:(四/2)编译过程——晚期(运行期)的主要内容,如果未能解决你的问题,请参考以下文章

JVM晚期运行期优化

晚期(运行期)优化

JVM理论:(四/1)编译过程——早期(编译期)

《深入理解 Java 虚拟机》读书笔记:晚期(运行期)优化

晚期(运行期)优化

java虚拟机晚期优化-运行期优化方法