“volatile”关键字用于什么?

139

我读了一些关于 volatile 关键字的文章,但是我无法弄清楚它的正确用法。 请告诉我在 C#和 Java 中应该如何使用它?


1
volatile的一个问题是它有多重含义。它作为向编译器提供信息不要进行诡异优化的遗留C遗留物,_同时_也意味着在访问时应该使用内存屏障。但在大多数情况下,它只会带来性能开销和/或使人困惑。 :P - AnorZaken
8个回答

177

考虑以下示例:

int i = 5;
System.out.println(i);
编译器可能对此进行优化,仅打印5,就像这样:
System.out.println(5);

然而,如果存在另一个线程可以改变i,那么这是错误的行为。如果另一个线程将i更改为6,则优化版本仍将打印5。

volatile关键字可以防止这种优化和缓存,因此在变量可能被另一个线程更改时很有用。


3
我相信如果将i标记为volatile,优化仍然是有效的。在Java中,一切都与* happens-before *关系有关。 - Tom Hawtin - tackline
感谢您的发布,那么volatile与变量锁定有关系吗? - Mircea
@Mircea:这就是我所听到的关于将某些东西标记为volatile的全部内容:将字段标记为volatile会使用一些内部机制,以允许线程看到给定变量的一致值,但这在上面的答案中没有提到...也许有人可以确认或否认这一点?谢谢。 - npinti
7
@Sjoerd:我不确定我理解这个例子。如果i是一个局部变量,其他线程无论如何也不能更改它。如果它是一个字段,除非它是final,否则编译器无法优化调用。我认为编译器不能基于假设一个字段“看起来”像final而进行优化,除非它被明确地声明为这样。 - polygenelubricants
@poly:是的,如果将那个局部变量作为引用传递给线程,可能会有所帮助。 - Steven Sudit
2
C#和Java不是C++。这是不正确的。它不能防止缓存,也不能防止优化。它涉及到读取-获取和存储-释放语义,在弱序内存架构上是必需的。它涉及到推测执行。 - doug65536

106

在C#和Java中,“volatile”告诉编译器变量的值永远不会缓存,因为其值可能会在程序本身范围之外发生更改。然后,编译器将避免任何可能导致问题的优化,如果变量在“其控制范围之外”更改。


45

读取volatile字段具有获取语义。这意味着保证从volatile变量中读取的内存将发生在后续任何内存读取之前。它阻止编译器进行重新排序,并且如果硬件需要(弱序CPU),它将使用特殊指令使硬件刷新在volatile读取之后但早期被推测启动的任何读取,或者CPU可以通过防止在发出负载获取和其退役之间发生任何推测负载来防止它们提前发出。

写入volatile字段具有释放语义。这意味着保证对volatile变量的任何内存写入都会延迟,直到所有先前的内存写入对其他处理器可见。

考虑以下示例:

something.foo = new Thing();
如果foo是类中的成员变量,并且其他CPU可以访问由something引用的对象实例,则它们可能在Thing构造函数中的内存写入全局可见之前看到值foo发生改变!这就是“弱排序内存”的含义。即使编译器在将所有存储放入构造函数之前都有foo的存储,也可能会发生这种情况。如果foovolatile,则对foo的存储将具有释放语义,并且硬件保证在允许对foo进行写入之前,所有写入foo之前的写入都可见于其他处理器。
如何让对foo的写入被重新排序得如此糟糕?如果持有foo的缓存行在缓存中,并且构造函数中的存储未命中缓存,则可能导致存储比缓存未命中的写入完成得早得多。
英特尔的可怕的Itanium架构具有弱排序内存。原始XBox 360中使用的处理器具有弱排序内存。许多ARM处理器,包括非常流行的ARMv7-A,都具有弱排序内存。
开发人员通常不会看到这些数据竞争,因为像锁定这样的东西将执行完整的内存屏障,基本上是同时获取和释放语义。在锁定之前,不能对锁定内的任何负载进行推测执行,它们会延迟到锁定被获取之后才执行。不能跨锁释放延迟任何存储,释放锁的指令被推迟到锁定内完成的所有写入都对全局可见性时才被执行。
更完整的示例是“双重检查锁定”模式。此模式的目的是避免必须始终获取锁定才能延迟初始化对象。
从维基百科中摘取。
public class MySingleton {
    private static object myLock = new object();
    private static volatile MySingleton mySingleton = null;

    private MySingleton() {
    }

    public static MySingleton GetInstance() {
        if (mySingleton == null) { // 1st check
            lock (myLock) {
                if (mySingleton == null) { // 2nd (double) check
                    mySingleton = new MySingleton();
                    // Write-release semantics are implicitly handled by marking
                    // mySingleton with 'volatile', which inserts the necessary memory
                    // barriers between the constructor call and the write to mySingleton.
                    // The barriers created by the lock are not sufficient because
                    // the object is made visible before the lock is released.
                }
            }
        }
        // The barriers created by the lock are not sufficient because not all threads
        // will acquire the lock. A fence for read-acquire semantics is needed between
        // the test of mySingleton (above) and the use of its contents. This fence
        // is automatically inserted because mySingleton is marked as 'volatile'.
        return mySingleton;
    }
}

在这个例子中,MySingleton 构造函数内的存储可能在 store 到 mySingleton 之前对其他处理器不可见。如果发生这种情况,窥视 mySingleton 的其他线程将无法获取锁定,并且他们不一定会获得构造函数的写入。

volatile 永远不会阻止缓存。它所做的是保证其他处理器“看到”的写入顺序。一个 store release 将延迟 store 直到所有挂起的写操作完成,并发出总线周期告诉其他处理器丢弃/写回他们可能已经缓存的相关行。load acquire 将刷新任何推测读取,确保它们不会是过去的旧值。


好的解释。双重检查锁定的例子也很好。然而,我仍然不确定何时使用它,因为我担心缓存方面的问题。如果我编写一个队列实现,其中只有1个线程将进行写入,而只有1个线程将进行读取,那么我是否可以在没有锁的情况下通过将我的头和尾“指针”标记为易失性来完成?我想确保读者和写者都看到最新的值。 - user4238474
headtail都需要是易失性的,以防止生产者假定tail不会改变,并防止消费者假定head不会改变。此外,必须使head成为易失性以确保队列数据写入在将head存储全局可见之前全局可见。 - doug65536
+1,“最新的”这样的术语不幸地暗示了一个单一正确值的概念。实际上,两个竞争对手可以在完全相同的时间到达终点线 - 在CPU上,两个核心可以同时请求写入。毕竟,核心不会轮流工作 - 这将使多核无意义。良好的多线程思考/设计不应该专注于试图强制低级别的“最新性” - 因为锁定只是强制核心任意选择一个发言者而没有公平性 - 而是尝试设计摆脱这种不自然的概念的需要。 - AnorZaken

39
为了理解volatile对变量的影响,重要的是要了解变量在非volatile情况下发生的情况。
变量是非volatile的
当两个线程A和B正在访问一个非volatile变量时,每个线程将在其本地缓存中维护该变量的本地副本。线程A在其本地缓存中进行的任何更改都不会对线程B可见。
变量是volatile的
当变量声明为volatile时,它实际上意味着线程不应该缓存这样的变量或者换句话说,线程不应该信任这些变量的值,除非它们直接从主内存中读取。
那么,何时将变量声明为volatile?
当您有一个可以由许多线程访问的变量,并且希望每个线程获取该变量的最新更新值,即使该值是由任何其他线程/进程/程序外部更新的。

4
错误。这与“防止缓存”无关。它涉及重新排序,由编译器或CPU硬件通过推测执行完成。 - doug65536
3
我认为这完全不正确。如果是正确的话,那么多线程代码就必须始终需要使用volatile关键字。 - tymtam

35

Volatile关键字在Java和C#中有不同的含义。

Java

来自Java Language Spec

如果一个字段被声明为volatile,那么Java内存模型确保所有线程看到该变量具有一致的值。

C#

来自C# Reference(2021年3月31日检索):

Volatile关键字指示一个字段可能会被多个同时执行的线程修改。编译器、运行时系统甚至硬件可能为了提高性能而重新排列对内存位置的读写。声明为volatile的字段不受这些优化的影响。(...)


非常感谢您的发布,我理解在Java中它的作用类似于在线程上下文中锁定该变量,在C#中,如果使用变量的值不仅可以从程序中更改,而且外部因素如操作系统也可以修改其值(没有隐含的锁定)...如果我正确理解了这些差异,请告诉我。 - Mircea
@Mircea 在Java中不涉及锁定,它只确保使用volatile变量的最新值。 - krock
Java是否承诺某种内存屏障,还是像C++和C#一样只承诺不优化引用? - Steven Sudit
内存屏障是一种实现细节。Java 实际上承诺的是所有读取将看到最近写入的值。 - Stephen C
1
@StevenSudit 是的,如果硬件需要屏障或者load/acquire或store/release指令,那么它将使用这些指令。请参考我的回答。 - doug65536

9
在Java中,“volatile”用于告诉JVM该变量可能被多个线程同时使用,因此某些常见的优化不能应用。特别是当两个访问同一变量的线程在同一台机器的不同CPU上运行时。CPU非常善于缓存数据,因为内存访问比缓存访问慢得多。这意味着如果数据在CPU1中进行了更新,它必须立即通过所有缓存到达主内存,而不是等待缓存自己清除,以便CPU2可以看到更新后的值(同样要忽略途中的所有缓存)。

0

当您读取非易失性数据时,执行线程可能会或可能不会始终获取更新后的值。 但如果对象是易失性的,则线程始终获取最新的值。


1
你能重新表达一下你的回答吗? - Anirudha Gupta
volatile 关键字将会给你最新的值,而不是缓存的值。 - Subhash Saini

-2

Volatile关键字解决了并发问题,使得值同步。这个关键字主要用于线程中,当多个线程更新同一个变量时。


1
我认为它并不能“解决”问题。它只是在某些情况下有所帮助的工具。在需要锁定的情况下,不要依赖volatile,例如在竞态条件中。 - Scratte

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