写点什么

深入理解关键字 volatile

作者:小小怪下士
  • 2023-03-20
    湖南
  • 本文字数:5912 字

    阅读完需:约 19 分钟

volatile 关键字的作用是什么?


相比于 synchronized 关键字(重量级锁)对性能影响较大,Java 提供了一种较为轻量级的可见性和有序性问题的解决方案,那就是使用 volatile 关键字。由于使用 volatile 不会引起上下文的切换和调度,所以 volatile 对性能的影响较小,开销较低。


从并发三要素的角度看,volatile 可以保证其修饰的变量的可见性有序性,无法保证原子性(不能保证完全的原子性,只能保证单次读/写操作具有原子性,即无法保证复合操作的原子性)。


下面将从并发三要素的角度介绍 volatile 如何做到可见和有序的。



1. volatile 如何实现可见性?

什么是可见性?


可见性指当多个线程同时访问共享变量时,一个线程对共享变量的修改,其他线程可以立即看到(即任意线程对共享变量操作时,变量一旦改变所有线程立即可以看到)。

1.1 可见性例子

/** * volatile 可见性例子 * @author 单程车票 */public class VisibilityDemo {
// 构造共享变量 public static boolean flag = true;// public static volatile boolean flag = true; // 如果使用volatile修饰则可以终止循环
public static void main(String[] args) { // 线程1更改flag new Thread(() -> { // 睡眠3秒确保线程2启动 try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) {e.printStackTrace();} // 修改共享变量 flag = false; System.out.println("修改成功,当前flag为true"); }, "one").start();
// 线程2获取更新后的flag终止循环 new Thread(() -> { while (flag) {
} System.out.println("获取到修改后的flag,终止循环"); }, "two").start(); }}
复制代码


  • 不使用 volatile 修饰 flag 变量时,运行程序会进入死循环,也就是说线程 1 对 flag 的修改并没有被线程 2 读到,也就是说这里的 flag 并不具备可见性。

  • 使用 volatile 修饰 flag 变量时,运行程序会终止循环,打印提示语句,说明线程 2 读到了线程 1 修改后的数据,也就是说被 volatile 修饰的变量具备可见性。



1.2 volatile 如何保证可见性?

volatile 修饰的共享变量 flag 被一个线程修改后,JMM(Java 内存模型)会把该线程的 CPU 内存中的共享变量 flag 立即强制刷新回主存中,并且让其他线程的 CPU 内存中的共享变量 flag 缓存失效,这样当其他线程需要访问该共享变量 flag 时,就会从主存获取最新的数据。



所以通过 volatile 修饰的变量可以保证可见性。


两点疑问及解答:


  1. 为什么会有 CPU 内存? 为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1/L2/其他)后再进行操作,但是操作完后的数据不知道何时才会写回主存。所以如果是普通变量(未被修饰的),什么时候被写入主存是不确定的,所以读取的可能还是旧值,因此无法保证可见性。

  2. 各个线程的 CPU 内存是怎么保持一致性的? 实现了缓存一致性协议(MESI),MESI 在硬件上约定了:每个处理器通过嗅探在总线上传播的数据来检查自己的 CPU 内存的值是否过期,当处理器发现自己的缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置为无效状态。当处理器对该数据进行修改操作时,会重新从系统内存(主存)中把数据读到处理器缓存(CPU 内存)里。



1.3 volatile 实现可见性的原理

原理一:Lock 指令(汇编指令)


通过上面的例子的 Class 文件查看汇编指令时,会发现变量有无被 volatile 修饰的区别在于被 volatile 修饰的变量会多一个 lock 前缀的指令


lock 前缀的指令会触发两个事件:


  • 将当前线程的处理器缓存行(CPU 内存的最小存储单元,这里可以大致理解为 CPU 内存)的数据写回到主存(系统内存)中

  • 写回主存的操作会使其他线程的 CPU 内存中该内存地址的数据无效(缓存失效)


所以使用 volatile 修饰的变量在汇编指令中会有 lock 前缀的指令,所以会将处理器缓存的数据写回主存中,同时使其他线程的处理器缓存的数据失效,这样其他线程需要使用数据时,会从主存中读取最新的数据,从而实现可见性。


原理二:内存屏障(CPU 指令)


volatile 的可见性实现除了依靠上述的 LOCK 指令(汇编指令)还依靠内存屏障(CPU 指令)


为了性能优化,JMM 在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序。JMM 提供了内存屏障阻止这种重排序。


这里介绍的是内存屏障中的一类:读写屏障(用于强制读取或刷新主存的数据,保证数据一致性)


  • Store 屏障:当一个线程修改了 volatile 变量的值,它会在修改后插入一个写屏障,告诉处理器在写屏障之前将所有存储在缓存中的数据同步到主内存

  • Load 屏障:当另一个线程读取 volatile 变量的值,它会在读取前插入一个读屏障,告诉处理器在读屏障之后的所有读操作都能获得内存屏障之前的所有写操作的最新结果


对上面的例子使用 javap 查看 JVM 指令时,如果被 volatile 修饰时多一个 ACC_VOLATILE,JVM 把字节码生成机器码时会在相应位置插入内存屏障指令,因此可以通过读写屏障实现 volatile 修饰变量的可见性。


注意读写屏障的特点:可以将所有变量(包括不被 volatile 修饰的变量)一起全部刷入主存,尽管这个特性可以使未被 volatile 修饰的变量也具备所谓的可见性,但是不应该过于依赖这个特性,在编程时,对需要要求可见性的变量应当明确的用 volatile 修饰(当然除了 volatile,synchronized、final 以及各种锁都可以实现可见性,这里不过多说明)。



2. volatile 如何实现有序性?

有序性是什么?


有序性指禁止指令重排序,即保证程序执行代码的顺序与编写程序的顺序一致(程序执行顺序按照代码的先后顺序执行)。


为什么会发生指令重排序?


现代计算机为了能让指令的执行尽可能的同时运行起来,采用指令流水线的方式,若指令之间不具有依赖,可以使流水线的并行最大化,所以 CPU 对无依赖的指令可以乱序执行,这样可以提高流水线的运行效率,在不影响最后结果的情况下,Java 编译器可以通过指令重排序来优化性能


编译器和处理器常常会对指令做重排序,一般分为三种类型:


  • 编译器优化重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  • 指令级并行重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

  • 内存系统重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。



所以指令重排序是指编译器和处理器为了优化程序的性能,在不改变数据依赖性的情况下,调整指令的执行顺序


这种优化在单线程情况下没有问题,但是在多线程情况下可能会导致影响程序结果。接下来将介绍一个多线程下指令重排的例子。



2.1 有序性例子

这里以单例模式的常用实现方式 DLC 双重检查 为例子


/** * volatile 有序性例子 * @author 单程车票 */public class Singleton {
// 使用volatile进行修饰 private static volatile Singleton instance;
// 私有化构造器 private Singleton() {}
// 双重检查锁 public static Singleton getInstance() { if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; }}
复制代码


如果写过单例模式的双重锁检查实现方式,会发现声明的变量被 volatile 修饰,那么为什么这里需要使用 volatile 修饰呢?


第一个原因是可见性,如果没有 volatile 修饰的话,当一个线程给 instance 赋值即 instance = new Singleton();后,其他线程如果无法及时看到 instance 更新,会导致创建多个单例对象,这样就不符合单例模式设计思想了,所以需要使用 volatile 修饰。


第二个原因则是禁止指令重排序(保证有序性),为什么需要禁止指令重排呢?


首先需要了解实例一个对象可以分为三个步骤:


  1. 分配内存空间

  2. 初始化对象

  3. 将对象引用赋值给变量


由于指令可以进行重排序,所以步骤可能发生变化变为


  1. 分配内存空间

  2. 将对象引用赋值给变量

  3. 初始化对象


如果未使用 volatile 修饰变量的话,多线程情况下可能出现这样的情况:


一个线程在执行第二步(将对象引用赋值给变量,即此时变量不为 null )时,而另一个线程进入第一次非空检查,此时发现变量不为 null ,直接返回对象,但是此时的对象由于指令重排序的原因并未进行初始化,即返回了一个未初始化的对象。将一个未初始化的变量暴露出来会导致不可预料的后果。


所以需要 volatile 保证变量有序性,禁止指令重排序。



2.2 volatile 实现有序性的原理

内存屏障的四种指令


内存屏障中禁止指令重排序的内存屏障的四种指令



Java 编译器会在生成指令时在适当位置插入内存屏障来禁止特定类型的处理器重排序


volatile 的插入屏障策略


  • 在每个 volatile 操作的前面插入一个 StoreStore 屏障

  • 在每个 volatile 操作的后面插入一个 StoreLoad 屏障

  • 在每个 volatile 操作的后面插入一个 LoadLoad 屏障

  • 在每个 volatile 操作的后面插入一个 LoadStore 屏障


即在每个 volatile 写操作前后分别插入内存屏障,在每个 volatile 读操作后插入两个内存屏障。



如何通过内存屏障保持有序性?


分析上面的双重检查锁例子:


不加 volatile 修饰时,多线程下可能出现的情况是这样的:



为了避免这种情况,使用 volatile 修饰变量时,会插入内存屏障


// 双重检查锁public static Singleton getInstance() {    if (instance == null){                   // 第一次检查        synchronized (Singleton.class){      // 加锁            if (instance == null){           // 第二次检查                插入 StorStore屏障           // 插入屏障禁止下面的new操作和读取操作重排序                instance = new Singleton();  // 创建对象                插入 LoadLoad屏障            // 插入屏障禁止下面的读取操作和上面的new操作重排序            }        }    }    return instance;}
复制代码


这里使用 volatile 修饰变量并不能避免实例对象的三个步骤重排序,因为 volatile 关键只能避免多个线程之间的重排序,不能避免单个线程内部的重排序。


这里 volatile 保证有序性的作用在于插入屏障之后必须等创建对象完成后才能进行读取操作,也就是说需要线程 1 的创建对象整个步骤完成后才会让线程 2 进行读取,禁止了重排序,这样就避免了返回一个未初始化的对象,保证了有序性。



3. volatile 为什么不能保证原子性?

什么是原子性?


原子性指一个操作或一系列操作是不可分割的,要么全部执行成功,要么全部不执行(中途不可被中断)。


为什么 volatile 不能保证原子性呢?


通过一个例子来证明 volatile 不能保证原子性


/** * 原子性例子 * @author 单程车票 */public class AtomicityDemo {
// 使用volatile修饰变量 public static volatile int i = 0;
public static void main(String[] args) { ExecutorService pool = Executors.newFixedThreadPool(1000);
// 多线程情况下执行1000次 for (int j = 0; j < 1000; j++) { pool.execute(() -> i++); }
// 打印结果 System.out.println(i); pool.shutdown(); }}
/*输出结果: 997*/
复制代码


正常情况下,打印结果应该为 1000,但是这里却是 997,说明这段程序并不是线程安全的,可以看出 volatile 无法保证原子性。


准确来说应该是 volatile 无法保证复合操作的原子性,但能保证单个操作的原子性


这里 volatile 保证单个操作的原子性可以应用于 使用 volatile 修饰共享的 long 或者 double 变量(可以避免字分裂情况,具体想要了解到可以查阅相关资料这里不做过多说明)。


i++ 操作是原子操作吗?


i++ 其实不是原子操作,实际上 i++ 分为三个步骤:


  • 读取 i 的值

  • 将 i 自增 1(i + 1)

  • 写回 i 的新值(i = i + 1)


这三个步骤每一步都是原子操作,但是组合起来就不是原子操作了,在多线程情况下同时执行 i++,会出现数据不一致性的问题。


所以可以证明 volatile 修饰的变量无法保证原子性。


可以通过 AtomicInteger 或者 synchronized 来保证 i++ 的原子性



4. volatile 常见的应用场景?

4.1 状态标志位

使用 volatile 修饰一个变量通过赋值不同的常数或值来标识不同的状态


/** * 可以通过布尔值来控制线程的启动和停止 */public class MyThread extends Thread {
// 状态标志变量 private volatile boolean flag = true;
// 根据状态标志位来执行 public void run() { while (flag) { // do something } } // 根据状态标志位来停止 public void stopThread() { flag = false; // 改变状态标志变量 }}
复制代码



4.2 双重检查 DLC

在多线程编程下,一个对象可能会被多个线程同时访问和修改,而且这个对象可能会被重新创建或者赋值为另一个对象。此时可以通过 volatile 来修饰该变量,保证该变量的可见性和有序性。


就如单例模式的双重检查 DLC 可以通过 volatile 来修饰从存储单例模式对象的变量。


/** * 单例模式的双重检查方式 */public class Singleton {
// 使用volatile进行修饰 private static volatile Singleton instance;
// 私有化构造器 private Singleton() {}
// 双重检查锁 public static Singleton getInstance() { if (instance == null){ synchronized (Singleton.class){ if (instance == null){ instance = new Singleton(); } } } return instance; }}
复制代码



4.3 较低开销的读写锁

使用 volatile 结合 synchronized 实现较低开销的读写锁,由于 volatile 可以保证变量的可见性和有序性,而 synchronized 可以保证变量的原子性和互斥性,可以结合使用实现较低开销的读写锁。


/** * 读写锁实现多线程下的计数器 */public class VolatileSynchronizedCounter {    // volatile变量    private volatile int count = 0;    // synchronized方法    public synchronized void increment() {         count++; // 原子操作    }    public int getCount() {        return count;    }}
复制代码


使用 volatile 修饰变量,synchronized 修饰方法,这样 volatile 修饰变量具有可见性,写操作会被其他线程立刻可见,synchronized 修饰方法保证 count++ 操作的原子性和互斥性,这样实现的读写锁,读操作无锁,写操作有锁,降低了开销

用户头像

还未添加个人签名 2022-09-04 加入

热衷于分享java技术,一起交流学习,探讨技术。 需要Java相关资料的可以+v:xiaoyanya_1

评论

发布
暂无评论
深入理解关键字volatile_Java_小小怪下士_InfoQ写作社区