在研究分代垃圾收集器对应用程序性能的更微妙影响时,我发现一个非常基本的操作的性能存在相当惊人的差异——简单地写入堆位置——关于写入的值是原始值还是原始值一个引用。
微基准测试
@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)
将增加两个测量值,但比率将相似。
一些背景知识:卡片表和相关的写屏障
新生代中的对象可能碰巧只能从老年代中的对象访问。为了避免收集 Activity 对象,YG 收集器必须知道自上次 YG 收集以来写入老年代区域的任何引用。这是通过一种称为卡片表的“脏标志表”实现的,它为每个 512 字节堆 block 都有一个标志。
方案的“丑陋”部分是当我们意识到每次写入引用都必须伴随卡片表不变 - 维护代码段时:卡片表中的位置保护被写入地址的地址必须被标记为脏。这段代码称为写屏障。
在特定的机器代码中,这看起来如下:
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
提供的权威答案邮件列表:
Hi Marko,
For primitive arrays we use handwritten assembler code which use XMM registers as vectors for initialization. For object arrays we did not optimize it because it is not common case. We can improve it similar to what we did for arracopy but we decided leave it for now.
Regards,
Vladimir
我也想知道为什么优化后的代码没有内联,也得到了答案:
The code is not small, so we decided to not inline it. Look on MacroAssembler::generate_fill() in 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
从相同的优化想法中受益。
关于java - 什么可以解释写入对堆位置的引用的巨大性能损失?,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/21523958/