22 深度解析HotSpot虚拟机Intrinsic机制:为什么JDK方法比你写的快?

图片[1]-22 深度解析HotSpot虚拟机Intrinsic机制:为什么JDK方法比你写的快?-速优课

本文导读

你有没有过这样的疑惑:同样的算法逻辑,为什么JDK自带的方法执行效率总是比自己实现的要快得多?比如String.indexOf方法,看着和自己写的差不多,但性能却有天壤之别。

答案就藏在HotSpot虚拟机的Intrinsic机制中。本文将带你揭开Intrinsic的神秘面纱,你将学到:

  • 什么是Intrinsic,它为什么能带来性能提升
  • Intrinsic与CPU指令集的关系
  • Intrinsic的两种实现方式:桩程序 vs 特殊IR节点
  • Intrinsic与方法内联的协作机制
  • HotSpot中常见的Intrinsic方法分类
  • 如何利用Intrinsic提升代码性能

一、从String.indexOf说起:为什么JDK方法更快?

有同学曾问过这样一个问题:String.indexOf方法和自己实现的indexOf方法在字节码层面上差不多,为什么执行效率却有天壤之别呢?

我们先来看一下String.indexOf方法的源代码(截取自Java 10.0.2):

public int indexOf(String str) {
    if (coder() == str.coder()) {
        return isLatin1() ? StringLatin1.indexOf(value, str.value)
                          : StringUTF16.indexOf(value, str.value);
    }
    if (coder() == LATIN1) {  // str.coder == UTF16
        return -1;
    }
    return StringUTF16.indexOfLatin1(value, str.value);
}

背景知识:Java 9引入了Compact Strings的概念

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

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

假设我们调用String.indexOf方法的调用者以及参数均为只包含Latin1字符的字符串,那么该方法的关键在于对StringLatin1.indexOf方法的调用。

下面是StringLatin1.indexOf方法的源代码。你会发现,它并没有使用特别高明的算法,唯一值得注意的便是方法声明前的@HotSpotIntrinsicCandidate注解。

@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, byte[] str) {
    if (str.length == 0) {
        return 0;
    }
    if (value.length == 0) {
        return -1;
    }
    return indexOf(value, value.length, str, str.length, 0);
}
​
@HotSpotIntrinsicCandidate
public static int indexOf(byte[] value, int valueCount, byte[] str, int strCount, int fromIndex) {
    byte first = str[0];
    int max = (valueCount - strCount);
    for (int i = fromIndex; i <= max; i++) {
        // Look for first character.
        if (value[i] != first) {
            while (++i <= max && value[i] != first);
        }
        // Found first character, now look at the rest of value
        if (i <= max) {
            int j = i + 1;
            int end = j + strCount - 1;
            for (int k = 1; j < end && value[j] == str[k]; j++, k++);
            if (j == end) {
                // Found whole string.
                return i;
            }
        }
    }
    return -1;
}

1.1 什么是Intrinsic

在HotSpot虚拟机中,所有被@HotSpotIntrinsicCandidate注解标注的方法都是HotSpot intrinsic。对这些方法的调用,会被HotSpot虚拟机替换成高效的指令序列,而原本的方法实现则会被忽略掉。

换句话说,HotSpot虚拟机将为标注了@HotSpotIntrinsicCandidate注解的方法额外维护一套高效实现。如果Java核心类库的开发者更改了原本的实现,那么虚拟机中的高效实现也需要进行相应的修改,以保证程序语义一致。

需要注意的是:

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

1.2 为什么不直接在源代码中实现

你或许会产生疑问:为什么不直接在源代码中使用这些高效实现呢?

这主要有两个原因:

  1. 架构依赖性:高效实现通常依赖于具体的CPU指令,而这些CPU指令不好在Java源程序中表达
  2. 可移植性:换了一个体系架构,说不定就没有对应的CPU指令,也就无法进行intrinsic优化了

下面我们来看几个具体的例子,深入理解intrinsic是如何工作的。


二、Intrinsic与CPU指令:硬件级别的性能优化

2.1 字符串查找:SSE4.2指令集的威力

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

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

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

这就是为什么JDK的indexOf方法比你自己写的快——它直接使用了CPU提供的专用硬件指令。

2.2 整数溢出检测:利用状态寄存器

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

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

@HotSpotIntrinsicCandidate
public static int addExact(int x, int y) {
    int r = x + y;
    // HD 2-12 Overflow iff both arguments have the opposite sign of the result
    if (((x ^ r) & (y ^ r)) < 0) {
        throw new ArithmeticException("integer overflow");
    }
    return r;
}

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

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

public static int addExact(int x, int y) {
    int r = x + y;
    jo LABEL_OVERFLOW; // jump if overflow flag set
    return r;
    LABEL_OVERFLOW:
      throw new ArithmeticException("integer overflow");
      // or deoptimize
}

可以看到,使用intrinsic后,原本需要多次运算的溢出检测,现在只需要一条跳转指令即可完成。

2.3 位计数:一条指令搞定

最后一个例子是Integer.bitCount方法,它将统计所输入的int值的二进制形式中有多少个1。

@HotSpotIntrinsicCandidate
public static int bitCount(int i) {
    // HD, Figure 5-2
    i = i - ((i >>> 1) & 0x55555555);
    i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
    i = (i + (i >>> 4)) & 0x0f0f0f0f;
    i = i + (i >>> 8);
    i = i + (i >>> 16);
    return i & 0x3f;
}

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


三、Intrinsic的实现方式与方法内联

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

3.1 两种实现方式

方式一:独立的桩程序(Stub)

这种实现方式既可以被解释执行器利用,直接替换对原方法的调用;也可以被即时编译器所利用,把代表对原方法的调用的IR节点,替换为对这些桩程序的调用的IR节点。

以这种形式实现的intrinsic比较少,主要包括Math类中的一些方法。

方式二:特殊的编译器IR节点

显然,这种实现方式仅能够被即时编译器所利用。

在编译过程中,即时编译器会将对原方法的调用的IR节点,替换成特殊的IR节点,并参与接下来的优化过程。最终,即时编译器的后端将根据这些特殊的IR节点,生成指定的CPU指令。

大部分的intrinsic都是通过这种方式实现的。

3.2 Intrinsic与方法内联的关系

这个替换过程是在方法内联时进行的。当即时编译器碰到方法调用节点时,它将查询目标方法是不是intrinsic。

  • 如果是intrinsic,则插入相应的特殊IR节点
  • 如果不是intrinsic,则进行原本的内联工作(即判断是否需要内联目标方法的方法体,并在需要内联的情况下,将目标方法的IR图纳入当前的编译范围之中)

也就是说,如果方法调用的目标方法是intrinsic,那么即时编译器会直接忽略原目标方法的字节码,甚至根本不在乎原目标方法是否有字节码。

即便是native方法,只要它被标记为intrinsic,即时编译器便能够将之”内联”进来,并插入特殊的IR节点。

3.3 Native方法的性能飞跃

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

举个例子,我们可以通过Thread.currentThread方法来获取当前线程。这是一个native方法,同时也是一个HotSpot intrinsic。

在X86_64体系架构中,R13寄存器存放着当前线程的指针。因此,对该方法的调用将被即时编译器替换为一个特殊IR节点,并最终生成读取R13寄存器的指令。

可以想象,从一次JNI调用变成一条寄存器读取指令,性能提升是巨大的。


四、HotSpot中常见的Intrinsic分类

最新版本的HotSpot虚拟机定义了三百多个intrinsic。下面我们来看看这些intrinsic主要分布在哪些类中。

4.1 Unsafe类:并发编程的基石

在这三百多个intrinsic中,有三成以上是Unsafe类的方法。不过,我们一般不会直接使用Unsafe类的方法,而是通过java.util.concurrent包来间接使用。

举个例子,Unsafe类中经常会被用到的便是compareAndSwap方法(Java 9+更名为compareAndSetcompareAndExchange方法)。在X86_64体系架构中,对这些方法的调用将被替换为lock cmpxchg指令,也就是原子性更新指令。

可以说,整个Java并发包的高效运行,都离不开Unsafe类的intrinsic支持。

4.2 字符串与数组类:SIMD指令的用武之地

StringBuilderStringBuffer类的方法也是intrinsic的常客。HotSpot虚拟机将优化利用这些方法构造字符串的方式,以尽量减少需要复制内存的情况。

String类、StringLatin1类、StringUTF16类和Arrays类的方法同样大量使用了intrinsic。HotSpot虚拟机将使用SIMD指令(Single Instruction Multiple Data,即用一条指令处理多个数据)对这些方法进行优化。

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

4.3 其他常见的Intrinsic

除了上面提到的,HotSpot虚拟机中的intrinsic还包括:

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

这部分intrinsic则比较简单,就不详细展开了。如果你想知道HotSpot虚拟机定义的所有intrinsic,可以直接查阅OpenJDK源代码。


总结与思考

本文深入解析了HotSpot虚拟机的Intrinsic机制,让我们来总结一下核心要点:

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

2. Intrinsic的价值

  • 利用特定CPU指令实现硬件级别的性能优化
  • 消除native方法的JNI调用开销
  • 为常见操作提供极致的性能保障

3. Intrinsic的两种实现方式

  • 桩程序(Stub):不太常见,可以在解释执行或者即时编译生成的代码中使用
  • 特殊IR节点:即时编译器在方法内联过程中,将对intrinsic的调用替换为特殊的IR节点,并最终生成指定的CPU指令

4. 常见的Intrinsic分类

  • Unsafe类:并发编程的基石,约占三成以上
  • StringArrays等类:使用SIMD指令进行批量操作优化
  • MathSystem等工具类:提供各种功能性方法的高效实现

实践建议

  • 优先使用JDK提供的标准方法,它们往往已经被Intrinsic优化过
  • 不要重复造轮子,尤其是在性能敏感的场景下
  • 可以通过对比JDK方法和自己实现的方法的性能差异,来感受Intrinsic的威力

最后,你可以体验一下Integer.bitCount intrinsic带来的性能提升。分别运行自己实现的bitCount和Integer.bitCount,看看性能差距有多大:

// time java Foo
public class Foo {
    public static int bitCount(int i) {
        // HD, Figure 5-2
        i = i - ((i >>> 1) & 0x55555555);
        i = (i & 0x33333333) + ((i >>> 2) & 0x33333333);
        i = (i + (i >>> 4)) & 0x0f0f0f0f;
        i = i + (i >>> 8);
        i = i + (i >>> 16);
        return i & 0x3f;
    }
    public static void main(String[] args) {
        int sum = 0;
        for (int i = Integer.MIN_VALUE; i < Integer.MAX_VALUE; i++) {
            sum += bitCount(i); // In a second run, replace with Integer.bitCount
        }
        System.out.println(sum);
    }
}

© 版权声明
THE END
喜欢就支持一下吧
点赞9
相关推荐
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容

温馨提示:
1、本内容转载于网络,版权归原作者所有!
2、本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
3、本内容若侵犯到你的版权利益,请联系我们,会尽快给予删除处理!