简单理解volatile(一)
文章目录
背景
下面是我近期学习的一些总结和理解,如果有什么遗漏或者错误,希望大家指出,一起学习呀
简介
volatile是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)和 volatile 变量,相比于synchronized,volatile更轻量级,因为它不会引起线程上下文的切换和调度。但是volatile 变量的同步性较差(有时它更简单并且开销更低),但是在性能上由于虚拟机对synchronized关键字或者juc包里面的锁实行了许多消除和优化,很难确切地说volatile会比synchronized快上多少,而且由于volatile不容易被正确完整理解,以至于许多程序员都习惯去避免使用它。
volatile变量特性
先来看看volatile的特性,经过volatile修饰的变量有两个特性
1.保证可见性,不保证原子性
1)当写一个volatile变量时,JMM会把该线程本地内存中的变量强制刷新到主内存中去。
2)这个写会操作会导致其他线程中的volatile变量缓存无效。
2.禁止指令重排
指令重排序是指编译器或CPU为了优化程序的执行性能而对指令进行重新排序的一种手段,重排序会带来可见性问题,所以在多线程开发中必须要关注并规避重排序。 重排序需要遵守以下的规则:
(1)重排序操作不会对存在数据依赖关系的操作进行重排序。
比如:a=1;b=a 这两个指令,第二个指令对第一个指令存在数据依赖,所以编译和处理时不会把第二个指令与第一个指令调换位置,即不会被重排序。
(2)保证单线程下程序的执行结果不能被改变。
比如:a=1;b=2;c=a+b这三个指令,第三个指令依赖于第一个和第二个指令,但是第一个和第二个指令不存在数据依赖,故在单线程环境下,有可能被重排序为b=2;a=1;c=a+b。
这时就引发了一个问题,在多线程环境下,有两个线程A与B,
初始化两个变量
int a = 1;
boolean status = false;
A线程执行操作如下:
a = 2; //1
status = true; //2
B线程执行操作如下:
if(status){
int b = a + 1; //3
System.out.println(b);
}
1和2由于不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达3处,而线程A中a=2这个操作还未被执行,所以b=a+1的结果也有可能依然等于2, 由此引发了程序输出偏差,显然是不合理的。
volatile的原理和实现机制
那么volatile是如何实现以上两个特性的呢?
有volatile修饰的共享变量进行写操作的时候会多出有“lock”标志的汇编代码, Lock前缀的指令在多核处理器下会引发两件事情:
1)将当前处理器缓存行中的数据写回到系统内存中
2)这个写回内存的操作会使在其他cpu里缓存了该内存地址的数据无效。
这里引用一下周志明老师的《深入理解Java虚拟机》第12章的例子
给出一段标准的双锁检测(Double Check Lock,DCL)单例代码
可以看到,相对于没有volatile修饰的变量,赋值操作(mov %eax,0x150(%esi) 这句指令)会多执行一个"lock add1 $0x0,(%esp)"操作,该指令的作用相当于一个内存屏障(重排序不能把内存屏障后面的指令重排序到该屏障前面)。
"add1 $0x0,(%esp)"操作是把ESP寄存器的值加0,显然是个空操作,关键在于前面的Lock指令,将本处理器的缓存写入内存中,引起别的处理器无效化其缓存。这就保证了volatile修饰的变量对其他线程的可见性。
可是,关于volatile变量的可见性,经常有开发人员误解:①volatile变量对所有线程是立即可见的,对volatile变量所有的写操作都能立刻反映到其他线程中,②所以基于volatile变量的运算在并发的线程下是安全的。
这句话①部分并没有错,但是并不能由①推出②。比如:java中的运算操作符并非原子操作。
如:定义一个volatile变量a,对a在多线程环境下执行a++操作
public class VTest extends Thread{
public static volatile int a=0;
@Override
public void run() {
a++;
}
public static void main(String[] args) {
VTest array[]=new VTest[10000];
for (int i = 0; i < array.length; i++) {
array[i]=new VTest();
array[i].start();
}
System.out.println(VTest.a);
}
}
预期结果:10000
真实结果:9998
造成这样的原因在于自增运算"a++"是非原子操作的,我们来看下面的例子。
public class aaa{
public static volatile int a = 0;
public static void main(String[] args) {
increase();
}
public static void increase(){
a++;
}
}
以上代码我们使用Javap反编译,其中increase()方法反编译结果如下
public static void increase();
Code:
0: getstatic #3 // Field a:I
3: iconst_1
4: iadd
5: putstatic #3 // Field a:I
8: return
可以发现a++操作是由4条字节码构成的(return不是a++产生),当getstatic指令把a的值取到操作栈顶时,volatile保证了该值的正确,但是在后续指令中,其他线程有可能会把a的值改变,所以当执行到putstatic指令时,把数据写回主存中,就会导致主存中的值偏小。
volatile内存语义实现
为了了解volatile如何禁止重排序,现在我们回过头来看volatile写-读的内存语义:
- 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。
- 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
重排序分为编译器重排序和处理器重排序,为了实现volatile内存语义,JMM分别对这两种重排序进行了规定如下:
是否能重排序 | 第二个操作 | 第二个操作 | 第二个操作 |
---|---|---|---|
第一个操作 | 普通读/写 | volatile读 | volatile写 |
普通读/写 | NO | ||
volatile读 | NO | NO | NO |
volatile写 | NO | NO |
总结下来就是:
- 当第二个操作为volatile写时,不管第一个操作是什么(普通读写或者volatile读写),都不能进行重排序。这个规则确保volatile写之前的所有操作都不会被重排序到volatile写之后;
- 当第一个操作为volatile读时,不管第二个操作是什么,都不能进行重排序。这个规则确保volatile读之后的所有操作都不会被重排序到volatile读之前;
- 当第一个操作是volatile写时,第二个操作是volatile读操作,不能进行重排序。
为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。下面是基于保守策略的 JMM 内存屏障插入策略:
- 在每个 volatile 写操作的前面插入一个 StoreStore 屏障(禁止前面的普通写与volatile写重排序)
- 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障(禁止volatile写与后面可能有的volatile读和写重排序)
- 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障(禁止屏障后面的普通读和屏障前面的volatile读重排序)
- 在每个 volatile 读操作的后面插入一个 LoadStore 屏障(禁止屏障后面的普通写和屏障前面的volatile读重排序)
这里尤其注意StoreLoad屏障, 是确保可见性的关键,它会将屏障之前的写缓冲区中的数据全部刷新到主内存中 同时,该屏障开销也很昂贵, 部分原因是它们必须关闭通常的略过缓存直接从写缓冲区读取数据的机制。这可能通过让一个缓冲区进行充分刷新(flush),以及其他延迟的方式来实现。
上面提到的内存屏障,不同硬件实现它的方式不同,如X86处理器平台除了StoreLoad屏障外,其他屏障都会被省略,JMM屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。
volatile使用场景
先来看看书上是怎么说的:
由于volatile变量只能保证可见性,不符合以下两条规则的场景中,仍要通过加锁来保证原子性:
- 运算结果不依赖于变量的当前值,或者能确保单线程修改变量值
- 变量不需要与其他的状态变量共同参与不变约束
第一点很好理解,第二点就有些晦涩,我举从其他博客上看见的例子来解释。
private Date start;
private Date end;
public void setInterval(Date newStart, Date newEnd) {
// 检查start<end是否成立, 在给start赋值之前不变式是有效的
start = newStart;
// 但是如果另外的线程在给start赋值之后给end赋值之前时检查start<end, 该不变式是无效的
end = newEnd;
// 给end赋值之后start<end不变式重新变为有效
}
总结
volatile主要作用 是能保证可见性和防止指令重排序 ,却不能保证原子性,是一种弱同步机制,如果volatile使用恰当,会获得比 synchronized 更低的执行成本,因为它不会引起线程上下文的切换和调度。尽管volatile是在 synchronized性能低下的时候提出的 ,现在synchronized经过不断优化,效率已经大幅提升,很难说volatile会比synchronized快多少,但学习volatile的意义更在于理解java内存模型(JMM)。
却不能保证原子性,是一种弱同步机制,如果volatile使用恰当,会获得比 synchronized 更低的执行成本,因为它不会引起线程上下文的切换和调度。尽管volatile是在 synchronized性能低下的时候提出的 ,现在synchronized经过不断优化,效率已经大幅提升,很难说volatile会比synchronized快多少,但学习volatile的意义更在于理解java内存模型(JMM)。