关于volatile:深入理解关键字volatile

33次阅读

共计 6565 个字符,预计需要花费 17 分钟才能阅读完成。

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++ 操作的原子性和互斥性,这样实现的读写锁,读操作无锁,写操作有锁,升高了开销

正文完
 0