JDK 中的非常规代码 - 出于未知原因使用的特定结构

Unconventional code in JDK - specific constructions used for unknown reason

我正在查看 JDK(JDK 12,但它也适用于旧的)代码并发现了一些奇怪的结构,我不明白为什么要使用它们。我们以Map.computeIfPresent为例,因为它很简单:

default V computeIfPresent(K key, BiFunction<? super K, ? super V, ? extends V> remappingFunction) {
    Object oldValue;
    if ((oldValue = this.get(key)) != null) {
        V newValue = remappingFunction.apply(key, oldValue);
        if (newValue != null) {
            this.put(key, newValue);
            return newValue;
        } else {
            this.remove(key);
            return null;
        }
    } else {
        return null;
    }
}

这个结构if ((oldValue = this.get(key)) != null)让我很吃惊。我知道这是可能的,因为它没什么特别的,但在正常的生产代码中我会认为它是一种代码味道。为什么不以正常方式编写它 (Object oldValue = this.get(key))?一定是离合器优化了,我也是这么想的

写了一个较小的版本来检查字节码:

int computeIfPresent(int key) {
  Integer oldValue;
  if ((oldValue = get(key)) != null) {
    return oldValue;
  } else {
    return 2;
  }
}

字节码输出:

int computeIfPresent(int);
  Code:
     0: aload_0
     1: iload_1
     2: invokevirtual #2                  // Method get:(I)Ljava/lang/Integer;
     5: dup
     6: astore_2
     7: ifnull        15
    10: aload_2
    11: invokevirtual #3                  // Method java/lang/Integer.intValue:()I
    14: ireturn
    15: iconst_2
    16: ireturn

具有经典变量初始化的 'normal' 版本的字节码:

int computeIfPresent(int);
  Code:
     0: aload_0
     1: iload_1
     2: invokevirtual #2                  // Method get:(I)Ljava/lang/Integer;
     5: astore_2
     6: aload_2
     7: ifnull        15
    10: aload_2
    11: invokevirtual #3                  // Method java/lang/Integer.intValue:()I
    14: ireturn
    15: iconst_2
    16: ireturn

唯一的区别是 dup + astore_2astore_2 + aload_2。我什至怀疑第一个 'clutch-optimized' 版本更糟,因为使用了 dup 并且堆栈无缘无故地变大了。也许我的例子太简单了,优化在更复杂的上下文中扩展了很多。

这是一个简单的例子,绝对不会在 JDK 代码中出现 - 打开 HashMap.java,有很多这样的片段,有时在同一行上有多个片段:

if ((first = tab[i = (n - 1) & hash]) != null)

虽然它真的很简单,但由于这些结构,我不得不停下来想一想这段代码实际上做了什么。

使用这些结构背后的真正原因是什么?我敢肯定这不仅仅是糟糕的代码。在我看来,代码质量受到很大影响,因此收益一定是可观的。或者只是规则 leave small optimizations to JIT 不适用于 JDK,因为它必须尽可能多地压缩性能?

或者这只是按照规则 initialize variables as late as possible 走极端?:)

您的问题的答案在于 JVM 规范,特别是您指出的不同之处:dup 指令 (JVMS §6.5.dup)。来自这些文档:

Duplicate the top value on the operand stack and push the duplicated value onto the operand stack.

查看操作数堆栈文档(添加JVMS §2.6.2重点):

A small number of Java Virtual Machine instructions (the dup instructions (§dup) and swap (§swap)) operate on run-time data areas as raw values without regard to their specific types; these instructions are defined in such a way that they cannot be used to modify or break up individual values. These restrictions on operand stack manipulation are enforced through class file verification (§4.10).

再深入一层,查看 class 验证部分(JVMS §4.10强调):

Link-time verification enhances the performance of the run-time interpreter. Expensive checks that would otherwise have to be performed to verify constraints at run time for each interpreted instruction can be eliminated. The Java Virtual Machine can assume that these checks have already been performed.

这表明这些限制在 link 时生效,也就是 JVM 加载您的 class 文件时。所以回答你的问题:

What is the real reason behind using those constructions?

让我们剖析一下指令在每种情况下的作用:

第一种情况(使用dup指令):

  1. invokevirtual将结果存入操作数栈顶
  2. dup 重复所以现在堆栈顶部有两个结果副本
  3. astore_2 将其存储到局部变量 #2 中,该变量从操作数堆栈中弹出一个引用
  4. ifnull 检查操作数栈的顶部是否为空,如果是,则转到指令 15,否则继续(我们假设它不为空)
  5. aload_2 将局部变量#2 压入操作数栈的顶部
  6. invokevirtual 在操作数栈的顶部调用一个方法,弹出它,然后压入结果
  7. ireturn 弹出操作数堆栈的顶部值,returns 它

第二种情况:

  1. invokevirtual将结果存入操作数栈顶
  2. astore_2 将结果从操作数栈弹出并将其存储在局部变量 #2
  3. aload_2 将局部变量#2 压入操作数栈的顶部
  4. ifnull 检查操作数栈的顶部是否为空,如果是,则转到指令 15,否则继续(我们假设它不为空)
  5. aload_2 将局部变量#2 压入操作数栈的顶部
  6. invokevirtual 在操作数栈的顶部调用一个方法,弹出它,然后压入结果
  7. ireturn 弹出操作数堆栈的顶部值,returns 它

那有什么区别呢? 第一个调用 aload_2 一次和 dup 一次,第二个调用 aload 两次。这里的区别几乎没有。如果查看整个操作过程中堆栈的大小,您会发现第一个实现将操作数堆栈增加了一个额外的值(少于 10 个字节,通常为 8 或 4 个字节,具体取决于 64 位或 32 位 JVM ), 但从堆栈内存中加载的局部变量少了一个。第二个使操作数堆栈稍微小一些,但有一个额外的局部变量加载(读取:从内存中获取)。

最终,这些优化将非常产生最小的影响,除非在内存极低的应用程序中,例如嵌入式系统。那么对你来说? 做可读的事情。

当有疑问时:"Premature optimization (can be) the root of all evil." 直到您知道您的代码很慢或者可以证明它在 运行 它之前很慢,您最好编写可读的代码。这几乎不属于您应该提前优化的关键 3%。