JVM优化之循环展开(附有详细的汇编代码)

Posted Java译站

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM优化之循环展开(附有详细的汇编代码)相关的知识,希望对你有一定的参考价值。

在JVM内部实现系列的前几篇文章中,我们已经看到了Java的HotSpot虚拟机的just-in-time (JIT)编译技术,包括逃逸分析和锁消除。本文我们将要讨论另一种自动优化,叫作循环展开。JIT编译器使用这项技术来让循环(比如Java的for或者while循环)执行得更加高效。

由于我们要对JVM的内部机制进行深入分析,所以你会时不时看到用于讲解介绍的各种C的代码甚至是汇编语言,扶稳了!

我们先从下面这段C代码开始,它会去分配100万个long类型的空间,然后用100万个随机的long值来填充。

C被认为是一门高级语言,不过事实真的是这样的吗?在苹果Mac电脑上,用Clang编译器(开启—S选项来打印Intel格式的汇编语言)来编译前面的代码会得到如下的输出结果:

JVM优化之循环展开(附有详细的汇编代码)

看下这个代码,你会发现开始处有一次calloc函数的调用,并且仅存在一次randomLong()函数的调用(在循环中)。里面有两次跳转,它和下面变种的C代码所生成的机器代码本质上是一样的:

JVM优化之循环展开(附有详细的汇编代码)

Java里面同样的代码应该是这样的:

JVM优化之循环展开(附有详细的汇编代码)

编译成字节码的话,就成了这样:

JVM优化之循环展开(附有详细的汇编代码)

这些程序在代码结构来看都非常相似。它们都在循环中对数组data进行了一次操作。真实的处理器会有指令流水线(instruction pipeline),如果程序一直向下线性执行的话,就能够充分地引用流水线,因为下一条执行的指令马上就会就绪。

对于前向跳转(注:原文是back branch,从代码执行顺序来看,是指要跳转回前面所执行过的分支上,这里姑且称为前向跳转)————跳转回前面的执行点————正如前面for循环中那样,对性能的影响取决于CPU提供的分支预测算法的准确程度。Intel 64和IA-32架构优化参考手册[PDF]第3.4.1节对里面所提到的特定芯片的分支预测算法有详细的介绍。

不过由于有HotSpot的JIT编译器的存在,Java程序还有着更多的可能。JIT编译器进行了很多优化,不同的情况下所编译出来的代码会很不一样。

尤其是在使用int, short, 或者char变量作为计数器的计数循环(counted loops)当中,JIT进行了不少优化。它会把循环体展开,并用一个个排列好的原循环体的拷贝来替代。对循环的重构减少了所需的前向跳转。而且和C代码编译后所生成的汇编代码相比,性能上有很大的提升,因为指令流水线的缓存被丢弃的次数要少得多了。

我们用几个简单的方法来测试下不同的循环执行方式的区别。你可以看下循环展开后的汇编代码,原先的多个循环操作是如何在一次循环内完成的。

在开始汇编代码之旅前,我们还需要对前面的Java代码作一些简单的修改,以便让JIT编译器发挥作用,因为HotSpot虚拟机只会对整个方法体进行编译。不光如此,这个方法还需要在解释模式下执行过一定次数后,编译器才会考虑对它进行编译(通常是执行了1万次后才会进入完全优化的编译模式)。如果只是前面那样的一个单独的main方法,JIT编译器是永远不会被唤起的,也就没有任何优化可言了。

下面这个Java方法基本上是和原先的例子类似的,你可以用它来进行测试:

JVM优化之循环展开(附有详细的汇编代码)

这个方法会顺序地从数组中取值,并累加起来,然后将结果返回。这和前面的例子是类似的,但我们选择把结果返回,这是为了确保JIT编译器不会把循环展开和逃逸分析结合起来进行更进一步的优化,那样就不容易确定循环展开的实际效果了。

我们可以从汇编语言中识别出一个关键的访问模式,这样更容易帮助我们理解代码在干什么。这就是由寄存器和偏移量组成的三元组[base, index, offset],这里面

  • index寄存器存储的是计数器(这个要乘上数据类型的大小)

  • offset用来记录展开循环内的偏移量

实际的汇编语言看起来会类似这样:

add rbx, QWORD PTR [base register + index register * size + offset]

假设数组类型是long的,我们来看下什么条件下会触发循环展开。需要注意的是,循环展开的行为在不同的HotSpot虚拟机版本间是不太一样的,同时也取决于具体的CPU架构,不过整体的概念是一样的。

hsdis可以从OpenJDK源码中编译得到,具体的操作文档可以看下JITWatch wiki。还有个方法,Oracle的GraalVM项目将hsdis作为可下载的二进制文件一起分发出来了———你可以从GraalVM的安装目录里把它拷贝到Java的安装位置下面。

装好了hsdis之后,还需要配置下让虚拟机把方法的编译后的汇编代码输出出来。要这么做你还得额外加上一些VM启动参数,包括-XX:+PrintAssembly。

需要注意的是JIT线程编译完方法后就会直接将对应的本地代码反汇编成可读的汇编语言。这是一个很昂贵的操作,会影响到应用程序的性能,所以在生产环境中不要使用。

用如下的VM选项来执行程序,你便能看到指定方法的反汇编后的汇编语言了:

java -XX:+UnlockDiagnosticVMOptions 
     -XX:-UseCompressedOops         
     -XX:PrintAssemblyOptions=intel 
     -XX:CompileCommand=print,javamag.lu.LoopUnrolling::intStride1 
     javamag.lu.LoopUnrolling

这个命令会生成一个步进固定为1的int计数循环所对应的汇编代码。

不断累加的long类型的求和结果存储在64位的寄存器rbx中。每个add指令都会从data数组中取出下一个值,并将它加到rbx上。每次加载后,偏移量的常量会增加8(这正是Java中long基础类型的大小)。

当展开的部分前向跳转回主循环的起始处时,offset寄存器会进行自增,加上这次循环迭代所处理的数据量:

JVM优化之循环展开(附有详细的汇编代码)

(为了让大家更容易理解,我们在汇编代码中加入了一些注释,这样每个独立的部分更加清晰了。为了简洁起见,我们只保留了一个退出方法的块,不过在汇编语言中通常会有多个退出块,来处理方法结束可能的各种情况。设置部分的代码也包含进来了,本文稍后会将它和其它操作来进行比较。)

当在循环里访问数组的时候,HotSpot虚拟机会将循环拆分成三个部分,来消除数组的边界检查:

  • 前置循环:执行初始迭代,并且进行边界检查。

  • 主循环:通过循环步长(就是每次迭代时计数器增加的大小)来计算在不需要边界检查情况下可以执行的最大迭代次数。

  • 后置循环:执行剩余的迭代,并且进行边界检查。

计算一下add操作和jump操作的比例,你就能知道这个方法的实际优化效果是怎样的了。在 我们前面测试的未优化的C语言的版本上,这个比例是1:1,而Java的HotSpot虚拟机的JIT编译器把这个数字提高到了8:1,在这一部分上减少了87%的跳转次数。而一次跳转的影响一般来说是会消耗2到300个CPU周期,用来等待从主存中重新加载代码,因此这个提升效果就非常明显了。(如果想了解HotSpot虚拟机是如何消除数组循环过程中的边界检查的,可以看下这个线上文档。)

安全点(Safepoints)

用long类型进行循环计数的Java方法的代码,看起来和int类型的是非常类似的:

JVM优化之循环展开(附有详细的汇编代码)

不过使用了long类型来计数之后,它所生成的汇编代码中的初始化的部分,就和前面所列出的汇编代码中的完全不一样了————哪怕步长是常量1,也不会出现循环展开:

JVM优化之循环展开(附有详细的汇编代码)

现在在循环体内就只有一个加的指令了——加法和跳转指令的比例又变回了1:1,循环展开的好处没有了。不光如此,循环中还多了一次安全点检查。

安全点是代码中的一些特殊位置,当执行线程执行到这个地方的时候,它便知道自己已经完成了对内部数据结构的所有修改(比如堆中的对象)。这个时候适合来检查并确认JVM是否需要暂停执行Java代码的所有线程。应用线程通过检查安全点并挂起执行,给JVM提供了一个机会来执行一些可能会修改内存布局或内部数据结构的操作,比如stop-the-world (STW)垃圾回收。

在代码解释执行的时候,有一个很适合进行安全点检查的时机:一个字节码刚执行完,下一个字节码还未执行的时候。

在“字节码间”进行安全点检查对解释执行来说非常有用,但对JIT编译过的方法来说,这个检查就必须要整合插入到编译器生成的代码里面才行。

如果缺少这些检查,就会出现其它线程都已经在它们的安全点上暂停但有的线程还在继续执行的情况。这会导致虚拟机进入到混乱的状态,几乎所有应用线程都停止运行了,却还有一些在不停地执行中。

HotSpot使用了几种启发法(heuristics)来往编译后的代码中插入安全点检查。最常用的两个就是在前向跳转之前(就像这个例子中这样),还有就是在方法即将退出但控制流还没回到调用方的时候。

不过,long计数的例子中安全点检查的出现也暴露了int计数循环中另一个特点:它们没有安全点检查。也就是说在整个int计数循环(步长为常量)的执行过程中不会出现任何安全点检查,在极端情下这可能会占用相当长的时间。

然而,如果是int计数但步长不固定的循环体,比如说每次方法调用时步长都可能发生变化:

这段代码便会强制要求JIT编译器在前向跳转处生成安全点检查。

长时间运行的int计数循环会导致其它线程一直在安全点等待,直到它执行结束,如果你对这个所导致的延迟暂停时间比较敏感的话,可以使用启动参数-XX:+UseCountedLoopSafepoints来解决这个问题。这个选项会在未展开的循环体的前向跳转处加入一个安全点检查。这样在刚才前面的例子中所生成的长长的汇编代码中,每进行8次加法运算,便会出现一次安全点检查。

除非你已经在性能测试中明确证实加上这个参数后会对性能有明显的提高,不然不要激活这个选项,其它会对性能产生影响的命令行参数也是同样的处理原则。很少有程序能从启用该选项中受益,因此不要盲目地开启这一选项。Java 10引入了一项更高级的叫循环切分(loop strip mining)的技术来更进一步地平衡安全点检查对吞吐量和延迟所产生的影响。

我们用JMH来测试下同样的数组使用int计数和使用long计数的性能差异,来做一下总结。正如前面所解释的,使用long计数的循环是不会被展开的,同时每次循环也会包含一次安全点检查。

最后的输出结果如下:

Benchmark                          Mode  Cnt     Score   Error  Units
LoopUnrollingCounter.intStride1   thrpt  200  2423.818 ± 2.547  ops/s
LoopUnrollingCounter.longStride1  thrpt  200  1469.833 ± 0.721  ops/s

也就是说使用int计数的循环每秒执行的操作能高出64%。

结论

HotSpot虚拟机可以执行更复杂的循环展开优化——比如说,当循环包含多个退出点时。这种情况下,循环会被展开,且每个展开的迭代都会进行一次终止条件的检查。

作为一个虚拟机,HotSpot利用循环展开的能力来减少或消除了前向跳转所带来的性能损耗。不过对大多数的Java开发人员来说,他们并不需要知道这个能力——这只不过又是一项运行时所提供的对他们透明的性能优化罢了。

英文原文链接

以上是关于JVM优化之循环展开(附有详细的汇编代码)的主要内容,如果未能解决你的问题,请参考以下文章

用c51语言把连续字节做比较,C51优化设计之循环语句

史上最详细JVM与性能优化知识点综合整理

前端性能优化:循环优化二,循环展开

循环展开和优化

使用hsdis查看jit生成的汇编代码

JVM性能优化,第2部分:编译器JVM