9

在研究分代垃圾收集器对应用程序性能的微妙影响时,我发现在一个非常基本的操作(简单写入堆位置)的性能方面存在相当惊人的差异,即写入的值是原始值还是引用。

微基准

@OutputTimeUnit(TimeUnit.NANOSECONDS)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 1, time = 1)
@Measurement(iterations = 3, time = 1)
@State(Scope.Thread)
@Threads(1)
@Fork(2)
public class Writing
{
  static final int TARGET_SIZE = 1024;

  static final    int[] primitiveArray = new    int[TARGET_SIZE];
  static final Object[] referenceArray = new Object[TARGET_SIZE];

  int val = 1;
  @GenerateMicroBenchmark
  public void fillPrimitiveArray() {
    final int primitiveValue = val++;
    for (int i = 0; i < TARGET_SIZE; i++)
      primitiveArray[i] = primitiveValue;
  }

  @GenerateMicroBenchmark
  public void fillReferenceArray() {
    final Object referenceValue = new Object();
    for (int i = 0; i < TARGET_SIZE; i++)
      referenceArray[i] = referenceValue;
  }
}

结果

Benchmark              Mode Thr    Cnt  Sec         Mean   Mean error    Units
fillPrimitiveArray     avgt   1      6    1       87.891        1.610  nsec/op
fillReferenceArray     avgt   1      6    1      640.287        8.368  nsec/op

由于整个循环几乎慢了 8 倍,因此写入本身可能慢了 10 倍以上。什么可以解释这种放缓?

写出原始数组的速度超过每纳秒 10 次写入。也许我应该问我问题的另一面:是什么让原始写作如此之快?(顺便说一句,我已经检查过了,时间与数组大小成线性关系。)

请注意,这都是单线程的;指定@Threads(2)将增加两个测量值,但比率将相似。


一点背景知识:卡表和相关的写屏障

年轻代中的对象可能碰巧只能从老年代中的对象访问。为了避免收集活动对象,YG 收集器必须知道自上次 YG 收集以来写入老年代区域的任何引用。这是通过一种称为卡表的“脏标志表”来实现的,它为每个 512 字节的堆块有一个标志。

当我们意识到每次写入引用都必须伴随着卡表不变的代码时,该方案的“丑陋”部分就出现了:必须标记卡表中保护被写入地址的位置一样。这段代码被称为写屏障

在特定的机器代码中,如下所示:

lea   edx, [edi+ebp*4+0x10]   ; calculate the heap location to write
mov   [edx], ebx              ; write the value to the heap location
shr   edx, 9                  ; calculate the offset into the card table
mov   [ecx+edx], ah           ; mark the card table entry as dirty

当写入的值是原始值时,这就是相同的高级操作所需要的全部内容:

mov   [edx+ebx*4+0x10], ebp

写入屏障似乎“仅”贡献了一次写入,但我的测量表明它会导致数量级的减速。我无法解释这一点。

UseCondCardMark只会让事情变得更糟

如果条目已被标记为脏,则有一个非常模糊的 JVM 标志应该避免卡表写入。这主要在一些退化的情况下很重要,因为大量的卡表写入导致线程之间通过 CPU 缓存进行错误共享。无论如何,我尝试使用该标志:

with  -XX:+UseCondCardMark:
Benchmark              Mode Thr    Cnt  Sec         Mean   Mean error    Units
fillPrimitiveArray     avgt   1      6    1       89.913        3.586  nsec/op
fillReferenceArray     avgt   1      6    1     1504.123       12.130  nsec/op
4

1 回答 1

4

引用 Vladimir Kozlov 在hotspot-compiler-dev邮件列表中提供的权威答案:

嗨,马尔科,

对于原始数组,我们使用手写的汇编代码,它使用 XMM 寄存器作为初始化向量。对于对象数组,我们没有对其进行优化,因为这并不常见。我们可以改进它,类似于我们对 arracopy 所做的改进,但我们决定暂时保留它。

问候,
弗拉基米尔

我也想知道为什么优化的代码没有内联,并且也得到了答案:

代码不小,所以我们决定不内联它。查看 macroAssembler_x86.cpp 中的 MacroAssembler::generate_fill():

http://hg.openjdk.java.net/hsx/hotspot-main/hotspot/file/54f0c207dc35/src/cpu/x86/vm/macroAssembler_x86.cpp


我原来的答案:

我错过了机器代码中的一个重要部分,显然是因为我正在查看已编译方法的堆栈替换版本,而不是用于后续调用的版本。事实证明,HotSpot 能够证明我的循环相当于调用Arrays.fill将完成的操作,并将整个循环替换call为此类代码的指令。我看不到该函数的代码,但它可能使用了所有可能的技巧,例如 MMX 指令,用相同的 32 位值填充一块内存。

这给了我衡量实际Arrays.fill通话的想法。我得到了更多的惊喜:

Benchmark                  Mode Thr    Cnt  Sec         Mean   Mean error    Units
fillPrimitiveArray         avgt   1      5    2      155.343        1.318  nsec/op
fillReferenceArray         avgt   1      5    2      682.975       17.990  nsec/op
loopFillPrimitiveArray     avgt   1      5    2      156.114        0.523  nsec/op
loopFillReferenceArray     avgt   1      5    2      682.209        7.047  nsec/op

循环和调用的结果fill是相同的。如果有的话,这比引发问题的结果更令人困惑。无论价值类型如何,我至少希望fill从相同的优化思想中受益。

于 2014-02-03T10:38:02.770 回答