@
1. 原理
Java 语言提供了一种稍弱的同步机制,即 volatile 关键字,该关键字可以保证修饰的变量更新操作能够通知到其他线程,并且保证变量执行前后的顺序执行,即能够解决《01-可见性、原子性和有序性问题:并发编程 Bug 的源头》中提到的并发编程 Bug 源头的两个因素:可见行和有序性。
1.1 保证有序性原理
JMM 通过插入内存屏障指令来禁止特定类型的重排序。java 编译器在生成字节码时,在 volatile 变量操作前后的指令序列中插入内存屏障来禁止特定类型的重排序。
volatile 内存屏障插入策略:
-
在每个 volatile 写操作的前面插入一个 StoreStore 屏障; -
在每个 volatile 写操作的后面插入一个 StoreLoad 屏障; -
在每个 volatile 读操作的后面插入一个 LoadLoad 屏障; -
在每个 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 前缀的指令在多核处理器下会引发了两件事:
-
将当前处理器缓存行的数据写回到系统内存; -
这个写回内存的操作会使在其他 CPU 里缓存了该内存地址的数据无效。
volatile 内存可见的写-读过程:
-
对 volatile 修饰的变量进行写操作; -
由于编译期间 JMM 插入一个 StoreLoad 内存屏障,JVM 就会向处理器发送一条 Lock 前缀的指令; -
Lock 前缀的指令将该变量所在缓存行的数据写回到主内存中,并使其他处理器中缓存了该变量内存地址的数据失效; -
当其他线程读取 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 机制。