浅谈JVM运行期的几种优化手段

2024年 3月 14日 79.4k 0

一、摘要

在之前的文章中我们谈到过,相比 C/C++ 语言,Java 语言在运行效率方面要稍逊一些,因为 Java 应用程序是在虚拟机上运行,而 C/C++ 程序是直接编译成平台相应的机器码来运行程序。

从虚拟机对外发布开始,开发团队一直在努力试图缩小 Java 与 C/C++ 语言在运行效率上的差距。从实际的结果来看,确实成果显著。

本文就来聊聊 HotSpot 虚拟机为了提升 Java 程序的执行效率,都实现了哪些激动人心的优化技术。

二、JIT 编译器的引入

JIT 编译器,也称为即时编译器,它是 JVM 的重要组成部分。与我们经常用的生成 Java 字节码的javac编译器不同,JIT 编译器是实现 Java 程序执行效率提升的核心利器。

经常有面试官会提出这样的一个问题:Java 程序是解释执行还是编译执行?

刚开始学习 Java 的同学,大概率会认为 Java 是编译执行,其执行流程类似于如下图。

图片图片

源码程序.java文件,通过javac命令编译成.class字节码,最后通过java命令在虚拟机中利用解释器来执行代码。其中虚拟机的解释器作用,就是将字节码的操作指令和真正的平台体系之间的指令建立映射,比如把 Java 的load指令转换成native code的load指令,以此来完成程序的执行。

其实,准确的说,Java 既有解释执行,也有编译执行,其工作流程大致可以用如下图来描述。

图片图片

其中,JIT 编译器会将热点代码编译成本地平台相关的机器码,并进行各种层次的优化,从而实现程序执行效率的提升。

JIT 编译器的出现,可以说补强了虚拟机边运行边解释的低性能问题。

也许有的同学会提出这样的疑问,既然引入了 JIT 编译器可以显著提升程序执行效率,那 HotSpot 为什么不直接采用 JIT 编译器来执行呢?

简单的说,解释器和编译器各有优势。

  • 当程序需要迅速启动和执行时,解释器可以首先发挥作用,省去编译的时间,可以立即执行
  • 当程序运行后,随着时间的推移,JIT 编译器可以发挥作用,能把越来越多的代码编译成本地机器码,进一步提升程序的执行效率

这就是为什么 Java 程序既有解释执行,也有编译执行的原因。

当然,能触发即时编译请求的条件比较多,比如方法调用,OSR 编译请求等。在默认设置下,无论是哪种场景,虚拟机在代码编译器还未完成的时候,都仍然按照解释器来继续执行,而编译动作则是在后台的编译线程中运行。

用户可以通过-XX:-BackgroundCompilation参数来禁止后台编译,此时所有的编译请求会等待,直到编译完成后再开始执行本地机器码。

2.1、Client 模式与 Server 模式

在 HotSpot 虚拟机中内置了两款即时编译器,分别是Client Compiler和Server Compiler,也称为 C1 编译器与 C2 编译器。

在目前的 HotSpot 虚拟机中,默认采用的是解释器与其中一个即时编译器直接配合的工作方式,用户也可以使用-client或者-server参数来指定解释器与具体的某个编译器配合工作。

它们之间的区别,可以用如下内容简要概括:

  • Client Compiler(C1编译器):它是一个简单快速的编译器,主要关注点在于局部性的优化,而放弃了许多耗时间长的全局优化手段
  • Sever Compiler(C2编译器):它是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,它会执行所有经典的优化动作,如无用代码消除、循环展开、常量传播、基本块重排序等,还会实施一些与 Java 语言特性密切相关的优化技术,如范围检查消除、空值检查消除等,另外,还有可能根据解释器或 Client Compiler 提供的性能监控信息,进行一些不稳定的激进优化,如守护内联、分支频率预测等

Sever Compiler 即时编译器,无疑是比较缓慢的,但它的编译速度依然远超传统的静态优化编译器,而且它相对于 Client Compiler 编译器输出的代码质量更高,可以减少本地代码的执行时间,从而抵消额外的编译时间开销,因此很多非服务端的虚拟机选择-server模式来运行。

2.2、编译对象与触发条件

在上文我们有提到,JIT 编译器会将热点代码编译成本地平台相关的机器码。

哪些代码会被 JIT 编译器判断为“热点代码”呢?主要有两类:

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

这两种情况都会使即时编译器以整个方法作为编译对象。

比较难以理解的可能是第二种情况,对于被多次执行的循环体,可以理解成以一个方法可能只被调用一次或者少量的几次,但是方法体内部存在循环次数较多的循环体问题,这样循环体的代码也会被重复执行多次,因此这些代码也被认为是“热点代码”。

上面提到的都是概念知识,虚拟机如何判断一段代码是否是“热点代码”呢?主要有两种办法:

  • 基于采样的热点探测
  • 基于计数器的热点探测

HotSpot 虚拟机中使用的是第二种基于计数器的热点探测方法,它为每个方法准备了两类计数器:方法调用计数器和回边计数器。

在确认虚拟机运行参数的前提下,这两类计数器都有一个确认的的阀值,当计数器超过阀值时,就会触发即时编译器。

下面我们一起来看看这两类计数器的实现。

2.2.1、方法调用计数器

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

当一个方法被调用时,会检查方法是否存在被 JIT 编译过的版本,如果存在,则优先使用编译后的本地机器码来执行;如果不存在,将此方法的调用计数器值加 1,然后判断方法调用计数器和回边计数器之和是否超过方法调用计数器的阈值,如果超过,向即时编译器提交一个该方法的代码编译请求,在默认不设置的情况下,不会同步等待编译请求完成,而时直接以解释方式执行方法。

具体流程,可以用如下图来概括。

图片图片

如果不设置阀值的情况下,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。

当超过一定的时间限制,如果方法的调用次数不足以让它提交给即时编译器编译,那这个方法的调用计数器就会少一半,这个过程称为方法的调用计数器热度衰减,而这段时间就称为此方法统计的半衰周期。

进行热度衰减的动作是在虚拟机进行垃圾回收时顺便进行的,可以通过-XX:-UseCounterDecay参数来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样一来,只要系统运行时间足够长,基本上绝大部分方法都会被编译成本地机器码。

此外,用户也可以通过-XX:CounterHalfLifeTime参数来设置半衰周期的时间,单位是秒。

2.2.2、回边计数器

回边计数器,通常用于统计一个方法中循环体代码执行的次数。在字节码方法循环体中,遇到控制流向后跳转的指令成为"回边",这个过程会产生“栈上替换”的行为,也就是方法栈帧还在栈上,只是方法被替换了,HotSpot 把这个过程触发的即时编译,称之为 OSR 编译。

关于回边计数器的阈值设置,虚拟机没有明确给出对应的参数,但是可以通过-XX:OnStackReplacePercentage参数来间接的调整回边计数器的阈值,这个参数也称为 ORS 比率,回边计数器的阈值计算公式如下:

  • Client 模式:方法调用计数器阈值 × OSR 比率 / 1000,其中 OSR 比率默认值933,如果都取默认值,回边计数器的阈值应该是 13995
  • Server 模式:方法调用计数器阈值 × ( OSR 比率 - 解释器监控比率) / 100,其中 OSR 比率默认 140,解释器监控比率默认33,如果都取默认值,回边计数器阈值应该是 10700

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

具体流程,可以用如下图来概括。

图片图片

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

三、运行期优化技术

HotSpot 虚拟机设计团队为了实现程序更快的执行效率,列出了很多的优化手段,比如方法内联、冗余访问消除、复写传播、无用代码消除、公共子表达式消除、数组边界检查消除、逃逸分析等。

下面我们抽取几个最常见的优化技术,一起来看看相关的实现。

3.1、公共子表达式消除

公共子表达式消除是一个普遍应用于各种编译器的经典优化技术。

如果一个子表达式已经计算过了,且表达式中变量的值不曾发生变化,那么这个子表达式就可以当做公共子表达式。

对于这种表达式,没有必要再花时间去对它进行计算,直接用前面计算过的表达式结果替代就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除;如果这种优化的范围涵盖了多个基本块,便称为全局公共子表达式消除。

举个简单的例子,假设存在以下代码。

// 原始代码
int d = (c * b) * 12 + a + (a + b * c);

如果这段代码交给 Javac 编译器则不会进行任何优化,但是这段代码进入到虚拟机即时编译器之后,它将会进行如下优化。

// 将 c*b 和 b*c 用 E 表示,消除公共子表达式
int d = E * 12 + a + (a + E);

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

// 代数简化
int d = E * 13 + a + a;

表达式变换之后,再次计算可以节省一些时间。

3.2、数组边界检查消除

数组边界检查消除也是一个经典优化技术。

Java 语言作为一门动态安全的语言,会自动对数组的读写访问索引合法性做检查,当超出地址范围,会抛出java.lang.ArrayIndexOutOfBoundsException异常,这对软件开发很友好,但对 JVM 却是一个性能负担。

如果能在编译期根据数据流分析判定索引一直在数组边界内,就可以消除数组上下边界的检测,从而节省很多次条件判断操作。

类似的消除手段还有空指针检查(NullPointException)、除数为零检查(ArithmeticException)、自动装箱消除(Autobox Elimination)、安全点消除(Safepoint Elimination)、消除反射(Dereflection)等等,针对这些检查的消除方式,可能会采用隐式异常处理的思路。

举个简单的例子,假设存在以下代码。

// 原始伪代码
if(foo != null) {
    return foo.value;
} else {
    throw new NullPointException();
}

优化后的伪代码。

// 隐式异常消除后的伪代码
try {
    return foo.value;
} catch (segment_fault) {
    uncommon_trap();
}

JVM 会注册一个 Segment Fault 信号的异常处理器(uncommon_trap() 是一个针对进程层面的异常处理器,与 try-catch 的线程级异常处理器不同),当 foo 不为空,可以省去判空的开销;如果 foo 真为空,会转到异常处理器恢复中断并抛出NullPointException异常。

借助 JVM 在运行期收集的性能监控信息,判定 foo 极少为空时,采用这样的优化方式可以提升程序的执行效率。

3.3、方法内联

方法内联是 JVM 最重要的优化手段之一,它可以去除方法调用的成本(如减少建立栈帧等),为其它优化建立了良好的基础。

虚拟机如果探测到某个方法是热点方法并且长度不太长时,会进行内联,所谓的内联就是把方法内代码拷贝、粘贴到调用者的位置。

举个例子!

public final static void method1(){
    handleA();
    handleB();
}
public static void main(String[] args) {
    handle1();
    method1();
    handle2();
}

优化之后可能变成:

public static void main(String[] args) {
    handle1();
    handleA();
    handleB();
    handle2();
}

从效果上看,就是把method1()方法拷贝到main()方法中。未拷贝之前,优化空间非常小,但是合并到一个方法之后,就有了很大的优化空间了。

再比如下面这个例子!

private final  static int method1(final int i) {
    return i * i;
}
public static void main(String[] args) {
    System.out.println(method1(10));
}

优化之后可能变成:

public static void main(String[] args) {
    System.out.println(100);
}

虚拟机还能够进行常量折叠(constant folding)的优化,减少不必要的代码执行环节,从而提升代码执行效率。

再次想到一个问题,在 Java 编程规范里面,可能很多新人不能理解为什么推荐尽量将方法声明为final?

我们知道 Java 是多态的特性,子类既可以调用父类方法,也可以重写父类方法,编程方面灵活性非常高,这样其实会导致一个问题,编译期间无法确定应该使用哪一个方法,只有在运行时才能确定,这就可能导致虚拟机很难对方法进行内联操作。

但是如果将方法声明为final,这些方法是无法被重写,方法 A 调用方法 B 基本上是可以完全确定的,可以进行方法内联操作。

3.4、逃逸分析

逃逸分析,在之前对象内存分配的文章中有所简单的介绍过,我们再次回顾一下相关的知识。

逃逸分析是一项比较前沿的优化技术,它并不是直接优化代码的手段,而是为其它优化手段提供了分析技术。

逃逸分析的基本行为是分析对象动态作用域。

当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸。

如果能证明一个对象不会逃移到方法外或者线程之外,换句话说就是别的方法或线程无法通过任何途径访问到这个对象,则可以通过一些途径为这个变量进行一些不同程度的优化。

3.4.1、栈上分配

在之前的对象创建文章中,我们提及过,对象会优先在堆上分配,垃圾收集器会定期回收堆空间中不再使用的对象,但这块的内存回收很耗时。如果确定一个对象不会逃逸出方法之外,让这个对象在栈上分配,对象所占用的内存空间就可以随着栈帧出栈而销毁,这样垃圾收集器的压力将会小很多。

3.4.2、同步消除

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

比如你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后,虚拟机会去掉同步锁来运行。

3.4.3、标量替换

标量是指一个数据已经无法再分解成更小的数据来表示了,比如 Java 虚拟机中的原始数据类型(int,long 等数值类型以及 reference 类型)等都不能进一步分解,它们可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量。

Java 中最典型的聚合量是对象,如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替,拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了。

关于逃逸分析的论文早在 1999 年就已经发表,但直到 Sun JDK1.6 才实现了逃逸分析,直到现在这项优化尚未足够成熟,仍有很大改进余地。

不成熟的原因主要是不能保证逃逸分析的性能收益必定能高于它的消耗。虽然在实际测试结果中,实施逃逸分析后的程序往往能运行出不错的成绩,但是在实际的应用程序,尤其是大型程序中反而发现实施逃逸分析可能出现效果不稳定的情况,或因分析过程耗时但却无法有效判别出非逃逸对象而导致性能有所下降。

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

四、小结

本文主要围绕 JVM 在运行期对代码采取的一些优化手段,进行了一次知识内容整合和总结,希望能帮助到大家。

内容比较多,如果有描述不对的地方,欢迎留言指出,不胜感激。

五、参考

1.https://www.cnblogs.com/xrq730/p/4857820.html

2.https://juejin.cn/post/7236634386568527928

3.https://blog.csdn.net/ChaoMing_H/article/details/129179684

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论