浅谈volatile关键字

写在前面

此篇仅记录On-Java-8学习内容

volatile 可能是 Java中最微妙和最难用的关键字。幸运的是,在现代 Java 中,你几乎总能避免使用它,如果你确实看到它在代码中使用,你应该保持怀疑态度和怀疑 – 这很有可能代码是过时的,或者编写代码的人不清楚使用它在大体上(或两者都有)易变性(volatile)或并发性的后果。

使用 volatile 有三个理由。

字分裂

当你的 Java 数据类型足够大(在 Java 中 long 和 double 类型都是 64 位),写入变量的过程分两步进行,就会发生 Word tearing (字分裂)情况。JVM 被允许将 64 位数量的读写作为两个单独的 32 位操作执行3,这增加了在读写过程中发生上下文切换的可能性,因此其他任务会看到不正确的结果。这被称为 Word tearing (字分裂),因为你可能只看到其中一部分修改后的值。基本上,任务有时可以在第一步之后但在第二步之前读取变量,从而产生垃圾值(对于例如 boolean 或 int 类型的小变量是没有问题的;任何 long 或 double 类型则除外)。

在缺乏任何其他保护的情况下,用 volatile 修饰符定义一个 long 或 double 变量,可阻止字分裂情况。然而,如果使用 synchronized 或 java.util.concurrent.atomic类之一保护这些变量,则 volatile 将被取代。此外,volatile 不会影响到增量操作并不是原子操作的事实。

可见性

你必须假设每个任务拥有自己的处理器,并且每个处理器都有自己的本地内存缓存。该缓存准许处理器运行的更快,因为处理器并不总是需要从比起使用缓存显著花费更多时间的主内存中获取数据。

出现这个问题是因为 Java 尝试尽可能地提高执行效率。缓存的主要目的是避免从主内存中读取数据。当并发时,有时不清楚 Java 什么时候应该将值从主内存刷新到本地缓存 — 而这个问题称为 缓存一致性(cache coherence )。

每个线程都可以在处理器缓存中存储变量的本地副本。将字段定义为 volatile 可以防止这些编译器优化,这样读写就可以直接进入内存,而不会被缓存。一旦该字段发生写操作,所有任务的读操作都将看到更改。如果一个 volatile 字段刚好存储在本地缓存,则会立即将其写入主内存,并且该字段的任何读取都始终发生在主内存中。

volatile 应该在何时适用于变量:

  1. 该变量同时被多个任务访问。

  2. 这些访问中至少有一个是写操作。

  3. 你尝试避免同步(在现代 Java 中,你可以使用高级工具来避免进行同步)。

举个例子,如果你使用变量作为停止任务的标志值。那么该变量至少必须声明为volatile (尽管这并不一定能保证这种标志的线程安全)。否则,当一个任务更改标志值时,这些更改可以存储在本地处理器缓存中,而不会刷新到主内存。当另一个任务查看标记值时,它不会看到更改。我更喜欢使用AtomicBoolean 类型作为标志值的办法

任务对其自身变量所做的任何写操作都始终对该任务可见,因此,如果只在任务中使用变量,你不需要使其变量声明为 volatile 。

如果单个线程对变量写入而其他线程只读取它,你可以放弃该变量声明为 volatile。通常,如果你有多个线程对变量写入,volatile 无法解决你的问题,并且你必须使用synchronized 来防止竞争条件。这有一个特殊的例外:可以让多个线程对该变量写入,

只要它们不需要先读取它并使用该值创建新值来写入变量。如果这些多个线程在结果中使用旧值,则会出现竞争条件,因为其余一个线程之一可能会在你的线程进行计算时修改该变量。即使你开始做对了,想象一下在代码修改或维护过程中忘记和引入一个重大变化是多么容易,或者对于不理解问题的不同程序员来说是多么容易(这在 Java 中尤其成问题因为程序员倾向于严重依赖编译时检查来告诉他们,他们的代码是否正确)。

重要的是要理解原子性和可见性是两个不同的概念。在非 volatile变量上的原子操作是不能保证是否将其刷新到主内存。

同步也会让主内存刷新,所以如果一个变量完全由 synchronized的方法或代码段 (或者 java.util.concurrent.atomic 库里类型之一) 所保护,则不需要让变量用volatile。

重排与Happen-Before 原则

只要结果不会改变程序表现,Java 可以通过重排指令来优化性能。然而,重排可能会影响本地处理器缓存与主内存交互的方式,从而产生细微的程序 bug 。直到 Java 5 才理解并解决了这个无法阻止重排的问题。现在,volatile 关键字可以阻止重排 volatile变量周围的读写指令。这种重排规则称为 happens before 担保原则。

这项原则保证在 volatile 变量读写之前发生的指令先于它们的读写之前发生。同样,任何跟随 volatile 变量之后读写的操作都保证发生在它们的读写之后。例如:

public class ReOrdering implements Runnable {
    int one, two, three, four, five, six;
    volatile int volaTile;
    @Override
    public void run() {
        one = 1;
        two = 2;
        three = 3;
        volaTile = 92;
        int x = four;
        int y = five;
        int z = six;
    }
}

例子中 one,two,three 变量赋值操作就可以被重排,只要它们都发生在 volatile变量写操作之前。同样,只要volatile 变量写操作发生在所有语句之前,x,y,z 语句可以被重排。这种 volatile(易变性)操作通常称为 memory barrier (内存屏障)。happens before 担保原则确保 volatile 变量的读写指令不能跨过内存屏障进行重排。

happens before 担保原则还有另一个作用:当线程向一个 volatile 变量写入时,在线程写入之前的其他所有变量(包括非 volatile 变量)也会刷新到主内存。当线程读取一个 volatile 变量时,它也会读取其他所有变量(包括非 volatile 变量)与 volatile变量一起刷新到主内存。尽管这是一个重要的特性,它解决了 Java 5 版本之前出现的一些非常狡猾的 bug ,但是你不应该依赖这项特性来 “自动” 使周围的变量变得易变性(volatile )的。如果你希望变量是易变性(volatile )的,那么维护代码的任何人都应该清楚这一点。

什么时候使用volatile

对于 Java 早期版本,编写一个证明需要 volatile 的示例并不难。如果你进行搜索,你可以找到这样的例子,但是如果你在** Java 8 中尝试这些例子,它们就不起作用了** (我没有找到任何一个)。我努力写这样一个例子,但没什么用。这可能原因是 JVM 或者硬件,或两者都得到了改进。这种效果对现有的应该 volatile (易变性)但不 volatile 的存储的程序是有益的;对于此类程序,失误发生的频率要低得多,而且问题更难追踪。

如果你尝试使用 volatile ,你可能更应该尝试让一个变量线程安全而不是引起同步的成本。因为 volatile 使用起来非常微妙和棘手,所以我建议根本不要使用它; 相反,请使用 java.util.concurrent.atomic 里面类之一。它们以比同步低得多的成本提供了完全的线程安全性。

如果你正在尝试调试其他人的并发代码,请首先查找使用volatile 的代码并将其替换为 Atomic 变量。除非你确定程序员对并发性有很高的理解,否则它们很可能会误用volatile 。


原文始发于微信公众号(子枫进阶之路):浅谈volatile关键字

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/24466.html

(0)
小半的头像小半

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!