12 - volatile 解决可见行和有序性

volatile 解决可见行和有序性

      • 1. 原理
        • 1.1 保证有序性原理
        • 1.2 保证可见行原理
        • 1.3 使用案例
      • 2. 使用场景
      • 3. 总结

1. 原理

  Java 语言提供了一种稍弱的同步机制,即 volatile 关键字,该关键字可以保证修饰的变量更新操作能够通知到其他线程,并且保证变量执行前后的顺序执行,即能够解决《01-可见性、原子性和有序性问题:并发编程 Bug 的源头》中提到的并发编程 Bug 源头的两个因素:可见行和有序性。

1.1 保证有序性原理

  JMM 通过插入内存屏障指令来禁止特定类型的重排序。java 编译器在生成字节码时,在 volatile 变量操作前后的指令序列中插入内存屏障来禁止特定类型的重排序。
  

  volatile内存屏障插入策略:

  1. 在每个 volatile 写操作的前面插入一个 StoreStore 屏障;
  2. 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障;
  3. 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障;
  4. 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
屏障类型 指令示例 说明
LoadLoadBarriers Load1; LoadLoad; Load2 确保 Load1 数据的装载,之前于 Load2 及所有后续装载指令的装载
StoreStoreBarriers Store1; StoreStore; Store2 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Store2 及所有后续存储指令的存储
LoadStoreBarriers Load1; LoadStore; Load2 确保 Load1 数据的装载,之前于 Store2 及所有后续存储指令刷新到内存
StoreLoadBarriers Store1; StoreLoad; Load2 确保 Store1 数据对其他处理器可见(刷新到内存),之前于 Load2 及所有后续装载指令的装载。StoreLoadBarriers 会使该屏障之前所有内存访问指令(存储和装载)完成后,才执行该屏障之后的内存访问指令

Store:数据对其他处理器可见,刷新到主内存中。
Load:让缓存中的数据失效,重新从主内存中加载数据。

  

1.2 保证可见行原理

  volatile 内存屏障插入策略中有一条,“在每个 volatile 写操作的后面插入一个StoreLoad 屏障”。StoreLoad 屏障会生成一个 Lock 前缀的指令,Lock 前缀的指令在多核处理器下会引发了两件事:

  1. 将当前处理器缓存行的数据写回到系统内存;
  2. 这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。

  volatile 内存可见的写-读过程

  1. 对 volatile 修饰的变量进行写操作;
  2. 由于编译期间 JMM 插入一个 StoreLoad 内存屏障,JVM 就会向处理器发送一条Lock 前缀的指令;
  3. Lock前缀的指令将该变量所在缓存行的数据写回到主内存中,并使其他处理器中缓存了该变量内存地址的数据失效;
  4. 当其他线程读取volatile修饰的变量时,本地内存中的缓存失效,就会到到主内存中读取最新的数据。
1.3 使用案例

  我们在实现单例模式的时候,一个经典的写法就是双重检索实现,关于单例模式可以参考《设计模式六之单例模式》。在这个实现里,我们使用了 volatile 关键字修饰单例实例对象,我们想一想我们要如此呢?

/**
 * 懒加载双重检查单例
 */
public class LazyDoubleCheckSingleton implements Serializable  {

    /**
     * 静态私有实例且用volatile修饰保证可见性
     */
    private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null; 1

    private LazyDoubleCheckSingleton() {

    }

    /**
     * 创建或获取静态私有实例的公有静态函数
     * @return
     */
    public static LazyDoubleCheckSingleton getInstance() {
        if (lazyDoubleCheckSingleton == null) {
            synchronized (LazyDoubleCheckSingleton.class) {
                if (lazyDoubleCheckSingleton == null) {
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton(); 2
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }

    /**
     * 防止序列化和反序列化破坏单例,单例类必须实现 Serializable 序列化接口
     * @return
     */
    public Object readResolve() {
        return instance;
    }
}

  其实使用 volatile 修饰实例变量是为了防止重排序,保证可见性,代码23行处共执行3条命令:1 分配对象的内存空间;2 初始化对象;3 设置变量指向刚刚分配的内存地址。但是在步骤2 和步骤3 之间顺序不固定,有时候步骤2 先执行,有时候步骤3 先执行,因此如果线程1先执行步骤3就释放锁,线程2 判断 instance != null 后直接返回的就是空对象,因此需要使用 volatile 防止重排序,保证可见性。
  

2. 使用场景

  在访问 volatile 变量时不会执行加锁操作,因此也就不会使执行线程阻塞,因此 volatile 变量是一种比 sychronized 关键字更轻量级的同步机制。volatile 适合这种场景:一个变量被多个线程共 享,线程直接给这个变量赋值。

12 - volatile 解决可见行和有序性_第1张图片
  当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。
  

3. 总结

  并发编程中,常用 volatile 关键字修饰变量已保证变量的修改对其他线程可见。volatile 可以通过插入内存屏障保证可见性和有序性,但是不能保证原子性,想要保证原子性必须通过锁机制或 CAS 机制。

你可能感兴趣的:(并发编程,并发编程,多线程,volatile)