Java 什么可以解释写入堆位置引用的巨大性能损失?
在研究分代垃圾收集器对应用程序性能的更微妙影响时,我发现一个非常基本的操作(对堆位置的简单写入)的性能存在着惊人的差异,即写入的值是原始值还是引用 微基准 由于整个循环的速度几乎慢了8倍,所以写操作本身可能慢了10倍多。有什么可能解释这种减速 基本阵列的写入速度超过每纳秒10次写入。也许我应该问我问题的另一面:是什么让原始写作如此之快?(顺便说一句,我已经检查过了,时间与数组大小成线性比例。) 请注意,这是所有单螺纹;指定Java 什么可以解释写入堆位置引用的巨大性能损失?,java,garbage-collection,microbenchmark,jmh,Java,Garbage Collection,Microbenchmark,Jmh,在研究分代垃圾收集器对应用程序性能的更微妙影响时,我发现一个非常基本的操作(对堆位置的简单写入)的性能存在着惊人的差异,即写入的值是原始值还是引用 微基准 由于整个循环的速度几乎慢了8倍,所以写操作本身可能慢了10倍多。有什么可能解释这种减速 基本阵列的写入速度超过每纳秒10次写入。也许我应该问我问题的另一面:是什么让原始写作如此之快?(顺便说一句,我已经检查过了,时间与数组大小成线性比例。) 请注意,这是所有单螺纹;指定@Threads(2)将增加两个测量值,但比率将相似 一点背景:卡片表和
@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标志,如果条目已经标记为dirty,它应该避免卡片表写入。这主要在一些退化的情况下很重要,在这些情况下,大量的卡表写入会导致线程之间通过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在邮件列表中提供的权威答案:
你好,马尔科
对于基本数组,我们使用使用XMM的手写汇编代码
寄存器作为初始化的向量。对于对象数组,我们没有
优化它,因为它不是常见的情况。我们可以对其进行类似的改进
我们为arracopy所做的一切,但我们决定暂时搁置
问候,弗拉基米尔 我还想知道为什么优化后的代码没有内联,也得到了答案: 代码不小,所以我们决定不内联它。观看 MacromAssembler::在MacromAssembler_x86.cpp中生成_fill():
我原来的答覆是: 我错过了机器代码中的一个重要部分,显然是因为我正在查看编译方法的堆栈替换版本,而不是用于后续调用的版本。事实证明,HotSpot能够证明我的循环相当于对
数组的调用。fill
将完成的操作,并用对此类代码的调用
指令替换整个循环。我看不到该函数的代码,但它可能使用了所有可能的技巧,例如MMX指令,用相同的32位值填充内存块
这让我想到了测量实际的数组。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
能够从相同的优化思想中获益,而不管值类型如何。很有趣。你能验证一下在边界检查或循环展开等方面没有什么区别吗?@maaartinus实际上有一个更大的区别,我没有注意到,因为我可能是在看OSR版本,而不是常规版本:整个循环实际上被一个调用所取代,可能是数组的本机代码。填充。HotSpot似乎能够证明它所需要的一切。很酷,但它提出了一个新的问题:为什么同样的优化对对象不起作用?我希望Array.fill
能够巧妙地使用写屏障,只写几个字节(可能使用另一个Array.fill
)。@maaartinus刚刚检查过---显式fill
调用执行的性能几乎与循环对应的调用相同,即使循环的参考版本没有转换为调用。Explicitfill
实际上比基本情况下的循环慢!如果说有什么区别的话,这就更加神秘了。我知道写屏障必须是即时的(考虑到并发性),但它仍然应该优化为仅在实际移动到下一张卡时才写入。即使使用相当愚蠢的实现,它的速度也可能是最慢的两倍:首先写入所有屏障,然后填充阵列。我的不同之处在于:对于引用,使用数组
稍微快一点,其他方面大致相同。对于引用数组,在计时方面似乎存在大量错误。也许更多的运行是值得的?@user2357112:基准测试框架(JMH)应该会处理好这一切。@user2357112我重复了5次迭代,每次迭代2秒,并进行了更多的预热。误差较小,结果收敛到几乎相同(loop==fill,但原语和引用之间的差距保持稳定)。请参阅更新的答案。@maaartinus重复计数是可配置的,并且
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
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