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

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

微基准

@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)将增加两个测量值,但比率将类似。


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

Young 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 

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

 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 

引用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


我原来的回答是:

我错过了机器代码中的一个重要部分,显然是因为我正在查看已编译方法的On-Stack Replacement版本而不是后续调用所使用的版本。 事实certificate,HotSpot能够certificate我的循环相当于对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从相同的优化思路中受益。