阅读 586

快速理解 volatile 关键字

前言

  看了很多 Java 并发编程书籍的目录,volatile 在 JMM 中总是单独拎出来作为一个章节来讲,主要是因为它的特殊规则。要彻底弄懂 volatile 不太容易,但是如果从它如何解决并发编程中的可见性、原子性和有序性问题来学习,就能很快掌握 volatile 的作用。学习 volatile 关键字很有必要,Java 并发工具中的很多类都是基于 volatile 的。

volatile 特性

  在 JMM 中 volatile 的三大特性如下:

  1. 保证可见性:当写一个 volatile 变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存,使其他线程立即可见。
  2. 保证有序性:当变量被修饰为 volatile 时,JMM 会禁止读写该变量前后语句的大部分重排序优化,以保证变量赋值操作的顺序与程序中的执行顺序一致。
  3. 部分原子性:对任意单个 volatile 变量的读/写具有原子性,但类似于 volatile++ 这种复合操作不具有原子性。

如何保证可见性

  volatile 变量可见性很多书上都喜欢放到 happens-before 原则中来讲:

    对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
复制代码

  其实我觉得这句话初看并不能很好的理解 volatile 的可见性,而且还会引入新的概念 happens-before 规则。换一种表述方式会容易理解的多,其在 JMM 中的写和读语义如下:

  1. 当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
  2. 当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

  这就保证了 volatile 变量的可见性,也解释了 happens-before 中的 volatile 规则,而且需要注意的是:在写和读时操作的是整个工作内存中的共享变量,所以在读 volatile 变量时工作内存中的其他共享变量也是最新的。

如何保证有序性

  volatile 的有序性可能比较晦涩,但是看完 JMM 针对编译器制定的 volatile 重排序规则表后就会很容易理解:

  由上图 1 可知,JMM 限制了大部分情况下 volatile 变量读写语句前后语句的重排序,结合图片来看看下个这个例子:

class OrderingExample {
    int x = 0;
    volatile boolean flag = false;
    public void writer() {
        x = 42; //宇宙的终极答案
        flag = true;
    }
    public void reader() {
        if (flag == true) {
            //x = ?
        }
    }
}
复制代码

  以上代码在并发编程前传 中讲有序性的时候也贴过,这里将 flag 定义成 volatile。如果线程 A 先执行完 writer(),线程 B 后执行到 reader() 中的 x= 的时候,x 一定等于 42(JDK 1.5 以后),原因如下:

  参考图 1,可以看出普通变量的写不能重排到 volatile 变量的写后面,所以便不存在有序性问题。 其他禁止重排序规则参考图 1 进行类推,整个规则让 JMM 在多线程环境下保证了 volatile 变量的有序性。在本规则中有以下两点需要注意:

  1. 只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语义,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。换句话说,如果没有破坏 volatile 的内存语义则可以重排序,参考图 1 空白格子对应的规则。

  2. 为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,细则如下:

     在每个volatile写操作的前面插入一StoreStore屏障。 
     在每个volatile写操作的后面插入一个StoreLoad屏障。
     在每个volatile读操作的后面插入一个LoadLoad屏障。
     在每个volatile读操作的后面插入一个LoadStore屏障。
    复制代码

如何保证部分原子性

  同样拿并发编程前传中 dobule 和 long 的例子,double 和 long 变量的单个读/写在绝大部分商业虚拟机上都是原子的,但在在极端情况下并不具有原子性,而加了 volatile 后就一定能保证单个读/写原子性。这由 JMM 保证,其中底层原理有待深究,但底层应该是通过 cpu 指令来实现的。

  之所以说只能保证部分原子性,是因为 volatile 并不能保证 volatile 变量参与的复合语句的原子性,比如 i++; i+=1; 等这种看上去是单读和写,实质需要先读后写的语句。

与 synchronized 的区别

  由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果读者想在程序中用volatile代替锁,请一定谨慎。即使是单个变量的语句,也只有以下三种情况下可以使用 volatile 代替锁:

  1. 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值。
  2. 该变量不会与其他状态变量一起纳入不变性条件中。
  3. 在访问变量时不需要加锁。

  对于 1 的前半句是指对变量的写之前不能还要去读它,比如类似 i++、i = i + 1 等语句。至于 1 的后半句类似于我们常见的一写多读模型,不存在多线程问题。

  对于 2 是指该变量不能与其他变量一起控制某个操作,比如 if( i < j ){},其中 i 和 j 都是共享变量,i 是 volatile 修饰的。又比如 while( i - j > 2){} 等。i 与其他共享变量 j 一起参与了不变的条件控制,故存在问题。

  在《Java 并发编程实战》中列出了第 3 点,而《深入理解 Java 虚拟机》中直接删去了。可见对于 3 是不言而喻的。

总结

  了解 volatile 的三大特性以后,回看阿里数据库大牛何登成关于 volatile 的文章《C/C++ volatile关键词深度剖析》理解起来不要太简单。理解 volatile 简单,如果想灵活应用 volatile 可以看看 Java 并发工具包中的一些源码实现,看看大牛如何把 volatile 运用的恰到好处的。

关注下面的标签,发现更多相似文章
评论