Java volatile 关键字

字分裂

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

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

可见性

在一个多线程的应用中,线程在操作非 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关键字解析

猜你喜欢

转载自juejin.im/post/5e53426a6fb9a07c7c2d5e9d