是的,在某些情况下这是明智的,正如您所建议的,易失性变量就是其中一种情况——即使对于单线程访问也是如此!
从硬件和编译器/JIT 的角度来看,易失性写入都是昂贵的。在硬件级别,这些写入可能比普通写入贵 10 到 100 倍,因为必须刷新写入缓冲区(在 x86 上,详细信息将因平台而异)。在编译器/JIT 级别,易失性写入会抑制许多常见的优化。
然而,推测只能让你走这么远——证据总是在基准测试中。这是一个尝试您的两种策略的微基准。基本思想是将值从一个数组复制到另一个数组(几乎是 System.arraycopy),有两种变体——一种是无条件复制,另一种是先检查值是否不同。
以下是简单的非易失性案例的复制例程(此处为完整源代码):
// no check
for (int i=0; i < ARRAY_LENGTH; i++) {
target[i] = source[i];
}
// check, then set if unequal
for (int i=0; i < ARRAY_LENGTH; i++) {
int x = source[i];
if (target[i] != x) {
target[i] = x;
}
}
使用上面的代码复制长度为 1000 的数组,使用Caliper作为我的 microbenchmark 工具的结果是:
benchmark arrayType ns linear runtime
CopyNoCheck SAME 470 =
CopyNoCheck DIFFERENT 460 =
CopyCheck SAME 1378 ===
CopyCheck DIFFERENT 1856 ====
这还包括每次运行大约 150ns 的开销来重置目标阵列。跳过检查要快得多——每个元素大约 0.47 ns(或者在我们移除设置开销后每个元素大约 0.32 ns,所以在我的盒子上几乎正好是 1 个周期)。
当数组相同时,检查速度大约慢 3 倍,当它们不同时,检查速度慢 4 倍。我很惊讶这张支票有多糟糕,因为它是完全可以预测的。我怀疑罪魁祸首主要是 JIT——循环体复杂得多,展开的次数可能更少,其他优化可能不适用。
让我们切换到 volatile 案例。在这里,我使用AtomicIntegerArray
了 volatile 元素数组,因为 Java 没有任何带有 volatile 元素的本机数组类型。在内部,此类只是使用 直接写入数组sun.misc.Unsafe
,这允许易失性写入。生成的程序集与普通数组访问基本相似,除了易失性方面(以及可能的范围检查消除,这在 AIA 情况下可能无效)。
这是代码:
// no check
for (int i=0; i < ARRAY_LENGTH; i++) {
target.set(i, source[i]);
}
// check, then set if unequal
for (int i=0; i < ARRAY_LENGTH; i++) {
int x = source[i];
if (target.get(i) != x) {
target.set(i, x);
}
}
结果如下:
arrayType benchmark us linear runtime
SAME CopyCheckAI 2.85 =======
SAME CopyNoCheckAI 10.21 ===========================
DIFFERENT CopyCheckAI 11.33 ==============================
DIFFERENT CopyNoCheckAI 11.19 =============================
桌子已经转了。首先检查比通常的方法快约 3.5 倍。总体而言,一切都慢得多——在检查情况下,我们每个循环支付约 3 ns,在最坏的情况下约 10 ns(上面的时间在我们身上,并覆盖了整个 1000 个元素数组的副本)。易失性写入确实更昂贵。DIFFERENT 案例中包含大约 1 ns 的开销来在每次迭代时重置数组(这就是为什么即使是简单的 DIFFERENT 也会稍微慢一些)。我怀疑“检查”案例中的很多开销实际上是边界检查。
这都是单线程的。如果您实际上在 volatile 上存在跨核争用,那么简单方法的结果会更糟,并且与上面的检查用例一样好(缓存行只会处于共享状态 - 不需要一致性流量)。
我也只测试了“每个元素都相等”与“每个元素不同”的极端情况。这意味着“检查”算法中的分支总是可以完美预测。如果你有相等和不同的混合,你不会只得到相同和不同情况下时间的加权组合 - 由于预测错误(无论是在硬件级别,也可能是在 JIT 级别),你会做得更糟,它不能再优化总是采用的分支)。
所以它是否明智,即使对于 volatile,也取决于具体的上下文——相等和不相等值的混合,周围的代码等等。在单线程场景中,我通常不会单独为 volatile 执行此操作,除非我怀疑大量集合是多余的。然而,在大量多线程结构中,读取然后执行易失性写入(或其他昂贵的操作,如 CAS)是最佳实践,您会看到它的质量代码,例如java.util.concurrent
结构。