在多线程编程中,Synchronized 和 volatile 都扮演者重要的角色,前面的文章我们已经了解了java内置锁Synchronized ,它保证了并发过程中的可见性与原子性,避免了共享数据的错误。
而 Volatile可以看做是轻量级的 Synchronized,它只保证了共享变量的可见性。在线程 A 修改了被 volatile 修饰的共享变量之后,线程 B 能够读取到正确的值。在 关于JMM 的文章中我们了解到 java 在多线程中操作共享变量的过程中,会存在指令重排序与共享变量工作内存缓存的问题。
volatile作为一个修饰符,使用很简单,但是它背后做了多少工作呢?
首先我们需要明白,本地内存是一个抽象概念,包括缓存、读写缓冲区、寄存器,甚至编译器重排序和cpu重排序。JVM按照JMM规范对volatile进行特殊处理,从而实现在CPU对该变量的特殊处理。
volatile底层原理
计算机系统中,硬盘负责存储数据, 但是数据交换速度慢,CPU 运行速度非常快,CPU直接硬盘的数据交换效率非常低,于是产生了内存,通过内存与 CPU 进行数据交换,但是内存的速度依旧不够快,严重拖慢整体的运行效率,故而在 CPU 内部添加了高速缓存,作为 CPU的临时存储器,与内存的数据交互。
- 在单核CPU中,多线程都在一个CPU中进行运行,共用一份缓存,对同一个共享变量的使用,而不会出现数据可见性的问题
- 而多核CPU由于多线程可能分配在不同的CPU,这种情况下进行计算时,就会出现一个CPU内核计算完成,并没有同步回主内存,而其他CPU无法使用最新的数据,而出现了可见性问题。
通过添加volatile修饰,通过JVM的优化,最后反应到CPU上,先从内存获取数据,存储在高速缓存中,然后再从高速缓存中获取数据进行计算,计算完成后的值并不会立即刷新回主内存中,而其他 CPU 这时并不知道变量值已经改变,使用的还是之前的变量值进行计算,这就产生了数据错误。这种机制类似我们之前讲过的 JMM 中主内存于工作内存的关系。
我们知道,javac 编译器将 .java 代码编译成为 .class 字节码,JVM 通过解释器与即时编译器(JIT)运行字节码中的指令,将字节码指令翻译称为具体的机器码指令,而被 volatile 修饰的共享变量,在翻译成为机器码的过程中为其赋值操作
添加特殊机器码指令前缀Lock xxxx
。
public class Test{
private volatile int i=1;//被 volatile 修饰
//线程A修改
public void setVar(){
i=2;
}
//线程B获取
public int getVar(){
return i;
}
}
在执行此条指令时,Lock 指令有两个作用:
- 使本CPU的缓存写入内存
- 上面的写入动作也会引起别的CPU或别的内核中的缓存无效,
所以通过这样一个指令前缀,可以让对volatile变量的修改对其他CPU可见。
指令重排序
还是上文中的Lock前缀的作用,为什么它能禁止指令重排序呢?
从JMM角度讲:
在JMM的逻辑实现中,当操作一个变量 执行putfield
指令(为变量赋值) 时,JVM会检查此变量是否是被volatile修饰的,如果是的话,JVM会为该变量添加内存屏障,用于隔离该变量与前后操作,从而禁止volatile变量的操作与前后操作的乱序。
摘自java并发编程的艺术:
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来
禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总
数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
·在每个volatile写操作的前面插入一个StoreStore屏障。
·在每个volatile写操作的后面插入一个StoreLoad屏障。
·在每个volatile读操作的后面插入一个LoadLoad屏障。
·在每个volatile读操作的后面插入一个LoadStore屏障。
从CPU执行角度讲:
以上的内存屏障就会在执行时生成相应带有Lock
前缀的机器码(全面已提及)。在CPU中,程序的执行计算是由CPU在不影响逻辑结果的前提下分配给不同的电路去处理逻辑,Lock指令前缀刷新回内存,必然是在此指令之前的运算全部计算完成之后,取得正确的结果才会刷新回内存的,所以这也形成了一道内存屏障,表示对该变量操作之前的操作不会乱序到其后,其后的操作不会乱序到之前。
综上述,volatile的实现就是一个Lock
指令前缀的作用。
使用注意事项
volatile虽然保证了可见性,但是它不保证原子性。
诸如i++
之类的语句,在执行时的步骤:
- 从内存取值,放到CPU缓存中
- CPU中i+1
- 存在缓存中
- 刷新会内存
可见这这并不是单纯的赋值操作,而是有在第4步完成之前,其他CPU内核是看不到数值变化的,而如果仅用volatile修饰的话,仅仅保证了第3部完成之后,会立即刷新回内存,但不会保证第2步计算与第3,4步的原子性。如果线程A计算+1之后,没有刷回内存,线程B也+1,那么最后的结果肯定是比期望的结果小的。所以在多线程操作++
时,还是应该使用synchronized
等同步操作保证原子性。
volatile比synchronized轻量,只保证可见性。正因如此,在java.util.concurrent
中AQS使用了被volatile修饰的变量来标记状态,实现了灵活多样的各种锁,补充了内置锁synchronized的互斥等缺点。