深入理解volatile底层原理
volatile 是轻量级的 synchronized,一般作用于变量。相比于synchronized
关键字,volatile
关键字的执行成本更低,因为它不会引起线程上下文的切换和调度。
volatile主要有以下两个功能:
- 保证共享变量的内存可见性(即当一个线程修改一个共享变量时,另一个线程能读到这个修改的值)。
- 禁止指令重排序
volatile 的用途:
从volatile的内存语义上来看,volatile可以保证内存可见性且禁止重排序。
在保证内存可见性这一点上,volatile有着与锁相同的内存语义,所以可以作为一个“轻量级”的锁来使用。但由于volatile仅仅保证对单个volatile变量的读/写具有原子性,而锁可以保证整个临界区代码的执行具有原子性。所以在功能上,锁比volatile更强大;在性能上,volatile更有优势。
volatile的特性
volatile
可以保证可见性和有序性。
可见性:
volatile
可以保证不同线程对共享变量进行操作时的可见性。即当一个线程修改了共享变量时,另一个线程可以读取到共享变量被修改后的值。有序性:
volatile
会通过禁止指令重排序进而保证有序性。原子性:对于单个的
volatile
修饰的变量的读/写是可以保证原子性的,但对于v++
这种复合操作并不能保证原子性。所以说volatile
不具备原子性。为什么说 volatile 不保证原子性?
Java中只有对基本类型变量的赋值和读取是原子操作,如 i = 1的赋值操作,但是像 j = i 或者 i++ 这样的操作都不是原子操作,因为他们都进行了多次原子操作,比如先读取 i 的值,再将 i 的值赋值给 j,两个原子操作加起来就不是原子操作了。
比如 i++ ,i = i + 1 分为三个操作
- 读取 i 的值
- 自增 i 的值
- 把 i 的值写回内存
这个过程中可能线程A读取了 i 的值,然后线程切换,即使是被volatile修饰,主存中变量的值也还没变化,所以另一个线程B也读取了 i 未被修改的值,之后线程切换回A,进行 + 1 操作,写回内存;而线程B写回内存会把线程A修改的值覆盖。
所以即便是volatile具有可见性,也不能保证对它修饰的变量具有原子性。
可以通过 synchronized和Lock实现原子性。因为synchronized和Lock能够保证任一时刻只有一个线程访问该代码块。
volatile实现内存可见性原理
volatile
可以保证内存可见性的关键是volatile
的读/写实现了缓存一致性。
缓存一致性协议 MESI 的主要内容为:
多个CPU从主内存读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里的数据,该数据会马上同步会主内存,其他CPU通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效,然后重新从主内存读取最新数据。
那 volatile 是如何实现缓存一致性的呢?可以发现通过volatile
修饰的变量,生成汇编指令时会比普通的变量多出一个 Lock
前缀指令,这个Lock
指令就是volatile
关键字可以保证内存可见性的关键,它主要有两个作用:
- 将当前处理器缓存的数据刷新到主内存。
- 刷新到主内存时会使得其他处理器缓存的该内存地址的数据无效。
volatile实现有序性原理
重排序可以提高代码的执行效率,但在多线程程序中可能导致程序的运行结果不正确,那
volatile
是如何解决这一问题的呢?
为了实现volatile
的内存语义,编译器在生成字节码时会通过插入内存屏障来禁止指令重排序。
内存屏障:内存屏障是一种CPU指令,它的作用是对该指令前和指令后的一些操作产生一定的约束,保证一些操作按顺序执行。
volatile 内存语义的实现
Java内存模型对编译器指定的volatile
重排序规则为:
- 当第一个操作是
volatile
读时,无论第二个操作是什么都不能进行重排序。 - 当第二个操作是
volatile
写时,无论第一个操作是什么都不能进行重排序。 - 当第一个操作是
volatile
写,第二个操作为volatile
读时,不能进行重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。编译器选择了一个比较保守的 JMM 内存屏障插入策略,这样可以保证在任何处理器平台,任何程序中都能得到正确的volatile内存语义。这个策略是:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。
什么是内存屏障
JVM是怎么限制处理器的重排序的呢?它是通过内存屏障来实现的。
什么是内存屏障?硬件层面,内存屏障分两种:读屏障(Load Barrier)和写屏障(Store Barrier)。
内存屏障有两个作用:
- 阻止屏障两侧的指令重排序;
- 强制把写缓冲区/高速缓存中的脏数据等写回主内存,或者让缓存中相应的数据失效。
注意这里的缓存主要指的是CPU缓存,如L1,L2等
JMM 把内存屏障指令分为4类,如下表所示:
屏障类型 | 指令示例 | 说明 |
---|---|---|
LoadLoad Barriers | Load1;LoadLoad;Load2 | 保证Load1数据的读取先于Load2及后续所有读取指令的执行 |
StoreStore Barriers | Store1;StoreStore;Store2 | 保证Store1数据刷新到主内存先于Store2及后续所有存储指令 |
LoadStore Barriers | Load1;LoadStore;Store2 | 保证Load1数据的读取先于Store2及后续的所有存储指令刷新到主内存 |
StoreLoad Barriers | Store1;StoreLoad;Load2 | 保证Store1数据刷新到主内存先于Load2及后续所有读取指令的执行。StoreLoad Barriers同时具备其他三个屏障的作用,它会使得该屏障之前的所有内存访问指令完成之后,才会执行该屏障之后的内存访问命令。 |
StoreLoad Barriers 的开销是四种屏障中最大的,因为处理器通常要把写缓冲区中的数据全部刷新到内存中。这个屏障是个万能屏障,兼具其它三种内存屏障的功能
双重检查锁下的重排序问题
1 | /** |
为什么使用两次 if 检查?
假如有两个线程,线程A在进入到 synchronized 同步代码块之后,在还没有生成 Singleton 对象前发生线程切换,此时线程B判断 instance == null 为 true,然后获取不到锁阻塞发生线程切换,切换到线程A,线程A将变量初始化后,退出同步代码块,线程切换,线程B进入同步代码块后,会再判断一下 instance 的值,否则会再次进行new 初始化,这就是双重检查锁的必要所在。
为什么使用volatile
?
采用 volatile 关键字修饰也是很有必要的,singleton = new Singleton(),new
对象时并不是一个完整的原子性操作,而是分为三步执行:
- 为 singleton 分配内存空间
- 执行构造方法,初始化 singleton
- 将 singleton 指向分配的内存地址
但是由于 JVM 具有指令重排的特性,执行顺序有可能变成 1-3-2。指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。例如,线程 T1 执行了 1 和 3,此时线程 T2 调用 getInstance() 后发现 singleton 不为空,因此返回 singleton ,但此时 singleton 还未被初始化,就会导致线程 T2 使用了未初始化的对象。
使用 volatile 可以禁止 JVM 的指令重排,保证在多线程环境下也能正常运行。
参考资料