修改机器码的重要性—修改机器码软件好使吗

首页 > 汽车 > 汽车资讯 > 正文

修改机器码的重要性—修改机器码软件好使吗

JIT(Just In Time)技术是Java虚拟机中的一项重要技术,其在运行时将字节码编译为机器码,以大幅提升程序的执行速度。

正是因为JVM中使用了JIT技术,才为Java代码在运行时的性能可能超过C++提供了基础。

一般情况下,我们所产出的代码,很大层面上需要保障代码的可读性,而这里的可读性是针对于编码人员的,而非针对于机器。具备高可读性的代码,通常并不意味着其可以高效地被机器直接执行,而通常情况下刚好相反

此处,我们针对JIT中一些常用的优化手段,来理解为何Java代码的执行效率可以如此之高。

经过JIT优化的代码的执行效率提升,很大层面上是因为JIT对指令进行了重新的排列。指令重排在保证代码逻辑不变的情况下,对代码的执行顺序进行了调整,从而提升了代码的执行效率。

为了理解指令重排,我们需要首先了解JVM所支持的指令是什么样子的。

对于已经编译完成的一个方法,存在三个重要的组成部分:

  • 本地变量表:用以保存方法的入参及声明的局部变量。
  • 操作数栈:用以存储运行的中间结果。
  • 指令集:即编译完成后的代码,也可能称为字节码。

Java指令在JVM规范中有详细的描述,对应版本的JVM都会拥有一份JVM规范的文档,这些文档被收录在Oracle的官网中:

在对应文档的“The Java Machine Instruction Set”章节中,有对各种执行的详细介绍,此处我们不过多赘述,而是简单讨论一下指令的行为。

JVM所支持的指令,从行为中可分为四类:

  • 从本地变量表或常量池中取出一个值,并将其压入到操作数栈中。如aload_0,将本地变量表中索引为0的值压入到操作数栈中。
  • 从操作数栈中取出操作数进行计算,并将操作结果重新压入到操作数栈中。如iadd,从操作数栈中取出两个32位整数,并将其相加得到的和重新压入到操作数栈中。
  • 从操作数栈中取出值并写入到本地变量表中。如astore_0,从操作数栈中取出一个值,并将其写入到本地变量表中索引为0的位置。
  • 用于控制程序跳转。如if_icmpeq、lookupswitch、tableswitch等。

此处我们着重了解前三类指令,首先看示例代码:

我们将这段代码编译成为class文件,并通过javap命令查看编译后的结果。

输出的结果如下:

这里我们只关注最后public int compute(int, int)方法中的指令:

我们可以看到,在源代码中的两行代码,编译完成后得到了8条指令,这8条指令是完全按照源代码的意图进行直译的。

而在实际执行中,JVM会对指令进行简化,简化后的指令:

我们可以看到,指令从8条被精简到了6条,其中针对操作数栈顶的值的读取和写入(即istore_3和iload_3)被合并,从而减少了不必要的操作。

那么此时,我们就可以理解指令重排的意义。

在编码过程中,从提高代码可读性的角度考虑,我们会将含义、目的将近的变量放到一起声明和初始化,并在后续操作中,按更容易理解的业务语义来对其进行批量操作,但是这个时候,可能会导致很多无效的读取和写入操作。为了合并掉这些操作,JVM在逻辑不变的前提下,对指令进行重排,从而使得更多的指令被合并,减少同一代码执行时所需的指令数量。

而指令重排所带来的好处是显而易见的,如果指令的数量被降低10%,那么性能将是实打实地提升10%。

逃逸分析是在Java6中引入的新特性,其与标量替换共同完成运行时的优化。

逃逸分析用来判断在一个方法中所实例化的对象,是否在方法外被使用。如果对象在方法外被使用,则表示这个对象发生了“**逃逸**”,否则视作未发生“**逃逸**”。而对于未发生逃逸的对象,则可通过栈上分配技术,直接在方法栈中为对象分配内存。进而通过**标量替换**技术,将变量中的字段打散到方法的本地变量表中,后续对于对象中字段的操作,就直接操作这些本地变量,此时这个对象就不见了,取而代之的是表示其所包含字段的本地变量。

标量是不可再被细分的值,如32位整数、布尔值、字符串等。标量不仅局限于基本数据类型。

此优化所带来的好处有:

  • 因为不再需要实例化对象,因此减少了堆内存的使用,降低了垃圾回收的压力,更多的内存可随着方法栈的销毁而直接被释放。
  • 锁消除,因为对象不会发生逃逸,因此对象的作用域仅在方法执行过程中,因此其是不会发生线程同步的。此时无效的对于同步锁的操作将被消除掉,提升执行效率。
  • 替换为标量的值,在方法逻辑执行过程中,可以参与到指令重排中,从而进一步优化性能。

因此,对于以下代码:

在进行标量替换后,其实际的逻辑将近似地被优化为:

以上仅是一个示意,当然,此间还涉及到一些其他的优化手段,比如内联等。

内联的概念比较容易理解,即是将一个方法的逻辑直接打平打调用方的代码中。例如:

在内联后即成为:

内联的好处有很多,例如降低代码的实际调用层次等。但是相比于其直接产生的收益,其间接收益则更大。内联是将各种优化手段有效衔接起来的重要手段。例如,逃逸分析的重要依据是对象是否在方法外被使用,如果我们将一个对象传入到一个方法中,例如对其字段进行校验等,那么这个对象就发生了逃逸,不能应用栈上分配、标量替换等优化手段,更进一步也就无法更好地进行指令重排。

而内联则有效地解决了这个问题,在实际代码运行过程中,内联无处不在,通常几层、十几层的调用栈,都会被内联到一个方法中。

那么,什么样的方法可以被内联呢?

简单来说,稳定的方法可以被内联。即当一个方法调用另一个方法时,如果另一个方法的逻辑不会发生变化,那么这个方法就可以被内联到调用方的方法中。例如final方法、private方法等。

但是因为内联的优化手段实在过于重要,因此JVM后期对内联再次进行了增强,也就是所说的激进优化。这里的激进优化主要在于可能发生变化的方法,如通过接口调用一个实现时。

一般情况下,当我们通过接口调用一个方法时,我们并不能确定最终调用的是接口的哪个实现。当对这个接口方法的调用成为热点,且目标方法不曾发生改变时,将尝试对这个被调用的方法进行内联,如果后续调用的目标方法发生变化,则会进行优化回退。优化回退的成本相对是很高的,因为一般情况下,代码将被回退到所有优化发生之前的状态。

这里所说的激进优化,与JVM参数中的AggressiveOpts是不同的,AggressiveOpts参数的开启表示将启用当前JVM版本中还不成熟的优化手段。

那么,激进优化的意义何在和?

一般情况下,在编码过程中,需要考虑到诸如并行开发、接口分离原则等诸多方面,会使我们的代码在设计层面被拆分成为不同的组件,而大多情况下,这些用作分离的接口通常只有一个实现(默认实现),这就为激进优化带来的底层的逻辑支撑。

JVM中还存在诸多的优化手段,如分支消除、反射优化等。但是总体而言,**JIT的优化主要依据在于热点代码判断,最重要的手段在于方法内联**。因此当我们进行代码设计时,首要应考虑代码的内联属性。如果组件的代码是更容易被内联的,通常情况下,其所带来的效率将会更高。基于此,可以总结一些有效的代码设计方法:

  • 多抽取工具方法。工具方法的抽取除了代码更好的可靠性外,也更便于内联的发生,并不会带来额外的调用栈开销。
  • 明确扩展点。在进行类设计时,对于哪些方法是需要多态的应该有明确的规划,对于不需要多态的方法应明确使用final进行封闭。
  • 单纯的没有多态的接口分离是不会带来额外的性能损耗的,因为这些方法最终会被内联掉。

备案号:赣ICP备2022005379号
华网(http://www.hbsztv.com) 版权所有未经同意不得复制或镜像

QQ:51985809邮箱:51985809@qq.com