Java volatile 关键字

1,130 阅读5分钟

读写64位值时确保原子性

注意:这里说的是确保 读取写入 这两个动作是原子的,而不是说关于其复合操作(加减乘除、位运算等)是原子的

出于 Java 编程语言内存模型的目的,(允许 Java 虚拟机)对于非 volatile 修饰的 longdouble 值的单次写入被视为 2 次单独的写入:分别写入前后 32 位。这可能导致线程从一次写入中看到 64 位值的前 32 位,而从另一次写入中看到后 32 位的情况

一些(Java 虚拟机)实现可能会发现将对 64 位 longdouble 值的单个写操作划分为对相邻 32 位值的两个写操作很方便。为了提高效率,此行为是特定于实现的;Java 虚拟机的实现可以自由执行原子和两部分的写入 longdouble

两部分的写入 longdouble 值可能导致在需要线程安全的代码中读取不确定的值。因此,多线程程序在读取或写入 64 位值时必须确保原子性

鼓励 Java 虚拟机的实现避免在可能的情况下拆分 64 位值;鼓励程序员将共享的 64 位值声明为 volatile 或正确同步其程序,以避免可能的复杂性

相关链接:

  1. The Java Language Specification, Java SE 8 Edition - 17.7. Non-Atomic Treatment of double and long
  2. 64位OpenJDK 7/8中并发长写的值完整性保证
  3. VNA05-J. Ensure atomicity when reading and writing 64-bit values

可见性

在一个多线程的应用中,线程在操作非 volatile 变量时,出于性能考虑,每个线程可能会将变量从主存拷贝到 CPU 缓存中。如果你的计算机有多个 CPU,每个线程可能会在不同的 CPU 中运行。这意味着,每个线程都有可能会把变量拷贝到各自 CPU 的缓存中。 出现这个问题是因为 Java 尝试尽可能地提高执行效率,缓存的主要目的是避免从主内存中读取数据。当并发时,有时不清楚 Java 什么时候应该将值从主内存刷新到本地缓存 — 而这个问题称为 缓存一致性 ( cache coherence )

CPU缓存

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

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

  1. 该变量同时被多个任务访问
  2. 这些访问中至少有一个是写操作
  3. 你尝试避免同步(在现代 Java 中,你可以使用高级工具来避免进行同步,例如 java.util.concurrent.atomic 库)

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

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

重排与 Happen-Before 原则

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

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

// lowlevel/ReOrdering.java

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;
  }
}

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

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

小结

  1. 针对 longdouble 这种 64 位值的读写,volatile 能确保其原子性
  2. volatile 保证可见性,所有读取直接从主存读取,所有写入直接写入主存中
  3. volatile 确保指令重排序时不会把其后面的指令排到 volatile 变量之前,也不会把前面的指令排到 volatile 变量之后
  4. volatile 不能保证变量的复合操作是原子的,可以通过 synchronizedjava.util.concurrent.atomic 库来保证变量复合操作的原子性
  5. 读写 volatile 变量会导致变量从主存读写,从主存读写比从 CPU 缓存读写更加昂贵;访问一个 volatile 变量会禁止指令重排,而指令重排是一种提升性能的技术。因此,应当只在需要保证变量可见性的情况下,才使用 volatile 变量,以免影响程序性能


参考资料:

~~~~~~~~On Java8 中文版 - 附录:并发底层原理 - volatile 关键字

~~~~~~~~并发编程网 - Java Volatile关键字

~~~~~~~~Matrix海子 - Java并发编程:volatile关键字解析