深入理解Java虚拟机-第十一章 晚期(运行期)优化

第十一章 晚期(运行期)优化

11.1 概述

本章讲述 JIT(Just In Time Compiler,即时编译器)。Java 虚拟机规范没有具体的约束规则区限制即时编译器应该如何实现,但是 JIT 编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,他也是虚拟机中最核心且最能体现虚拟机技术水平的部分。

如无特殊说明,本章提及的编译器、即时编译器都是指的 HotSpot 虚拟机内的 JIT ,虚拟机也是特指 HotSpot 虚拟机。

11.2 HotSpot 虚拟机内的即时编译器

本节中,我们将要了解 HotSpot 虚拟机内的 JIT 的运作过程,同时还要解决以下几个问题:

  • 为何 HotSpot 虚拟机要使用解释器与编译器并存的架构?
  • 为何 HotSpot 虚拟机要实现两个不同的即时编译器?
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译为本地代码?如何编译为本地代码?
  • 如何从外部观察即时编译器的编译过程和编译结果?
11.2.1 解释器与编译器

解释器和编译器各有优势:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译时间立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后可以获得更高的执行效率。当程序运行环境中内存资源限制较大(如部分嵌入式系统),可以使用解释执行节约内存,反之则可以使用编译执行来提升效率。同时解释器还可以作为一个编译器激进优化的逃生门,当激进优化后出现 “罕见陷阱”(Uncommon Trap)时可以通过 “逆优化”(Deoptimization)退回到解释状态继续执行(部分没有解释器的虚拟机中也会采用不进行激进优化的 C1 编译器担任 逃生门 的角色)。所以解释器和编译器两者经常配合工作,如图所示:
解释器与编译器的交互
HotSpot 虚拟机中默认内置了两个 JIT 编译器,分别称为 Client Compiler 和 Server Compiler,也被简称为 C1 编译器和 C2 编译器。关于 HotSpot 两个编译器的解释,引用一篇博客中相关的描述。博客的名称叫《Java 面试-即时编译( JIT )》

在 HotSpot 虚拟机中,内置了两种 JIT,分别为C1 编译器和C2 编译器,这两个编译器的编译过程是不一样的。

  • C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,也称为Client Compiler,例如,GUI 应用对界面启动速度就有一定要求。
  • C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Compiler,例如,服务器上长期运行的 Java 应用对稳定运行就有一定的要求。

在 JDK 7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数 -client或者-server 强制指定虚拟机的即时编译模式。
分层编译将 JVM 的执行状态分为了 5 个层次:

  • 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
  • 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;
  • 第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;
  • 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;
  • 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

对于 C1 的三种状态,按执行效率从高至低:第 1 层、第 2层、第 3层。
通常情况下,C2 的执行效率比 C1 高出30%以上。
在 Java8 中,默认开启分层编译,-client 和 -server 的设置已经是无效的了。如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1。

书中只提到了 0-1-4 3 层,而博客中细分为 5 层。1.8 前可通过 -client 和 -server 来设置使用 C1 还是 C2 ,但1.8后,在 Java8 中,默认开启分层编译,-client 和 -server 的设置已经是无效的了。
无论采用的编译器是 C1 还是 C2 ,解释器与编译器搭配使用的方式在虚拟机中称为 混合模式(Mixed Mode),也可通过 -Xint 和 -Xcomp 来将模式改为 解释模式 和 编译模式。可以通过 -version 命令数出结果显示这三种模式,如下图所示:
虚拟机执行模式

11.2.2 编译对象与触发条件

热点代码分两类:

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

前者很好理解,一个方法被调用多了,方法体内代码执行的次数自然就多,它成为热点代码是理所当然的。而后者则是为了解决一个方法只会被少量调用但是方法体内部存在循环次数较多的循环体的问题,这样循环体的代码也被重复执行多次,也应当被认为是热点代码。
两种情况都是以整体方法作为编译对象,第一种情况比较好理解,这种编译也是虚拟机中标准的 JIT 编译方式。而对于后一种情况,尽管编译动作是由循环体所触发,但是编译器仍然会编译整个方法。这种编译方式因为编译发生在方法执行过程中,因此形象地称之为 栈上替换(On Stack Replacement,简称为 OSR 编译,即方法栈帧还在栈上,方法就被替换了)。
判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),目前主要的热点探测判定方式有两种:

  • 基于采样的热点探测(Sample Based Hot Spot Detection):采用这种方式的虚拟机会周期性的检查各个线程的栈顶,如果发现某个(某些)方法经常出现在站定,那这个方法就是 “热点方法”。有点事简单高效,很容易地获取方法调用关系,缺点就是很难精确的确定一个方法的热度
  • 基于计数器的热点探测(Counter Based Hot Spot Detection):采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果超过一定阈值则认为它是 “热点方法”。优点是能够精确和严禁的确定热点方法,缺点则是需要为每个方法建立并维护计数器,且不能直接获取到方法的调用关系。

在 HotSpot 虚拟机中,使用的是第二种——基于计数器的热点探测方法,所以他为每个方法准备了两类计数器:

  • 方法调用计数器:在 Client 模式下,方法计数器默认阈值是 1500 次,而在 Server 模式下则是 10,000 次,这个阈值可以通过 -XX:CompileThreshold 来人为设定。而在分层编译的情况下-XX: CompileThreshold指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。整体流程如下:
    方法调用计数器触发即时编译
    如果不做设置,这里的方法调用器计数并不是一个绝对的调用次数,而是一段时间内的调用次数。当超过一定时间限度后,如果仍然不满足提交 JIT 编译时,那这个计数器就会被减少一半,这个过程被称作方法调用计数器热度的衰减,而这段时间就称为半衰周期。进行热度衰减的动作是在虚拟机进行垃圾回收时顺便进行的,可以使用 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样随着运行时间的增长,绝大多数方法都会被编译成本地代码提高运行速度。可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。

  • 回边计数器:回边计数器用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。虽然 HotSpot 也提供了一个 -XX:BackEdgeThreshold 供用户设置回边计数器阈值,但是当前虚拟机并未用到,所以要设置另一个参数 -XX:OnStackReplacePercentage 来间接调整回边计数器的阈值计算公式如下:

    • Client 模式下,计算公式为:
      方法调用计数器阈值 * OSR 比率 / 100
      OSR 比例默认为 933,如果都取默认值,那么 Client 模式虚拟机的回边计数器的阈值为 13995
    • Server 模式下,计算公式为:
      方法调用计数器阈值 * (OSR 比率 - 解释器监控比率 (InterpreterProfilePercentage))/ 100
      其中 OSR 比例默认值为 140,InterpreterProfilePercentage 默认值为 33,都取默认值,则 Server 模式下的虚拟机回边计数器的阈值为 10700。

    说这些其实仅用作了解,在分层编译的情况下,-XX: OnStackReplacePercentage指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。具体整个流程如下:
    回边计数器触发即时编译
    与方法计数器不同,回边计数器没有技术热度衰减的过程。因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候他还会吧方法计数器的值也调整到溢出状态,这样下次在进入该方法的时候就会执行标准编译过程。

这里需要提醒一点,上面两个流程图仅仅介绍的是 Client VM 的即时编译方式,对于 Server VM 来说,执行情况会比上面的描述更复杂一些。

11.2.3 编译过程

在默认设置下,无论是哪种情况产生的即时编译请求,虚拟机代码编译器还未完成编译之前,都仍然按照解释方式继续进行,而编译动作则在后台的编译线程中进行。用户可以通过参数 -XX:-BackgroundCompilation 来禁止后台编译,这样一旦达到 JIT 的条件,执行线程向虚拟机提交编译请求后会一直等待,知道编译完成。
对于 Client Compiler 来说,他是一个简单的三段式编译器:

  • 第一阶段:首先会完成一些基础优化,如方法内联、常量传播等。之后,一个平台独立的前端,将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR)。HIR使用静态分配(Static Single Assignment,SSA)的方式代表代码值,这可以使一些在HIR构造之中和之后进行的优化更容易实现
  • 第二阶段:首先在HIR上完成另一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。之后,一个平台相关的后端,会从HIR中产生低级中间代码(Low-Level Intermediate Representation,LIR)
  • 最终阶段:在平台相关的后端上使用线性扫描法(Linear Scan Register Allocation)在LIR上分配寄存器,并在LIR上做窥孔(Peephole)优化,然后产生机器代码。大体流程如下所示:
    Client Compiler 架构
    对于 Server Compiler ,引用书上的话作总结:

Server Compiler则是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,它会执行所有经典的优化动作:无用代码消除(Dead Code Elimination)、循环展开(Loop Unrolling)、循环表达式外提(Loop Expression Hoisting)、公共子表达式消除(Common Subexpression Elimination )、常量传播(Constant Propagation)、基本块重排序(Basic Block Recording)等。还会实施一些与Java语言特性密切相关的优化技术,如范围检查消除(Range Check Elimination)、空值检查消除(Null Check Elimination),不过并非所有的空值检查消除都是依赖编译器进行优化的,有一些是在代码的运行过程中自动优化了)等。另外,还可能根据解释器或Client Compiler提供的性能监控信息,进行一些不稳定的激进优化,如守护内联(Guarded Inlining)、分支频率预测(Branch Frequency Prediction)等。
Server Compiler的寄存器分配器是一个全局图着色分配器,它可以充分利用某些处理器架构(如RISC)上的大寄存器集合。以即时编译的标准来看,Server Compiler无疑是比较缓慢的,但它的速度仍然远远超过传统的静态优化编译器,而且它相对于Client Compiler编译输出的代码质量有所提高,可以减少本地代码的执行时间,从而抵消了额外的编译时间开销,所以也有很多非服务端的应用选择使用Server模式的虚拟机运行。

11.2.4 查看及分析即时编译结果

11.3.1 优化技术概览

话不多说直接上图:
1
2
3
书中举例说明了几个简单的优化,此处不一一赘述。

11.3.2 公共子表达式消除

这个其实跟我们中学数学学的提取公因数非常相似,它的含义是:如果一个表达式 E 已经计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生过,那么这个 E 的这次出现就成为了公共子表达式。对于这种表达式没有必要再次计算,只需要直接用前面计算过的表达式结果代替 E 就可以了。例如:

	public static void main(String[] args) {
        int a = 10, b = 11, c = 12;
        int d = (c * b) * 12 + a + (a + b * c);
    }

这段代码进入到 JIT 后,编译器检测到 c * b 和 b * c 没区别,并且在计算时没改变,则替换成如下伪代码

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

这时编译期可能还会做一次代数化简优化:

    int d = E * 13 + a * 2;

当然这里举的例子我们其实自己就可以优化成这样,生产中需要优化的代码可能比这里麻烦得多,

11.3.3 数组边界检查消除

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

11.3.4 方法内联

先伪代码举例:

	static class B{
        int value;

        final int getValue() {
            return value;
        }
    }

    public void foo() {
        B b = new B();
        y = b.getValue();
        // do something...
        z = b.getValue();
        sum = y + z;
    }

优化后

	public void foo() {
        B b = new B();
        y = b.value;
        // do something...
        z = b.value;
        sum = y + z;
    }

方法内联看起来只不过是将目标方法的代码复制到发起调用的方法中,避免发生真实的方法调用而已.但实际上Java虚拟机中的内联过程远远没有那么简单,因为如果不是即时编译器做了一些特别的努力,按照经典编译原理的优化理论,大多数的Java方法都无法进行内联
无法内联的原因前面讲过,只有使用 invokespecial 指令调用的私有方法、实例构造器、父类方法以及使用 invokestatic 指令进行调用的静态方法才是在编译器进行解析的。除了上述四种方法,其余方法都是要在运行期时进行方法接收者多态选择后才可以调用。对于一个虚方法来说,编译期做内联时根本没法判断用哪个方法版本。为了解决这个问题,虚拟机团队首先引入了一种名为 “类型继承关系分析”(Class Hierarchy Analysis,CHA)的技术。
编译器在进行内联时,对于非 virtual 方法,直接进行内联,如果是 invokevirtual时,则先向 CHA 查询此方法在当前程序下是否有多个目标版本,如果查询结果只有一个,则也可以内联,不过这种内联就属于前文提到过的激进优化了,需要留有逃生门。
如果向 CHA 查询出来的结果是有多个版本的目标方法可供选择,则编译器还会进行最后一次努力,使用内联缓存(Inline Cache)来完成方法内敛。在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接受者的版本信息,并且每次进行方法调用时都比较接受者版本,如果以后进来的每次调用的方法接受者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接受者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内敛,查找虚方法表进行方法分派。

11.3.5 逃逸分析

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

  • 栈上分配(Stack Allocation):如果一个对象被分析到不会出现在别的线程和方法中时,其实可以直接在栈上分配这个变量,随着虚拟机栈帧出栈,变量随之销毁。这样大大减少了 GC 所需要清理的垃圾
  • 同步消除(Synchronization Elimination):线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,那么这个变量的读写肯定是安全的,那么针对这个变量实施的同步措施也就可以消除掉了。
  • 标量替换(Scalar Replacement):标量是指一个数据已经无法再分解为更小的数据来表示了。Java虚拟机中原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那么它就称作聚合量,Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始数据来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步的优化手段创建条件。

如果要完全准确的判断一个对象是否会逃逸,需要进行数据流敏感的一系列复杂分析,从而确定程序各分支执行对目标对象影响。过程耗时较长不说,如果分析完后没有几个不逃逸的对象,那么这段时间就白白浪费了。而 JDK 8 中已经默认开启了逃逸分析说明现在的虚拟机团队对逃逸分析已经有了十足的把握,但是还是要放上几个关闭逃逸分析的参数:

  • 开启逃逸分析(JDK8中,逃逸分析默认开启。)
    -XX:+DoEscapeAnalysis
  • 关闭逃逸分析
    -XX:-DoEscapeAnalysis
  • 逃逸分析结果展示
    -XX:+PrintEscapeAnalysis
  • 开启标量替换
    -XX:+EliminateAllocations
  • 开启同步消除
    -XX:+EliminateLocks
  • 查看标量替换结果
    -XX:PrintEliminateAllocations

关于逃逸分析讲解的找到一篇很好的文章,有兴趣的可以去看下:《Java-JVM-逃逸分析》

读书越多越发现自己的无知,Keep Fighting!

本文仅是在自我学习 《深入理解Java虚拟机》这本书后进行的自我总结,有错欢迎友善指正。

欢迎友善交流,不喜勿喷~
Hope can help~

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值