[11] 前后端编译与优化
[11] 前后端编译与优化

1. 编译器种类

在 Java 技术下谈「编译期」需要有具体的上下文语境,因为它可能是指:前端编译器即时编译器(Just In Time Compiler,JIT 编译器,后端运行期编译器)提前编译器(Ahead of Time Compliler,AOT 编译器,静态提前编译器)。

1.1 前端编译器(静态编译)

.java 文件转换为 .class 文件的过程。这也是人们最常说的、最符合普通程序员对 Java 程序编译认知的「编译器」。
  • JDK 中的 Javac
  • Eclips JDT 中的增量式编译器(ECJ)

1.2 即时编译器(动态编译)

运行期把字节码转换为本地机器码的过程。
即时编译的技术,目的是避免解释执行,在运行期将整个方法体编译成本地机器码,每次方法执行时,只执行编译后的本地机器码即可,使执行效率大幅提升。
  • JIT 编译器:HotSpot VM 的 C1、C2 编译器
  • Graal 编译器

1.3 提前编译器(静态编译)

直接把 .java 文件编译成本地机器代码的过程。
  • JDK 中的 Jaotc
  • GNU Compiler for the Java(GCJ)
  • Excelsior JET

1.4 逆优化

HotSpot VM 中的解释器还可以作为即时编译器激进优化时的后备「逃生门」。
C2 编译器(Server Compiler)会根据 PGO(性能分析制导优化)选择一些激进的优化手段(不能保证所有情况都正确,但大多数时候都能提升运行速度)。
当激进优化的假设不成立时(如加载新类后类型继承结构出现变化、罕见情况等)可以通过逆优化(Deoptimization)退回到解释状态继续执行,因此在整个 JVM 执行架构里,解释器与编译器经常是相辅相成地配合工作。
notion image
如果情况允许,HotSpot VM 也会采用不进行激进优化的 C1 编译器(Client Compiler)充当「逃生门」的角色。

1.5 HotSpot VM 的程序执行模式

在 HotSpot VM 中也可以设置特殊的程序执行模式:
  • -Xint:完全采用解释器模式执行的解释模式(Interpreted Mode)
  • -Xcomp:完全采用即时编译器执行的编译模式(Compiled Mode),如果即时编译器出现问题,解释器会介入执行。
  • -Xmixed:采用解释器 + 即时编译器的混合模式(Mixed Mode)共同执行(默认)。

1.6 为什么 JVM 要用「两条腿」走路?

  • 首先程序启动后,解释器可以首先发挥作用,而不是等待即时编译器全部编译完成后再执行,这样可以省去很多不必要的编译时间,
  • 编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间,但编译为本地代码后执行效率更高。
  • 对于服务端应用,启动时间并非关注重点,但是对于看重启动时间的应用场景,就需要找到一个平衡点。
  • 随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。
JDK 1.2 以前 JVM 对字节码就是采用纯解释方式执行。也有内部没有解释器、由即时编译器工作的 JRockit VM,目前已不再发展。

2. Javac 编译器(前端编译器)

Javac 编译器不像 HotSpot VM 那样使用 C++ 语言(包含少量 C 语言)实现,它本身就是一个由 Java 语言编写的程序。

2.1 编译过程

从 Javac 代码的总体结构来看,编译过程大致可以分为 1 个准备过程和 3 个处理过程:
  1. 准备过程:初始化插入式注解处理器。
  1. 解析与填充符号表过程
      • 词法、语法分析
        • 句法分析:将源代码的字符流转变为标记(Token编译过程最小元素)集合。
        • 词法分析:构造出抽象语法树(AST,用来描述程序代码语法结构的树形表示方式,每一个节点代表程序中的一个语法结构)。
      • 填充符号表:产生符号地址和符号信息。
  1. 插入式注解处理器的注解处理过程:插入式注解处理器的执行阶段。
  1. 分析与字节码生成过程
      • 标注检查:对语法的静态信息进行检查(变量使用前是否已声明、变量赋值类型是否匹配、常量折叠:int a = 1 + 2 ==> int a = 3)。
      • 数据流及控制流分析:对程序动态运行过程进行检查(局部变量是否赋值、方法路径返回值验证、受检异常的正确处理等)。
      • 解语法糖:将简化代码编写的语法糖(泛型、装拆箱、for 循环、条件编译等)还原为原有的形式。
      • 字节码生成:将前面各个步骤所生成的信息转化成字节码(写到磁盘、<init>()<cinit>() 相关处理等)。
notion image

3. JIT 编译器(后端编译器)

目前主流的两款商用 JVM(HotSpot VM、OpenJ9 VM)里,Java 程序最初都是通过解释器进行解释执行的,当 JVM 发现某个方法或代码块的运行特别频繁,就会把这些代码认定为热点代码(Hot Spot Code)
为了提高热点代码的执行效率,在运行时,JVM 会把这些代码编译成本地机器码,并以各种手段尽可能地进行代码优化,在程序运行期里完成这个任务的后端编译器被称为即时编译器(Just In Time Compiler,JIT 编译器,后端运行期编译器)
在 HotSpot VM 中有两个传统即时编译器:客户端编译器(Client Compiler,C1 编译器)服务端编译器(Server Compiler,C2 编译器),也对应了 JVM 的两种工作模式:ClientServer
  • C1 编译器(Client Compiler):对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。
  • C2 编译器(Server Compiler):进行耗时较长的优化,以及激进优化,单优化后的代码执行效率更高。
JDK 10 新增了 Graal 编译器(实验特性),长期目标是代替 C2 编译器。

3.1 热点代码

根据代码被调用执行的频率而定,需要被编译为本地代码的字节码,也称之为热点代码
  • 被多次调用的方法
  • 被多次执行的循环体
JIT 编译器会在运行时针对频繁调用的热点代码做出深度优化,将其直接编译为对应平台的本地机器码,以此提升 Java 程序的执行性能。由于这种编译方法发生在方法的执行过程中,因此也被称为栈上替换(On Statck Replacement,OSR)。

3.2 热点探测

热点探测(Hot Spot Code Detection)决定了哪些代码属于热点代码,也决定了即时编译被触发的条件。目前主流的热点探测判定方式有两种:
  • 基于采样的热点探测(Sample Based Hot Spot Code Detection):周期检查各线程的调用栈顶,如果发现有方法经常出现在栈顶,那就认为这个方法是「热点方法」。
    • 优点:简单高效、容易获取方法调用关系(将调用堆栈展开即可)。
    • 缺点:难以精确确认一个方法的热度、容易受到外界因素(线程阻塞)影响而扰乱热点探测。
  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection):为每个方法或代码块建立计数器,统计方法执行次数,如果超过阀值就认为这个方法是「热点方法」。
    • 优点:统计结果与前者相比更加精确严谨。
    • 缺点:实现复杂(需要为每个方法建立并维护计数器)、不能直接获取方法调用关系。
两种热点探测方法在商用 JVM 中都有用到,J9 VM 用过第一种采样热点探测,而在 HotSpot VM 中使用的是第二种基于计数器的热点探测方法,为了实现热点计数,HotSpot VM 为每个方法都准备了两类计数器:方法调用计数器(Invocation Counter)回边计数器(Back Edge Counter)。
当 JVM 运行参数确定的前提下,这两个计数器都有一个明确的阈值,计数器阈值一旦溢出,就会触发即时编译。

3.3 方法调用计数器

方法调用计数器用于统计方法被调用的次数。它的默认阀值为:
  • Client 模式:1500 次
  • Server 模式:10000 次
这个阀值可以通过参数 -XX:CompileThreshold 来设置。

交互过程
notion image
如果没有做过任何设置,执行引擎默认不会同步等待编译请求完成,而是继续进入解释器按照解释方式执行字节码,直到提交的请求被即时编译器编译完成。
当编译工作完成后,这个方法的调用入口地址就会被系统自动改写成新值,下一次调用该方法时就会使用已编译的版本了。

热度衰减
方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频 率,即一段时间之内方法被调用的次数。因为热度衰减(Counter Decay)机制会使方法调用计数器的值减少。
当超过一定的时间限度,如果方法调用次数仍然不足以提交给即时编译器编译(未超过阀值),那么这个方法的调用计数器的值就会被减少一半。这个时间限度称为此方法统计的半衰周期(Counter Half Life Time)
  • 通过参数 -XX:CounterHalfLifeTime 设置半衰周期的时间,单位是秒。
  • 通过设置参数 -XX:+/-UseCounterDecay 来开启/关闭热度衰减机制。进行热度衰减的动作是 JVM 在进行 GC 时顺便进行的,默认情况下每次 GC 时都会对方法调用计数器的值进行「砍半」的操作,导致有些方法一直处于「温热」状态,永远达不到 Server 模式下 10000 次调用次数来触发 C2 编译器的阀值。

3.4 回边计数器

回边计数器用于统计一个方法中循环体代码执行的次数。在字节码中遇到控制流向后跳转的指令就称为「回边(Back Edge)」。
准确地说,应当是回边的次数而不是循环次数,因为并非所有的循环都是回边,如空循环实际上就可以视为自己跳转到自己的过程,因此并不算作控制流向后跳转,也不会被回边计数器统计。
虽然HotSpot VM 也提供了一个类似于方法调用计数器阈值 -XX: CompileThreshold 的参数:-XX:BackEdgeThreshold 来供用户设置,但当前的 HotSpot VM 实际上并未使用此参数,必须设置另外一个 OSR 比率参数 -XX:OnStackReplacePercentage 来间接调整回边计数器的阈值,其计算公式有如下两种:
  • Client 模式:方法调用计数器阈值(-XX:CompileThreshold)* OSR 比率(-XX:OnStackReplacePercentage,默认 933)/ 100。如果都取默认值,阀值则为 13995。
  • Server 模式:方法调用计数器阈值(-XX: CompileThreshold)* (OSR 比率(-XX:OnStackReplacePercentage,默认 140)- 解释器监控比率(-XX:InterpreterProfilePercentage,默认 33)) / 100。如果都取默认值,阀值则为 10700。

交互过程
notion image
当超过阈值的时候,将会提交一个 OSR 编译请求,并且把回边计数器的值稍微降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果,进行 OSR。
与方法调用计数器不同,回边计数器没有热度衰减的机制,因此这个计数器统计的就是该方法内循环体执行的绝对次数。当计数器溢出的时候,它还会把方法调用计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

图 11-3 和图 11-4 都仅仅是描述了 Client 模式下的即时编译方式,对于 Server 模式来说,执行情况会比上面描述还要复杂一些。

3.5 编译过程

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

C1 编译器编译过程
C1 编译器和 C2 编译器的编译过程是有所差别的。对于 C1 编译器来说,它是一个相对简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。
  • 第一阶段:一个平台独立的前端将字节码构造成一种高级中间代码表示(High-Level Intermediate Representation,HIR,即与目标机器指令集无关的中间表示)。HIR 使用静态单分配(Static Single Assignment,SSA)的形式来代表代码值,这可以使得一些在 HIR 的构造过程之中和之后进行的优化动作更容易实现。在此之前编译器已经会在字节码上完成一部分基础优化,如方法内联、 常量传播等优化将会在字节码被构造成 HIR 之前完成。
  • 第二阶段:一个平台相关的后端从 HIR 中产生低级中间代码表示(Low-Level Intermediate Representation,LIR,即与目标机器指令集相关的中间表示),而在此之前会在 HIR 上完成另外一些优化,如空值检查消除、范围检查消除等,以便让 HIR 达到更高效的代码表示形式。
  • 第三阶段:在平台相关的后端使用线性扫描算法(Linear Scan Register Allocation)在 LIR 上分配寄存器,并在 LIR 上做窥孔(Peephole)优化,然后产生机器代码。
notion image

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

4. Graal 编译器(后端编译器)

从 JDK 10 起,HotSpot VM 就同时拥有三款不同的即时编译器。Graal 编译器是 HotSpot VM 即时编译器以及提前编译器共同的最新成果。

5. AOT 编译器(后端编译器)

提前编译器(Ahead of Time Compliler,AOT 编译器,静态提前编译器)的基本思想是:在程序执行前生成 Java 方法的本地机器码,以便在程序运行时直接使用本地机器码以获得更高的性能。
但是 Java 语言本身的动态特性带来了额外的复杂性,影响了 Java 程序静态编译后代码的质量。例如 Java 语言中的动态类加载,因为 AOT 编译器是在程序运行前进行编译的,所以无法获知这一信息,所以会导致一些问题的产生。类似的问题还有很多,这里就不一一举例了。
虽然 AOT 编译器从编译质量上来看,比不上 JIT 编译器。但其存在的目的在于避免 JIT 编译器的运行时性能或内存的消耗,或者避免解释程序的早期性能开销。
在运行速度上来说,AOT 编译器编译出来的代码比 JIT 编译出来的慢,但是比解释执行的快。而编译时间上,AOT 编译也是一个始终的速度。所以说 AOT 编译器的存在是 JVM 牺牲质量换取性能的一种策略。
即时编译器相较于提前编译器有着三种关键优势:
  • 性能分析制导优化(Profile-Guided Optimization,PGO):根据收集到的运行时性能监控信息来进行集中优化(如去虚拟化、分支预测等)和分配更好的资源(如寄存器、缓存等)。
  • 激进预测性优化(Aggressive Speculative Optimization):有逆优化的后备方案在,C2 编译器可以大胆的采取一些较为激进的优化策略和分支预测,就算真走到罕见分支上,也可以通过逆优化退回低级编译器甚至解释器上运行,并不会产生无法挽救的后果。
  • 链接时优化(Link-Time Optimization,LTO):Java 语言天生就是动态链接的,一个个 Class 文件在运行期被加载到 JVM 内存当中,然后在即时编译器里产生优化后的本地代码。
JDK 9 引入了用于支持对 Class 文件和模块进行提前编译的工具 Jaotc,以减少程序的启动时间和到达全速性能的预热时间,但由于这项功能必须针对特定物理机器和目标 JVM 的运行参数来使用,加之限制太多,Java 开发人员对此了解、使用普遍比较少。
目前状态的 Jaotc 还有许多需要完善的地方,仍难以直接编译 SpringBoot、MyBatis 这些常见的第三方工具库,甚至在众多 Java 标准模块中,能比较顺利编译的也只有 java.base 模块而已。不过随着 Graal 编译器的逐渐成熟,作为提前编译器的 Jaotc 前途还是可期的。

6. 编译器优化技术

OpenJDK 的官方 Wiki 上,HotSpot VM 设计团队列出了一个相对比较全面的、即时编译器中采用的优化技术列表
notion image

6.1 分层编译

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

问题原因
由于 JIT 编译器编译本地代码需要占用程序运行时间,通常要编译出优化程度越高的代码,所花费的时间便会越长;而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,这对解释执行阶段的速度也有所影响。
为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot VM 在编译子系统中加入了分层编译的功能。
分层编译在 JDK 6 时出现,需要使用参数 -XX:+TieredCompilation 手动开启。到了 JDK 7 时成为默认开启的参数。

编译层次
  • 第 0 层:程序纯解释执行,并且解释器不开启性能监控功能(Profiling)。
  • 第 1 层:使用 C1 编译器将字节码编译为本地代码来运行,进行简单可靠的稳定优化,不开启性能监控功能。
  • 第 2 层:仍然使用 C1 编译器执行,仅开启方法调用及回边次数统计等有限的性能监控功能。
  • 第 3 层:仍然使用 C1 编译器执行,开启全部性能监控,除了第 2 层的统计信息外,还会收集如分支跳转、虚方法调用版本等全部的统计信息。
  • 第 4 层:使用 C2 编译器将字节码编译为本地代码,相比起 C1 编译器,C2 编译器会启用更多编译耗时更长的优化,还会根据性能监控信息进行一些不可靠的激进优化。
以上层次并不是固定不变的,根据不同的运行参数和版本,JVM 可以调整分层的数量。各层次编译之间的交互、转换关系如下:
notion image
实施分层编译后,解释器、C1 编译器和 C2 编译器就会同时工作,热点代码都可能会被多次编译,用 C1 编译器获取更高的编译速度,用 C2 编译器来获取更好的编译质量,在解释执行的时候也无须额外承担收集性能监控信息的任务,而在 C2 编译器采用高复杂度的优化算法时,C1 编译器可先采用简单优化来为它争取更多的编译时间。充分利用两种模式的优点,从而达到最优的运行效率。

6.2 方法内联(Client)

方法内联(Method Inlining)是编译器最重要的优化技术,它将被调用的方法代码编译到调用点处,减少了方法体就减少了栈帧的生成,减少参数传递以及跳转过程。方法内联膨胀最后可以便于在更大范围上进行后续优化手段,因此各种编译器一般都会把方法内联优化放在最优先的位置。
static class B {
    int value;

    final int get() {
        return value;
    }
}

public void foo() {
    y = b.get();
    // ...do stuff...
    z = b.get();
    sum = y + z;
}
优化前的原始代码
public void foo() {
    y = b.value;
    // ...do stuff...
    z = b.value;
    sum = y + z;
}
进行方法内联优化后的代码

6.3 冗余消除(Client)

把一些「啰嗦」的代码优化简洁。因为 y = b.value 已经保证了变量 yb.value 是一致的,这样就可以不再去访问对象 b 的局部变量。
public void foo() {
    y = b.value;
    // ...do stuff...
    z = y;
    sum = y + z;
}
冗余消除后的代码
如果把 b.value 看作一个表达式,那么也可以把这项优化看作是一种公共子表达式消除。

6.4 去虚拟化(Client)

在 Java 语言中,默认方法都是虚方法调用,编译器如何对虚方法做内联优化?
实际上 JVM 会通过类继承关系分析等一系列激进的猜测去做去虚拟化(Devitalization),譬如一个接口只有一个唯一实现类,则对该唯一实现类进行内联。

6.5 逃逸分析

逃逸分析(Escape Analysis)是 JVM 最前沿的优化技术之一,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
  • 开启逃逸分析:-XX:+DoEscapeAnalysis,从 JDK 7 开始成为 Server 模式默认开启的选项。
  • 查看分析结果:-XX:+PrintEscapeAnalysis
从 JDK 6 开始 HotSpot VM 才开始支持初步的逃逸分析,而且到现在这项优化技术尚未足够成熟,仍有很大的改进余地。

基本原理
分析对象动态作用域:
  • 当一个对象在方法里面被定义后,如果该对象的作用域仅局限于本方法中,则称为不逃逸(No-Escape,没有逃逸)
  • 该对象可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸(Arg-Escape,参数逃逸)
  • 甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的静态变量,这种称为线程逃逸(Global-Escape,全局逃逸)
如果重写了类的 finalize() 方法,则此类的实例对象都是全局逃逸状态(因此为了提高性能,除非万不得已,不要轻易重写 finalize() 方法)。
不逃逸方法逃逸线程逃逸,称为对象由低到高的不同逃逸程度

如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化策略,如:栈上分配(Stack Allocations)标量替换(Scalar Replacement)同步消除(Synchronization Elimination)

6.6 栈上分配(Server)

正常来说,在 Java 堆上为对象分配空间几乎是常识,但存在部分例外,栈上分配策略就是这种除了堆上分配的例外。

为什么需要栈上分配?
在 Java 程序中其实有很多对象的作用域都不会逃逸出方法外,也就是说该对象的生命周期会随着方法的调用开始而开始,方法的调用结束而结束。对于这种对象,如果分配在堆中,方法调用结束后就没有引用指向该对象,需要被 GC 回收,如果大量存在这种情况,对垃圾收集器来说也是一种负担(减轻 GC 负担)。
对象分配流程图
对象分配流程图

什么是栈上分配?
栈上分配策略针对那些作用域不会逃逸出方法的对象,在分配内存时不在将对象分配在堆内存中,而是将对象属性「打散」后分配在栈(线程私有的,属于栈内存)上。这样随着方法的调用结束,栈空间的回收就会随着将栈上分配的打散后的对象回收掉,不再给垃圾收集器增加额外的无用负担,从而提升应用程序整体的性能。

开启栈上分配策略前需要开启逃逸分析技术和标量替换策略。
栈上分配可以支持方法逃逸,但不能支持线程逃逸

6.7 标量替换(Server)

栈上分配策略中,将对象属性「打散」,就是标量替换
  • 标量:若一个数据已经无法分解成更小的数据来表示,那它就是标量。
  • 聚合量:若一个数据可以继续分解,那它就是聚合量(Aggregate),Java 中的对象就是典型的聚合量。
标量替换就是根据程序访问的情况,用标量值代替聚合对象的属性值,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。
  • 开启标量替换:-XX:+EliminateAllocations
  • 查看替换情况:-XX:+PrintEliminateAllocations
假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。
标量替换可以视作栈上分配的一种特例,实现更简单,不用考虑整个对象完整结构的分配,但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内,只支持不逃逸

6.8 同步消除(Server)

同步消除也被称为锁消除。线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程(可以有方法逃逸,但没有线程逃逸),无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施(通常指 synchronized)也就可以安全地消除掉。
  • 开启同步消除:-XX:+EliminateLocks
例如以下代码:
public void lockElimination() {
    A a = new A();
    synchronized (a) {
        // do somthing
    }
}
lockElimination() 方法中,对象 a 永远不会被其它方法或者线程访问到,因此 a 是非逃逸对象,这就导致 synchronized (a) 没有任何意义,因为在任何线程中,a 都是不同的锁对象。所以 JVM 会对上述代码进行优化,删除同步相关代码,以下:
public void lockElimination() {
    A a = new A();
    // do somthing
}
同步消除有一个经典的使用场景:StringBuffer

6.9 公共子表达式消除

公共子表达式消除(Common Subexpression Elimination)是一项非常经典的、普遍应用于各种编译器的优化技术。

含义
如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就称为公共子表达式(Common Subexpression)

对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替 E
  • 如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination)
  • 如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)

6.10 数组边界检查消除

数组边界检查消除(Array Bounds Checking Elimination)是即时编译器中的一项语言相关的经典优化技术。
Java 语言是一门动态安全的语言,对数组的读写访问不像 C/C++ 那样实质上就是 裸指针操作。如果有一个数组 foo[],在 Java 语言中访问数组元素 foo[i] 的时候系统将会自动进行上下界的范围检查,如检查不通过会抛出运行时异常 java.lang.ArrayIndexOutOfBoundsException
频繁的在运行期做数组边界检查,对效率来说是一大开销,可以在编译期根据数据流分析数组的 length 值,确定数组常量索引不会超过边界,那么编译器就可以将数组的上下界检查消除掉。在运行期就可以节省很多次条件判断操作。