0%

HotSpot虚拟机的intrinsic

在Java9之前,字符串是用char数组来存储的,主要为了支持非英文字符。然而,大多数Java程序中的字符串都是有Latin1字符组成的,也就是说每个字符仅需占据一个字节,而使用char数组的存储方式将极大的浪费内存空间。

Java9引入了Compact Strings的概念,当字符串仅包含Latin1字符时,使用一个字节代表一个字符的编码格式,使得内存使用效率大大提高。

在HotSpot虚拟机中,所有被HotSpotIntrinsicCandidate注解(Java9+大量加入)标注的方法都是HotSpot intrinsic。对这些方法的调用,会被HotSpot虚拟机替换成高效的指令序列(由HotSpot虚拟机额外维护的一套高效实现)。而原本的方法实现则会被忽略掉。

其他虚拟机未必维护了这些intrinsic的高效实现,它们可以直接使用原本较为低效的JDK代码。不同版本的HotSpot虚拟机所实现的intrinsic数量也大不相同。通常越新版本的Java,其intrinsic数量越多。

高效实现通常依赖于具体的CPU指令,而这些CPU指令不好在Java源程序中表达。换了一个体系架构,可能就没有对应的CPU指令,也就无法进行Intrinsic优化了。所以不直接在源代码中使用这些高效实现。

intrinsic与CPU指令

StringLatin1.indexOf

StringLatin1.indexOf方法将在一个字符串(byte数组)中查找另一个字符串(byte数组),并且返回命中时的索引值,或者-1(未命中)。

X86_64体系架构的SSE4.2指令集就包含一条指令PCMPESTRI,让它能够在16字节以下的字符串中,查找另一个16字节以下的字符串,并且返回命中时的索引值。

HotSpot虚拟机便围绕着这一指令,开发出X86_64体系架构上的高效实现,并替换原本对StringLatin1.indexOf方法的调用。

整数加法的溢出

一般在做整数加法时,需要考虑结果是否会溢出,并且在溢出的情况下做出相应的处理,以保证程序的正确性。

Java核心类库提供了一个Math.addExact方法。它将接收两个int值(或long值)作为参数,并返回这两个int值的和。当这两个int值之和溢出时,该方法将抛出ArithmeticException异常。

在Java层面判断int值之和是否溢出比较费事。需要分别比较两个int值与它们的和的符号是否不同。如果都不同,那么便认为这两个int值之和溢出。对应的实现便是两个异或操作,一个与操作,以及一个比较操作。

在X86_64体系架构中,大部分计算指令都会更新状态寄存器(FLAGS register),其中就有表示指令结果是否溢出的标识位(overflow flag)。因此,只需要在加法指令之后比较溢出标志位,便可以知道int值之和是否溢出了。

Integer.bitCount

该方法将统计所输入的int值的二进制形式中有多少个1。

Integer.bitCount方法的实现比较巧妙,但是它需要的计算步骤也比较多。在X86_64体系架构中,仅需要一条指令popcnt,便可以直接统计出int值中1的个数。

intrinsic与方法内联

HotSpot虚拟机中,intrinsic的实现分为两种。

一种是独立的桩程序。它既可以被解释执行器利用,直接替换对原方法的调用;也可以被即时编译器所利用,它把代表对原方法的调用的IR节点,替换为对这些桩程序的调用的IR节点。以这种形式实现的Intrinsic比较少,主要包括Match类中的一些方法。

另一种是特殊的编译器IR节点。显然,这种实现方式仅能够被即时编译器所利用。在编译过程中,即时编译器会对原方法的调用的IR节点,替换成特殊的IR节点,并参与接下来的优化过程。最终,即时编译器的后端将根据这些特殊的IR节点,生成指定的CPU指令。大部分的intrinsic都是通过这种方式实现的。

这个替换过程是在方法内联时进行的。放即时编译器碰到方法调用节点时,它将查询目标方法是不是intrinsic。如果是,则插入相应的特殊IR节点;如果不是,则进行原本的内联工作。(即判断是否需要内联目标方法的方法体,并在需要内联的情况下,将目标方法的IR图纳入当前的编译范围之中。)

也就是说,如果方法调用的目标方法是intrinsic,那么即时编译器会直接忽略原目标方法的字节码,甚至根本不在乎原目标方法是否有字节码。即便是native方法,只要它被标记为intrinsic,即时编译器便能够将之“内联”进来,并插入特殊的IR节点。

事实上,不少被标记为intrinsic的方法都是native方法。原本对这些native方法的调用需要经过JNI(Java Native Interface),其性能开销十分巨大。但是,经过即时编译器的intrinsic优化之后,这部分JNI开销便直接消失不见,并且最终的结果也十分搞笑。

例,可以通过Thread.currentThread方法来获取当前线程。这是一个native方法,同时也是一个HotSpot intrinsic。在X86_64体系架构中,R13寄存器存放着当前线程的指针。因此,对该方法的调用将被即时编译器替换为一个特殊IR节点,并最终生成读取R13寄存器指令。

已有intrinsic简介

HotSpot虚拟机定义了数百个intrinsic。有三成以上是Unsafe类的方法。一般不会直接使用Unsafe类的方法,而是通过java.util.concurrent包来间接使用。

Unsafe类中经常被用到的便是compareAndSwap方法(Java9+更名为compareAndSet或compareAndExchange方法)。在X96_64体系架构中,对这些方法的调用将被替换为lock cmpxchg指令,也就是原子性更新指令。

除了Unsafe类的方法之外,HotSpot虚拟机中的intrinsic还包括下面的几种:

  1. StringBuilder和StringBuffer类的方法。HotSpot虚拟机将优化利用这些方法构造字符串的方式,以尽量减少需要复制内存的情况。
  2. String类、StringLatin1类、StringUTF16类和Arrays类的方法。HotSpot虚拟机将使用SIMD指令(single instruction multiple data,即用一条指令处理多个数据)对这些方法进行优化。

    Arrays.equals(byte[], byte[])方法原本是逐个字节比较,在使用了SIMD指令之后,可以放入16字节的XMM寄存器中(甚至是64字节的ZMM寄存器中)批量比较。

  3. 基本类型的包装类、Object类、Math类、System类中各个功能性方法,反射API、MethodHandle类中与调用机制相关的方法,压缩、加密相关方法。

总结

HotSpot虚拟机将对标注了@HotSpotIntrinsicCandidate注解的方法的调用,替换为直接使用基于特定CPU指令的高效实现。这些方法称之为intrinsic。

具体来说,intrinsic的实现有两种。一是不大常见的桩程序,可以在解释执行或者即时编译生成的代码中使用。二是特殊的IR节点。即时编译器将在方法内联过程中,将对intrinsic的调用替换为这些特殊的IR节点,并最终生成指定的CPU指令。

HotSpot虚拟机定义了三百多个intrinsic(Java12)。其中比较特殊的右Unsafe类的方法,基本上使用java.util.concurrent包便会间接使用到Unsafe类的intrinsic。除此之外,String类和Arrays类中的intrinsic也比较特殊。即时编译器将为之生成非常高效的SIMD指令。