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