什么可以解释写入堆位置引用的巨大性能损失?

Mar*_*nik 9 java garbage-collection microbenchmark jmh

在研究分代垃圾收集器对应用程序性能的微妙后果时,我已经在一个非常基本的操作 - 一个简单的写入堆位置 - 的性能方面遇到了相当惊人的差异 - 关于所写的值是原始值还是引用.

微基准

@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;
  }
}
Run Code Online (Sandbox Code Playgroud)

结果

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
Run Code Online (Sandbox Code Playgroud)

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

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

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


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

Young Generation中的一个对象可能恰好只能从Old Generation中的一个对象到达.为避免收集活动对象,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
Run Code Online (Sandbox Code Playgroud)

当写入的值是原始的时,这就是同一个高级操作所需要的:

mov   [edx+ebx*4+0x10], ebp
Run Code Online (Sandbox Code Playgroud)

写入障碍似乎只"贡献"了一次写入,但我的测量表明它会导致数量级的减速.我无法解释这一点.

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
Run Code Online (Sandbox Code Playgroud)

Mar*_*nik 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
Run Code Online (Sandbox Code Playgroud)

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