前言
本文隶属于专栏《100个问题搞定Java虚拟机》,该专栏为笔者原创,引用请注明来源,不足和错误之处请在评论区帮忙指出,谢谢!
本专栏目录结构和参考文献请见100个问题搞定Java虚拟机
正文
使用 volatile 有三大理由
字分裂(Word tearing)
当你的 Java 数据类型足够大(在 Java 中 long 和 double 类型都是 64 位),写入变量的过程分两步进行,就会发生 Word tearing(字分裂)情况。
JVM 被允许将 64 位数量的读写作为两个单独的 32 位操作执行,这增加了在读写过程中发生上下文切换的可能性,因此其他任务会看到不正确的结果。
这被称为 Word tearing(字分裂),因为你可能只看到其中一部分修改后的值。
基本上,任务有时可以在第一步之后但在第二步之前读取变量,从而产生垃圾值(对于例如 boolean 或 int 类型的小变量是没有问题的;任何 long 或 double 类型则除外)。
在缺乏任何其他保护的情况下,用 volatile 修饰符定义ー个 long 或 double 变量,可阻止字分裂情况。
然而,如果使用 synchronized 或 java.util.concurrent.atomic 类之保护这些变量,则 volatile 将被取代。
此外,volatile 修饰的字段进行增量操作并不保证原子性。
可见性
每个任务拥有自己的处理器,并且每个处理器都有自己的本地内存缓存。
这个缓存可以让处理器跑的更快,从主内存中获取数据会比从缓存获取花费的代价更大,所以处理器并不需要从主内存中获取数据。
JVM 会尝试尽可能地提高执行效率,而缓存的主要目的就是避免从主内存中读取数据。
当并发时,有时不清楚 Java 什么时候应该将值从主内存刷新到本地缓存一而这个问题称为缓存一致性(cache coherence)。
每个线程都可以在处理器缓存中存储变量的本地副本。
将字段定义为 volatile 可以防止这些编译器优化,这样读写就可以直接进入内存,而不会被缓存。
一旦该字段发生写操作,所有任务的读操作都将看到更改。
如果一个 volatile 字段刚好存储在本地缓存,则会立即将其写入主内存,并且该字段的任何读取都始终发生在主内存中。
关于主内存和工作内存请参考我的另一篇博客——主内存和工作内存是什么?
重排和 happens-before 原则
只要结果不会改变程序表现,Java 可以通过重排指令来优化性能。
然而,重排可能会影响本地处理器缓存与主内存交互的方式,从而产生细微的程序 bug。
直到 Java5 才理解并解决了这个无法阻止重排的问题。
现在,volatile 关键字可以阻止重排 volatile 变量周围的读写指令(通过添加内存屏障实现)。
这种重排规则称为 happens before 担保原则。
这项原则保证在 volatile 変量读写之前发生的指令先于它们的读写之前发生。
同样, 任何跟随 volatile 変量之后读写的操作都保证发生在它们的读写之后。
happens before 担保原则还有另一个作用:
当线程向一个 volatile 変量写入时,在线程写入之前的其他所有变量(包括非 volatile 变量)也会刷新到主内存。
当线程读取一个 volatile 变量时,它也会读取其他所有变量(包括非 volatile 变量)与 volatile 变量一起刷新到主内存。
关于 happens before 的更多内容请参考我的另一篇博客——什么是happens-before原则?
补充
volatile 适用场景
- volatile 修饰的变量同时被多个任务访问。
- 访向 volatile 修饰的变量中至少有一个是写操作。
- 尝试避免同步(在 Java 中,可以使用一些高级工具来避免进行同步)。
按照《Java 编程思想》的作者 Bruce Eckel 大神的说法:
Java8 里面的 volatile 使用起来非常微妙和棘手,建议根本不要使用它,相反,可以使用 java.util.concurrent.atomic 里面的 原子类来代替它。
关于 Java 并发的更多内容可以关注我的另一篇专栏—— 100个问题搞定Java并发