并发编程之 volatile 关键字 - 解决可见行和有序性

@

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 适合这种场景:一个变量被多个线程共 享,线程直接给这个变量赋值。

在这里插入图片描述   当对非 volatile 变量进行读写的时候,每个线程先从内存拷贝变量到 CPU 缓存中。如果计算机有多个 CPU,每个线程可能在不同的 CPU 上被处理,这意味着每个线程可以拷贝到不同的 CPU cache 中。而声明变量是 volatile 的,JVM 保证了每次读变量都从内存中读,跳过 CPU cache 这一步。

  

3. 总结

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

猜你喜欢

转载自juejin.im/post/5ef37bace51d4534bb148b62