《深入了解Java虚拟机》第十一章

第十一章 后端编译与优化

· 无论是提前编译器抑或即时编译器,都不是Java虚拟机必需的组成部分。但是它却是商业Java虚拟机中的核心。
· 2012年的Java世界中,提前编译器(AOT,Ahead OfTime)虽然早已有应用,但是即时编译器(JIT,Just InTime)才是主流。而这几年,AOT逐渐被主流JDK支持。
本章中所提及的都特指HotSpot虚拟机内置的即时编译器。

11.2 即时编译器

· 目前主流的两款商用Java虚拟机(HotSpot、OpenJ9)里,Java程序最初都是通过解释器进行解释执行的。
· 当虚拟机发现某个方法或者代码块的运行特别频繁,就会把这些代码认定为“热点代码”。为了提高热点代码执行效率,运行时,虚拟机就会把这些代码编译为本地机器码,并进行代码优化。运行时完成这个任务的后端编译器就称为“即时编译器”。
要解决的问题:

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

11.2.1 解释器与编译器

目前主流的商用JVM,内部都同时包含了解释器和编译器。它们各自有各自的优势:

  • 当程序需要快速启动和执行时,解释器可以立即发挥作用;随着时间推移,编译器逐渐发挥作用,把越来越多的代码编译为本地机器代码,获得更高效率。
  • 当程序运行环境中内存资源限制较大,可以使用解释执行节约内存(如部分嵌入式系统和部分JavaCard应用中只有解释器),反之可以使用编译执行提高效率。
  • 解释器还可以作为编译器激进优化时的“逃生门”(情况允许,HotSpot还可能选择不进行激进优化的客户端编译器充当“逃生门”)。
    激进优化是根据概率选择一些不能保证所有情况都正确,但大多时候能提升运行速度的手段。
    如果激进优化不成立,如加载新类,类型继承关系改变、出现“罕见陷阱”可以立即采取逆优化,退回到解释状态运行。
    在这里插入图片描述
    · HotSpot虚拟机内置两个(或三个)即时编译器,其中两个分别为“客户端编译器”(也称C1编译器)和“服务端编译器”(也称C2编译器)。第三个Graal编译器是JDK10出现的,长期目标是替换C2。
    · 在分层编译工作模式出现前,HotSpot通常采用解释器与其中一个编译器搭配的工作方式。程序使用哪个编译器,只取决于虚拟机运行的模式是客户端还是服务端模式。虚拟机一般根据版本和宿主机硬件性能选择模式,不过也可以通过参数“-client”或“-server”自行设置。
    · 无论使用哪种编译器,这种搭配使用方式都称为“混合模式”。
  • 用户可以使用参数“-Xint”强制使虚拟机运行在“解释模式”(Interpreted Mode),这时候编译器完全不介入工作,全部代码由解释方式执行。
  • 用户可以使用参数“-Xcom”强制使虚拟机运行在“编译模式”(Compiled Mode),这时候优先采用编译方式执行。解释器作为“逃生门”。

· 由于JIT编译本地代码需要占用程序运行时间,优化程度越高时间越久;而且想要编译出优化程度高的代码,需要解析器收集性能监控信息,这对解释执行阶段的速度也有影响。
· 为了权衡程序启动响应速度和运行效率,加入了分层编译功能。在JDK7的服务端模式虚拟机中作为默认编译策略被开启。划分编译层次,包括:
- 第0层。纯解释执行。不开启性能监控功能(Profiling
- 第1层。使用客户端编译器。简单稳定优化,不开启性能监控功能。
- 第2层。使用客户端编译器。仅开启方法及回边次数统计等有限性能监控功能
- 第3层。使用客户端编译器。开启全部性能监控,除了2层的信息,还收集分支跳转、虚方法调用版本等全部统计信息。
- 第4层。使用服务端编译器。会启用更多编译耗时更长的优化,还会根据监控信息进行些不可靠激进优化
以上层次并非固定不变,分层数量可以根据运行参数、版本调整。
在这里插入图片描述
实施分层编译后,解释器、C1、C2编译器就会同时工作。热点代码可能会被多次编译,用客户端编译器获取更好的编译速度服务端编译器获得更好编译质量。解释器执行时无需额外承担收集监控信息任务;在服务端编译器采用复杂优化时,客户端编译器可先采用简单优化为它争取更多编译时间。

11.2.2 编译对象与触发条件

· 被即时编译的目标是“热点代码”,包括:

  1. 被多次调用的方法
  2. 被多次执行的循环体
    · 对于这两种情况,编译的目标对象都是整个方法体
    · 第一种情况,由于是依靠方法调用触发的编译,编译器理应以整个方法作为编译对象。
    · 对于第二种情况,尽管编译动作是由循环体触发的,热点只是方法的一部分,但编译器依旧要以整个方法作为编译对象,只是执行入口会有所不同。编译时会传入执行入口点字节码序号(BCI,Byte Code Index)。
    · 这种编译方式因为编译发生在方法执行的过程中,因此称为“栈上替换(OSR,OnStack Replacement)。即方法的栈帧还在栈上,方法就被替换了。

· 热点探测:某段代码是不是热点代码,是不是需要触发即时编译。
· 目前主流的热点探测判定方式:

  1. 基于采样的热点探测。采用这种方法的虚拟机会周期性检查各个线程的调用栈顶,如果发现某个(某些)方法经常出现在栈顶,就是“热点方法”。
    好处:实现简单高效,很容易获取方法调用关系将调用堆栈展开即可)。
    缺点:很难精准确认一个方法的热度,容易因受到线程阻塞或别的外界因素的影响而扰乱探测。
  2. 基于计数器的热点探测。采用这种方法的虚拟机为每个方法(甚至是代码块)建立计数器,当执行次数超过某个阈值,就认为是“热点方法”。
    好处:统计结果更加严谨。
    缺点:实现麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取方法的调用关系。

· J9用过第一种,HotSpot使用的是第二种。
· 为了实现热点计数,HotSpot为每个方法准备了两类计数器方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter,“回边”指在循环边界往回跳转。也可以说是,在字节码中遇到控制流向后跳转的指令)。当虚拟机运行参数确定的前提下,这两个计数器都有一个明确的阈值,一旦溢出,就会触发即时编译

· 方法调用计数器:统计方法被调用的次数。默认阈值在客户端模式下是1500次,服务端模式下是10000次。
· 当一个方法被调用时,虚拟机会先检查该方法是否存在被即时编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在,则该方法的计数器加一,然后判断两类计算器(方法调用计数器和回边计数器)之和是否超过方法调用计数器的阈值。一旦超过,将会向即时编译器提交一个该方法的代码编译请求

· 如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器,按照解释方式执行字节码,直到提交的请求被JIT编译完成。当编译完成,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了。
在这里插入图片描述
· 默认设置下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。
· 当超过一定的时间限度,方法的调用次数还没达到阈值,就把该方法的调用计数器减少一半,这个过程被称为方法调用器热度的衰减,而这段时间称为此方法统计的半衰周期
· 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可用参数关闭热度衰减,这样只要系统运行时间足够长,程序中大部分都会被编译成本地代码。还可以设置半衰周期时间。

· 回边计数器:统计一个方法中循环体代码执行的次数(准确说是回边次数,因为有的循环不是回边,比如空循环是自己跳到自己,不算控制流向后跳转,也不会被计数器统计)。
· 建立回边计数器统计的目的是为了触发栈上的替换编译
· 当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本。如果有,优先执行它;否则回边计数器值加一,然后判断两类计数器之和是否超过回边计数器的阈值。一旦超过,提交一个栈上替换编译请求,并把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
在这里插入图片描述
· 与方法计数器不同的是,回边计数器没有计数热度衰减的过程。因此该计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,方法计数器的值调到溢出状态,这样下次进入该方法时直接会执行已编译版本。

· 图11-2和11-3都仅是描述了客户端模式虚拟机的即时编译方式。对服务端模式虚拟机来说,执行情况更复杂。

11.2.3 编译过程

· 在默认条件下,无论是方法调用产生的标准编译请求,还是栈上替换编译请求,虚拟机在未完成编译前,都仍按照解释方式执行代码,而编译动作则在后台的编译线程中进行。
· 如果设置参数禁止后台编译,到达触发即时编译条件时,执行线程向虚拟机提交请求后,会一直阻塞等待,直到编译过程完成后再开始执行编译器输出的本地代码。
· 后台执行编译过程中,服务端和客户端编译器具体做的事有差别。
· 对于客户端编译器来说,是一个相对简单快速的三段式编译器,主要关注局部性的优化,而放弃了许多耗时较长的全局优化。

· 第一阶段,一个平台独立的前端将字节码构造成一个高级中间代码表示(HIR,即与目标机器指令集无关的中间表示),HIR使用静态单分配(SSA)的形式来表示代码值,这可以使一些在HIR构造之中和之和的优化动作更容易实现。在此之前,编译器已经在字节码上完成部分基础优化,如方法内联、常量传播等优化将会在字节码被构造成HIR之前完成。
· 第二阶段,一个平台相关的后端会从HIR中产生低级中间代码表示(LIR,即与目标机器指令集相关的中间表示),在此之前会在HIR上完成另一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。
· 最后阶段,是在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR做窥孔(Peephole)优化,然后产生机器代码。客户端编译器大致执行过程如下:
在这里插入图片描述
服务端编译器则是专门面向服务端典型应用场景、并为了服务端的性能配置针对性调整过、能容忍很高优化复杂度高级编译器

它会执行大部分经典的优化动作,如:无用代码消除(Dead Code Elimination)、循环展开(LoopUnrolling)、循环表达式外提(Loop Expression Hoisting)、消除公共子表达式(Common Subexpression Elimination)、常量传播(Constant Propagation)、基本块重排序(BasicBlock Reordering)等。
还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination,不过并非所有的空值检查消除都是依赖编译器优化的,有一些是代码运行过程中自动优化了)等。另外,还可能根据解释器或客户端编译器提供的性能监控信息,进行一些不稳定的预测性激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch FrequencyPrediction)等。

· 服务端编译采用的寄存器分配器是一个全局着色分配器,它可以充分利用一些处理器架构(如RISC)的大寄存器集合。以即时编译的标准来看,它无疑是缓慢的,但是编译速度还是远超静态优化编译器,而且因为它能提供更高质量的代码,可以大幅度减少本地代码执行时间,从而抵消掉编译时间较长的缺点。所以有些非服务端应用也采用服务端模式的HotSpot虚拟机来运行。

基本块是指程序按照控制流分割出来的最小代码块,它的特点是只有唯一的一个入口和唯一的一个出口。只要基本块中第一条指令被执行了,那么基本块内所有指令都会按照顺序全部执行一次。
例如:图中数据流以虚线表示,控制流以实线表示。说明代码的执行顺序是先调用getX()方法,再调用getY()方法。(表达式:getX()+getY())
在这里插入图片描述

11.3 提前编译器

· 虽然提前编译器只比即时编译器晚几个月,但是由于Java当时的核心优势是平台中立性,这与提前编译理念产生冲突,因此一直沉寂。直到2013年Android使用提前编译的ART(Android Runtime),直接终结了使用即时编译的Dalvik虚拟机,也震撼到Java世界。
· 毕竟有的人只追求更好的执行性能,平台中立性、字节膨胀(提前编译后本地二进制码体积明显变大)、动态扩展(提前编译只允许在封闭程序中,不能在外部加载新的字节码)都可舍弃。问题:AOT真的可以获得更好的执行性能吗?

11.3.1 提前编译的优劣得失

· 现在提前编译产品有两条明显分支:

  1. 第一条与c/c++编译器类似,完成在程序运行前先把代码编译成机器码的静态翻译工作
  2. 第二条是把原本JIT在运行时需要做的编译工作提前做好并且保存,下一次运行到这些代码,就直接加载进来使用。

对于第一条:
· 传统的提前编译应用形式,Android 的ART就是这种形式。可以用运行前的时间来换取更好的性能。Android 7.0起,重启启动了解释执行和即时编译,当空闲时系统再在后台自动进行提前编译
· 采用即时编译的最大弱点就是需要占用程序运行时间和运算资源。而提前编译刚好克制这个弱点。即使现在先进的JIT已经很快,甚至能够容忍相当大的优化复杂度了;即使有分层编译支持,能够用快速、质量低的JIT为高质量的JIT争取更多编译时间,但是,即时编译消耗的时间(资源)都是原本可用于程序运行的时间(资源)。

e.g 其中最耗时的优化之一是“过程间分析优化”,它需要在全代码范围进行大量工作,来获取诸如某个程序点上某个变量值是否一定为常量、某段代码段是否永不再被调用、在某个点调用的某个虚方法是否只能是单一版本等。
目前所见的虚拟机对该优化支持有限,要么借助大规模的方法内联,打通方法间的隔阂,以“过程内分析”(只考虑过程内部语句,不考虑过程调用分析)来模拟模拟过程间分析的部分效果;要么借助激进优化。
但如果是程序运行前做静态编译,这些耗时优化就可以大胆进行了。能够采取许多即时编译不会做的全程序优化措施来获得更好的运行性能。

对于第二条:
· 本质是给即时编译器做缓存加速,去改善:1. Java程序的启动时间;2. 需要预热才能到达最高性能 这两个问题。
· 这种提前编译方式称为:动态提前编译,即时编译缓存
· 目前的java体系中,这条路径已经完全被主流商用JDK支持。真正引起业界广泛关注的是OpenJDK/OracleJDK 9中所带的Jaotc提前编译器,是基于Graal编译器实现的工具。用户能够针对目标机器,为应用程序进行提前编译
· 实现困难很多,譬如虚拟机运行时采用了不同的垃圾收集器,这原本就需要即时编译子系统的配合才能正常工作。如果要进行提前编译,就需要把这些配合工作平移过去。而且编译后的静态链接库只能支持运行在相同参数的虚拟机上。

· 尽管即时编译在时间、运算资源上的劣势无法忽视,但针对提前编译也有天然优势。

  1. 性能分析制导优化(PGO)。前面提到,解释器或者客户端编译器运行中,会不断收集性能监控信息,譬如某个程序点抽象类的实际类型是什么、条件判断会走哪个分支、循环会进行多少次等,这些数据在静态分析时是无法得到的,或者是说只能通过启发性条件去进行猜测。但动态运行时却可以看出明显的偏好性。这样就可以将热点代码集中放到一起,集中优化和分配更好资源给它。
  2. 激进预测优化。静态优化必须保证优化前后,程序外部可见影响是不变的,不然程序出错优化毫无意义。而提前编译不存在这个问题,如果失败,大不了退回到低级编译器甚至解释器上去执行。
    Java虚拟机中,会通过类继承关系等一系列激进的猜测去做虚拟化,保证大部分有内联价值的虚方法都能内联。内联是最基础的一项优化措施。
  3. 链接时优化(LTO,Link-Time Optimization)。Java语言天生是动态链接的,一个个Class文件在运行期被加载进虚拟机内存,然后在即时编译器中产生优化后的本地代码。
    但如果放到C/C++这样使用提前编译的语言上,譬如C/C++程序要调用某个动态链接库的某方法,会出现明显边界隔阂,而且很难优化。因为主程序与动态链接库程序在编译时是完全独立的,编译时间、编译器都可能完全不同。当出现跨链接库边界的调用时,理应做的优化——譬如调用方法内联,执行就会十分困难。

11.4 编译器的优化技术

11.4.1 优化技术概览

在这里插入图片描述
即时编译器对这些代码优化变换是建立在代码的中间表示或者是机器码之上的,不是直接在Java源码上去做的。以下为了方便理解,写成java源码形式。

· 第一步方法内联:主要目的一个是去除方法调用的成本;一个是为其他优化建立良好基础。方法内联膨胀后,可以便于在更大范围上进行后续优化手段。一般用于靠前的优化。
在这里插入图片描述

· 第二步冗余访问消除:
在这里插入图片描述
假设其中的“do stuff”所代表的操作没有改变b.value的值,那么可以把“z=b.value”替换为“z=y”。这样就能不再去访问对象b的局部变量了。把b.value看成一个表达式,这项优化就是一种公共子表达式消除

· 第三步复写传播:依旧如上个例子,该段逻辑没必要使用额外的z变量,我们可以用y代替z。
在这里插入图片描述

· 第四步无用代码消除:无用代码指永不会执行的代码或者完全没有意义的代码。比如经过第三步,“y=y”是毫无意义的,会将其删除。
在这里插入图片描述

11.4.2 方法内联

最重要的优化技术(之一)。

· 没有内联,多数其他优化都无法有效进行。譬如下面的例子,两个方法不内联,后续就无法发现“Dead Code”无用代码的存在。分开来看,两个方法中的操作可能都有意义。
在这里插入图片描述
· 方法内联的优化行为,就是把目标方法的代码原封不动地“复制”到发起调用的方法中,避免发生真实的方法调用而已。

面临问题
· 如果不是即时编译器做了特殊努力,大部分Java方法都没有办法实现内联。理由:只有使用invokespecial指令调用的私有方法实例构造器父类方法和使用invokestatic指令调用的静态方法,还有被final修饰的方法(虽然使用invokevirtual指令调用,但也是非虚方法)才会在编译期进行解析。其他的java方法调用必须在运行时进行方法接收者的多态选择。简而言之,Java语言中默认的实例方法是虚方法
· 对于一个虚方法,编译器静态做内联时候很难确定应该使用的方法版本。
如11-7中b.get()直接内联为b.value为例,如果不依赖上下文,无法确定b的实际类型是什么。
再如一个继承了父类ParentB的子类SubB,SubB中重写了父类的get()方法,那么b.get()执行哪个版本,需要根据实际类型动态分派,而实际类型需要实际运行到这行代码才能确定,编译器在编译时很难得到准确结论。
· 更糟糕的是,Java提倡面向对象的编程,而java对象默认方法是虚方法。虚方法和内联的矛盾上述可知。C/C++中默认为非虚方法,需要用到多态时采用的是用virtual关键字修饰。但Java采用虚拟机来解决问题。

解决问题
· JVM首先引入了类型继承关系分析(CHA,Class Hierarchy Analysis),这是种全应用程序范围内类型分析技术,用于确定已加载的类中,某个接口是否有多于1种的实现、某个类是否有子类、某个子类是否覆盖了父类的某个虚方法等信息。
· 这样,编译器进行内联时会根据不同情况采取不同的处理:如果是非虚方法,直接进行内联;如果是虚方法,向CHA查询此方法的当前程序状态下是否有多个版本可供选择。
· 如果只有一个,直接假设“应用程序全貌就是现在运行的样子”进行内联,称为守护内联。不过因为Java程序是动态连接的,可能之后又加载到新的类型,改变了CHA结论。因此这种内联是激进优化
· 如果检测出多版本的目标方法可供选择,那即时编译器会进行最一次努力,使用内联缓存的方式缩减方法调用的开销。这种状态下方法调用是真正发生了的,但比起直接查虚方法表还是快一些。内联缓存是建立在目标方法正常入口之前的缓存。
· 内联缓存的工作原理大致为:在未发生方法调用前,内联缓存为空。当发生第一次调用后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接受者版本。
· 如果以后进来的每次调用的方法接收者版本都是一样的,那么这时它就是一种单态内联缓存。通过该缓存来调用,比用不内联的非虚方法调用,仅多了一次类型判断的开销而已。
· 如果真的出现方法接收者不一致情况,说明程序用到了虚方法的多态特性,这时候会退化成超多态内联缓存。其开销相当于真正查找虚方法表来进行方法分派。
· 所以说,多数情况下Java虚拟机进行的方法内联都是一种激进优化

11.4.3 逃逸分析

**最前沿**的优化技术之一。

· 逃逸分析和CHA一样,是为其他的优化措施提供依据的分析技术
· 逃逸分析的基本原理:分析对象动态作用域,当一个对象在方法里被定义后,如果被外部方法调用,例如作为参数传到别的方法中,称为“方法逃逸”;被外部线程访问到,譬如赋值给其他线程中访问的实例变量,这种称为“线程逃逸”。
· 从不逃逸、方法逃逸、线程逃逸,称为“对象由低到高的不同逃逸程度”。
· 如果能证明一个对象不会逃逸到方法或线程外,或者逃逸程度比较低(不逃逸出线程),则可能为这个对象实例采取不同程度的优化,如:
- 栈上分配:(复杂度太高,HotSPot还没有使用)JVM中,在Java堆上分配创建对象的内存空间。Java堆中的对象对于各个线程都是共享可见的。只要持有这个对象的引用,就能访问到对象数据。
如果确定对象不会逃出线程,可以让该对象在栈上分配内存,对象所占空间随栈帧出栈而销毁。这样就能减轻垃圾收集子系统的压力。

- <font color = #F9000>标量替换</font>:如果一个数据无法再继续分解了,就称为**标量**,反之,称为**聚合量**。JVM中的原始数据类型(int、reference等)都是标量。Java对象就是典型聚合量。把一个对象拆散,将其用到的成员变量恢复为原始类型来访问,就是**标量替换**。
&emsp;如果逃逸分析证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行时可能不会创建这个对象,而是改为直接创建若干被这个方法使用的**成员变量**来代替。
&emsp;对象拆分后,对象的成员变量能在**栈上分配读写**外(栈上存储数据,很大机会被虚拟机分配到物理机的高速寄存器中存储),还可以为后续优化创造条件。
&emsp;标量替换可以视为栈上分配一特例,实现更简单(不用考虑整个对象完整结构的分配),但是不允许对象逃逸出方法范围内。

- <font color = #F9000>同步消除</font>:线程同步是个耗时工作。如果逃逸分析能确定一个变量不会逃逸出线程,就能把对这个变量实施的同步措施消除掉。

· 逃逸分析技术不成熟,HotSpot才开始支持初步的逃逸分析。因为计算成本非常高,甚至不能保证分析带来的性能能够大于计算成本消耗。
· C/C++中,原生支持栈上分配(只要不使用new),C#支持值类型,很自然可做到标量替换(并不会对引用类型做这种优化)。灵活运用栈内存方面,Java是处于弱项。现在正在设计inline关键字用于定义Java内联类型,与C#支持值类型功能相对标。

11.4.4 公共子表达式消除

**语言无关**的**经典**优化技术之一。

· 一个表达式E之前已经被计算过了,并且从计算到现在E中所有值没有变化,那直接用之前结果代替E。
· 如果这种优化仅限程序基本块内,便称为局部公共子表达式消除;如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。
· 编译器还可能(取决于哪种虚拟机的编译器,以及具体的上下文而定)进行另一种优化——代数化简

11.4.5 数组边界检查消除

**语言相关**的**经典**优化技术之一。

· Java语言是一门动态安全语言,不像C/C++对数组指针操作实质是裸指针操作,Java操作前会自动进行上下界范围检查,这虽然安全,但是代码量大,也是一种性能负担。
· 综合考虑后,只要在编译期根据数据流分析来确定数组长度的值,并判断要访问的数组下标没越界,那执行的时候就不需要判断了。如果是循环中,只要编译器通过数据流分析,判断了循环变量的取值范围在数组长度内,在循环中就能把整个数组上下界检查消除。

· 虽然大量的安全检查使编写Java程序比编写C和C++程序容易了很多,但也导致Java比C和C++要做更多的事情(导致一些隐式开销)。
· 为了消除这些隐式开销,除了如数组边界检查优化这种尽可能把运行期提前到编译期完成的思路外,还可以避开它——“隐式异常处理”,Java中空指针检查和算术运算中除数为零的检查都采用了这种方案。抛出异常,进入异常处理器的过程涉及进程从用户态转到内核态中处理的过程,结束后会再回到用户态,速度会比一次判断检查慢得多。当抛出异常次数少的情况,优化比较合理。

语言相关的其他消除操作还有不少,如自动装箱消除、安全点消除、消除反射等。

· Graal虚拟机以及Graal编译器仍在实验室中尚未商用,但未来有望代替或成为HotSpot下一代技术基础。
· Graal编译器最终目标是成为一款高编译效率、高输出质量、支持提前编译和即时编译,同时支持应用于包括HotSpot在内的不同虚拟机的编译器。
· JDK 9时发布的Java虚拟机编译器接口(JVMCI,Java-Level JVMCompiler Interface)使Graal可以从HotSpot的代码中分离出来。
· JVMCI主要提供如下三种功能

  1. 响应HotSpot的编译请求,并将该请求分发给Java实现的即时编译器
  2. 允许编译器访问HotSpot中与即时编译相关的数据结构,包括类、字段、方法及其性能监控数据等。并提供一组这些数据结构在Java语言层面的抽象表示
  3. 提供HotSpot代码缓存的Java端抽象表示,允许编译器部署编译完成的机器码。

· 综合利用上述三项功能,我们就可以把一个虚拟机外的即时编译器集成到HotSpot中,响应HotSpot发出的最顶层的编译请求,并将编译后的二进制码部署到HotSpot的代码缓存中。
· 单独使用上述第三项功能,又可以绕开HotSpot的即时编译系统,让该编译器直接为应用的类库编译出机器码,该编译器当作一个提前编译器去使用(如Jaotc)。
· 即时编译器:输入要编译的方法的字节码;输出与方法对应的二进制机器码。
· JVMCI编译器接口的输入除了字节码外,HotSpot还会向编译器提供各种该方法的相关信息,譬如局部变量表中变量槽的个数、操作数栈的最大深度,收集到的统计信息等。