易失性变量是否昂贵?

129
阅读完编译器编写者的JSR-133食谱,特别是关于volatile实现的“与原子指令的交互”一节后,我认为在不更新volatile变量的情况下读取它需要LoadLoad或LoadStore障碍。在页面的下面,我看到LoadLoad和LoadStore在X86 CPU上实际上是无操作的。这是否意味着可以在x86上执行volatile读取操作而不需要显式缓存失效,并且与正常变量读取(不考虑volatile的重新排序限制)一样快?我相信我没有正确理解这个问题。有人能给我启示吗?
编辑:我想知道在多处理器环境中是否存在差异。在单CPU系统上,CPU可能会查看自己的线程缓存,如John V.所述,但在多CPU系统上,必须为CPU提供一些配置选项,以使这不足够,并且必须访问主内存,从而使volatile在多CPU系统上变慢,对吗?
PS:在学习更多关于此事时,我偶然发现了以下优秀的文章,由于这个问题可能对其他人有用,因此在此分享我的链接:

1
你可以阅读我的关于多CPU配置的编辑,这正是你所指的。在多CPU系统上,短暂引用可能只进行一次主内存的读/写操作。 - John Vint
2
volatile 读本身并不昂贵。主要的成本在于它如何防止优化。实际上,除非 volatile 在紧密循环中使用,否则平均成本并不是很高。 - irreputable
2
这篇关于infoq的文章(http://www.infoq.com/articles/memory_barriers_jvm_concurrency)也许会对您有兴趣,它展示了volatile和synchronized在不同架构下生成的代码所产生的影响。这也是一个情况,jvm可以比预先编译器表现更好,因为它知道是否在单处理器系统上运行并可以省略一些内存屏障。 - Jörn Horstmann
4个回答

130

在Intel上,未受争议的易失性读取操作相当便宜。如果我们考虑以下简单情况:

public static long l;

public static void run() {        
    if (l == -1)
        System.exit(-1);

    if (l == -2)
        System.exit(-1);
}

使用Java 7的汇编代码打印功能,run方法看起来像这样:

# {method} 'run2' '()V' in 'Test2'
#           [sp+0x10]  (sp of caller)
0xb396ce80: mov    %eax,-0x3000(%esp)
0xb396ce87: push   %ebp
0xb396ce88: sub    $0x8,%esp          ;*synchronization entry
                                    ; - Test2::run2@-1 (line 33)
0xb396ce8e: mov    $0xffffffff,%ecx
0xb396ce93: mov    $0xffffffff,%ebx
0xb396ce98: mov    $0x6fa2b2f0,%esi   ;   {oop('Test2')}
0xb396ce9d: mov    0x150(%esi),%ebp
0xb396cea3: mov    0x154(%esi),%edi   ;*getstatic l
                                    ; - Test2::run@0 (line 33)
0xb396cea9: cmp    %ecx,%ebp
0xb396ceab: jne    0xb396ceaf
0xb396cead: cmp    %ebx,%edi
0xb396ceaf: je     0xb396cece         ;*getstatic l
                                    ; - Test2::run@14 (line 37)
0xb396ceb1: mov    $0xfffffffe,%ecx
0xb396ceb6: mov    $0xffffffff,%ebx
0xb396cebb: cmp    %ecx,%ebp
0xb396cebd: jne    0xb396cec1
0xb396cebf: cmp    %ebx,%edi
0xb396cec1: je     0xb396ceeb         ;*return
                                    ; - Test2::run@28 (line 40)
0xb396cec3: add    $0x8,%esp
0xb396cec6: pop    %ebp
0xb396cec7: test   %eax,0xb7732000    ;   {poll_return}
;... lines removed

如果您查看对getstatic的2个引用,第一个涉及从内存中加载,而第二个跳过了加载,因为该值是从已经加载到的寄存器(long为64位,在我的32位笔记本上使用2个寄存器)中重用的。

如果我们将l变量设置为volatile,则生成的汇编代码会有所不同。

# {method} 'run2' '()V' in 'Test2'
#           [sp+0x10]  (sp of caller)
0xb3ab9340: mov    %eax,-0x3000(%esp)
0xb3ab9347: push   %ebp
0xb3ab9348: sub    $0x8,%esp          ;*synchronization entry
                                    ; - Test2::run2@-1 (line 32)
0xb3ab934e: mov    $0xffffffff,%ecx
0xb3ab9353: mov    $0xffffffff,%ebx
0xb3ab9358: mov    $0x150,%ebp
0xb3ab935d: movsd  0x6fb7b2f0(%ebp),%xmm0  ;   {oop('Test2')}
0xb3ab9365: movd   %xmm0,%eax
0xb3ab9369: psrlq  $0x20,%xmm0
0xb3ab936e: movd   %xmm0,%edx         ;*getstatic l
                                    ; - Test2::run@0 (line 32)
0xb3ab9372: cmp    %ecx,%eax
0xb3ab9374: jne    0xb3ab9378
0xb3ab9376: cmp    %ebx,%edx
0xb3ab9378: je     0xb3ab93ac
0xb3ab937a: mov    $0xfffffffe,%ecx
0xb3ab937f: mov    $0xffffffff,%ebx
0xb3ab9384: movsd  0x6fb7b2f0(%ebp),%xmm0  ;   {oop('Test2')}
0xb3ab938c: movd   %xmm0,%ebp
0xb3ab9390: psrlq  $0x20,%xmm0
0xb3ab9395: movd   %xmm0,%edi         ;*getstatic l
                                    ; - Test2::run@14 (line 36)
0xb3ab9399: cmp    %ecx,%ebp
0xb3ab939b: jne    0xb3ab939f
0xb3ab939d: cmp    %ebx,%edi
0xb3ab939f: je     0xb3ab93ba         ;*return
;... lines removed
在这种情况下,变量 l 的两个 getstatic 引用都涉及从内存加载,即该值无法在多个 volatile 读取之间保留在寄存器中。为确保存在原子读取,该值从主内存读取到 MMX 寄存器中 movsd 0x6fb7b2f0(%ebp),%xmm0,使读取操作成为单个指令(从前面的示例中我们看到,64位值通常需要在32位系统上进行两个32位读取)。
因此,volatile 读取的总成本大致相当于内存加载,并且可以像 L1 缓存访问一样便宜。但是,如果另一个核心正在写入volatile 变量,则缓存行将无效,需要主内存或可能是 L3 缓存访问。实际成本会严重取决于 CPU 架构。即使在英特尔和 AMD 之间,高速缓存一致性协议也是不同的。

旁注:Java 6具有显示汇编代码的能力(这是由Hotspot实现的)。 - bestsss
在JDK5中,volatile不能与任何读/写指令重排序(这修复了双重检查锁定等问题)。这是否意味着它也会影响如何操作非volatile字段?将访问volatile和非volatile字段混合使用将很有趣。 - ewernli
@evemli,你需要小心,我曾经自己做过这个声明,但最终发现是不正确的。存在一个边缘情况。Java内存模型允许蟑螂旅馆语义,即存储可以在volatile存储之前重新排序。如果你从IBM网站上的Brian Goetz文章中学到了这一点,那么值得一提的是,这篇文章过于简化了JMM规范。 - Michael Barker

22

一般来说,大多数现代处理器上,volatile load(即volatile读取)与普通load(即普通读取)相似。而volatile store(即volatile写入)则约为montior-enter/monitor-exit(即进入/退出监视器)的1/3。这种情况在具有缓存一致性的系统中可见。

回答楼主的问题,volatile写入通常是昂贵的,而读取通常不是。

这是否意味着在x86上,可以进行没有显式缓存失效的volatile read操作,并且与普通变量读取一样快(不考虑volatile的重新排序限制)?

是的,有时验证字段时,CPU可能甚至不会触及主内存,而是窃听其他线程的缓存并从那里获取值(非常一般的解释)。

但是,我赞成Neil的建议,如果您有一个被多个线程访问的字段,则应将其封装为AtomicReference。作为AtomicReference,它的读/写吞吐量大致相同,但还更明显地表明该字段将被多个线程访问和修改。

编辑以回答楼主的编辑:

缓存一致性是一种有些复杂的协议,但简单来说:CPU将共享连接到主内存的公共高速缓存行。如果一台CPU加载了内存,并且没有其他CPU使用该内存,那么该CPU将假定它是最新的值。如果另一台CPU尝试加载相同的内存位置,则已经加载的CPU将意识到这一点,并实际上共享对请求CPU的缓存引用 - 现在请求CPU在其CPU缓存中具有该内存的副本。(它从未必须在主内存中查找引用)

还涉及相当多的协议,但这给出了正在发生的情况的概念。此外,回答您的另一个问题,在没有多个处理器的情况下,volatile读/写实际上可以比具有多个处理器更快。有些应用程序在单个CPU并发运行时甚至会运行得更快。


6
AtomicReference是一个包装器,它包裹了一个带有额外本地函数的volatile字段,提供了附加功能,例如getAndSet、compareAndSet等。因此,从性能的角度来看,只有在需要附加功能时使用它才有用。但我想知道为什么您在这里提到操作系统?该功能直接在CPU操作码中实现。这是否意味着在多处理器系统上,其中一个CPU不知道其他CPU的缓存内容时,易失性变量会变慢,因为CPU总是要命中主存? - Daniel
@John,为什么要通过AtomicReference添加另一个间接引用?如果需要CAS-好的,但是AtomicUpdater可能是更好的选择。据我回忆,没有关于AtomicReference的内在特性。 - bestsss
@bestsss 对于所有一般目的而言,AtomicReference.set/get和volatile load和store之间没有区别。 话虽如此,我对何时使用哪个也有同样的感觉(并且在某种程度上仍然如此)。 这个回答可以详细说明一下https://dev59.com/c2865IYBdhLWcg3wIrFg#3964347。 使用任何一个更多的是偏好,我使用AtomicReference而不是简单的volatile的唯一论点是为了清晰的文档 - 这本身也不是最好的论据,我理解。 - John Vint
顺便说一下,有人认为使用volatile字段/AtomicReference(无需CAS)会导致错误的代码http://old.nabble.com/Using-a-volatile-variable-as-a-%22guard%22-td30861496.html#nabble.pending30867595 - John Vint
@John,如果我通过AtomicReference声明任何东西,我绝对确定会涉及到一些CAS操作。我很少在不需要CAS的情况下声明任何易失性变量,在大多数情况下,这些变量由单个线程更新,但保留了监视它们的能力。另一个选项是stop布尔值,只需更改一次即可。 - bestsss
显示剩余3条评论

14
根据Java Memory Model(在Java 5+中由JSR 133定义),对volatile变量的任何操作 - 读取或写入 - 都会与同一变量的任何其他操作创建一个 happens-before 关系。这意味着编译器和JIT被迫避免某些优化,例如在线程内重新排序指令或仅在本地缓存中执行操作。
由于一些优化不可用,所以产生的代码肯定比原来慢,但可能不会慢太多。
尽管如此,除非你知道将从synchronized块之外的多个线程访问变量,否则不应使变量成为volatile。即使如此,您也应该考虑synchronizedAtomicReference及其伙伴、显式Lock类等是否比volatile更好选择。

4

访问一个volatile变量在很多方面类似于在同步块中封装对普通变量的访问。例如,访问volatile变量可以防止CPU在访问之前和之后重新排序指令,这通常会降低执行速度(虽然我无法确定降低了多少速度)。

更普遍地说,在多处理器系统上,我不认为访问volatile变量可以没有代价--必须有一种方法来确保在处理器A上的写操作将与处理器B上的读操作同步。


4
读取volatile变量与执行monitor-enter操作具有相同的代价,涉及指令重排序的可能性,而写入volatile变量等于执行monitor-exit操作。不同之处可能在于哪些变量(例如处理器缓存)被刷新或失效。虽然同步会刷新或使所有内容无效,但对volatile变量的访问应始终忽略缓存。 - Daniel
20
访问一个易失变量与使用同步块有很大的区别。进入同步块需要一个基于原子compareAndSet的写入来获取锁,并使用易失写入来释放锁。如果锁是有争议的,则控制权必须从用户空间传递到内核空间来仲裁锁(这是昂贵的部分)。访问易失变量始终会保留在用户空间。 - Michael Barker
@MichaelBarker:你确定所有的监视器都必须由内核而不是应用程序来保护吗? - Daniel
@Daniel:如果你使用同步块或锁来表示监视器,那么是的,但只有在监视器被争用时才会这样。如果不想使用内核仲裁,唯一的方法就是使用相同的逻辑,但忙等待而不是将线程挂起。 - Michael Barker
@MichaelBarker:好的,对于满足锁定条件的锁,我理解了。 - Daniel
@Daniel,“cache-ignoring”是什么意思? - curiousguy

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接