程序员Java学习笔记

《深入理解java虚拟机》-晚期(运行期)优化

2017-02-02  本文已影响159人  xiedacon

概述

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

java虚拟机规范中没有规定即时编译器应该如何实现,也没有规定虚拟机必需拥有即时编译器,这部分功能完全是虚拟机具体实现相关的内容。本文中提及的编译器、即时编译器都是指HotSpot虚拟机内的即时编译器

HotSpot虚拟机内的即时编译器

解释器和编译器

HotSpot虚拟机采用解释器与编译器并存的架构,解释器与编译器两者各有优势:

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

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

由于即时编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所花费的时间可能更长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行的速度也有影响。HotSpot虚拟机采用分层编译(Tiered Compilation)的策略,其中包括:

编译对象与触发条件

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

在这两种情况下,都是以整个方法作为编译对象,这种编译方式被称为栈上替换(On Stack Replacement,简称OSR编译,即方法栈帧还在栈上,方法就被替换了)

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

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

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

ps:上面描述的是Client VM的即时编译方法,对于Server VM来说,执行情况会比上面的描述更复杂

编译过程

Server Compiler和Client Compiler两个编译器的编译过程是不一样的

Client Compiler是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段

  1. 第一个阶段:使用一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representaion, HIR)。HIR使用静态单分配(Static Single Assignment, SSA)的形式来代表代码值,这可以使得一些在HIR的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器会在字节码上完成一部分基础优化,如方法内联、常量传播等
  2. 第二个阶段:使用一个平台相关的前端从HIR中产生低级中间代码表示(Low-Level Intermediate Representaion, LIR),而在此之前会在HIR上完成另外一些优化,如空值检查清除、范围检查清除等
  3. 最后阶段:使用平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在LIR上分离寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码
Client Compiler架构

Server Compiler是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,它会执行所有经典的优化动作。Server Compiler的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构上的大寄存器集合。以即时编译的标准来看,Server Compiler编译速度比较缓慢,但依然远远超过传统的静态优化编译器,而且相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销

编译优化技术

在即时编译器中采用的优化技术有很多,本节主要针对以下四种优化技术:

公共子表达式消除

公共子表达式消除是一个普遍应用与各种编译器的经典优化技术,它的含义是:

数组边界检查消除

数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。由于java语言中访问数组元素时,系统将会自动进行上下界的范围检查,这必定会造成性能负担。为了安全,数组边界检查是必须做的,但数组边界检查是否必须一次不漏的执行则是可以“商量”的事情。例如编译器通过数据流分析判定数组下标的取值永远在[0,数组.length)之内,就可以把数组的上下界检查消除

从更高的角度看,大量安全检查使编写java程序更简单,但也造成了更多的隐式开销,对于这些隐式开销,除了尽可能把运行期检查提到编译期完成的思路之外,还可以使用隐式异常处理

if(x != null){
  return x.value;
}else{
  throw new NullPointException();
}

隐式异常优化后:

try{
  return x.value;
}catch(segment_fault){
  uncommon_trap();
}

虚拟机会注册一个Segment Fault信号的异常处理器(uncommon_trap()),这样x不为空时,不会额外消耗一次对foo判空的开销。代价是当x为空时,必须转入异常处理器中恢复并抛出NullPointException,速度远比一次判空检查慢

方法内联

方法内联是编译器最重要的优化手段之一,除了消除方法调用成本之外,更重要的意义是为其他优化手段建立良好的基础。方法内联的优化行为只不过是把目标方法的代码“复制”到发起调用的方法之中,避免发生真实的方法调用而已。但实际上java虚拟机中的内联过程远远没有那么简单,因为java中的方法大多数是虚方法,虚方法在编译期做内联的时候根本无法确定应该使用哪个方法版本

对此java虚拟机设计团队想了很多办法,首先是引入了一种名为“类型继承关系分析”(Class Hierarchy Analysis, CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多余一种的实现,某个类是否存在子类、子类是否为抽象类等信息

编译器在进行内联:

逃逸分析

逃逸分析(Escape Analysis)是目前java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。其基本行为是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,如作为调用参数传递到其他方法中,称为方法逃逸;被外部线程访问到,称为线程逃逸

如果能证明一个对象不会逃逸到方法或线程之外,则可能为这个变量进行一些高效的优化:

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

java与C/C++的编译器对比实际上代表了最经典的即时编译器与静态编译器的对比。java虚拟机的即时编译器与C/C++的静态优化编译器相比,可能会由于下列原因而导致输出的本地代码有一些劣势:

  1. 即时编译器运行时占用的是用户程序的运行时间,因此即时编译器不敢随便引入大规模的优化技术,而编译的时间成本在静态优化编译器中并不是主要的关注点
  2. java语言是动态的类型安全语言,这就意味着虚拟机必须频繁地进行安全检查
  3. java语言中虚方法的使用频率远远大于C/C++语言,导致即时编译器在进行一些优化时的难度要远大于C/C++的静态优化编译器
  4. java语言时可以动态扩展的语言,运行时加载新的类可能改变程序类型的继承关系,导致许多全局的优化措施都只能以激进优化的方式来完成
  5. java虚拟机中对象的内存分配都是在堆上进行的,而C/C++的对象则有多种分配方式,而且C/C++中主要由用户程序代码来回收分配的内存,因此运行效率上比垃圾收集机制要高

上面说的java语言相对C/C++的劣势都是为了换取开发效率上的优势而付出的代价,而且还有许多优化是java的即时编译器能做而C/C++的静态优化编译器不能做或者不好做的,如别名分析、调用频率预测、分支频率预测、裁剪为被选择的分支等

上一篇 下一篇

猜你喜欢

热点阅读