一、volatile
的作用
通常我们通过synchronized
关键字来解决可见性、有序性以及原子性问题,但是synchronized
是一个比较重量级的操作,对系统的性能有比较大的影响,所以如果有其他解决方案的话,我们通常会避免使用synchronized
来解决问题。
仅仅为了读写一个或两个实例域就使用同步,显得开销过大了。
- 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
- 编译器可以改变指令的执行顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值可以被另一个线程改变。
而volatile
关键字就是java中提供的另外一种解决可见性和有序性问题的方案,这里需要注意的是:**对volatile
变量的单次读/写操作可以保证原子性的,如long和double类型的变量,但是并不能保证i++
这种操作的原子性,因为它本质上是读写两次操作。
volatile
的机制:编译器被要求同过在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当的重新排序指令。
二、volatile
的作用
防止重排序
在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现,源码如下:
package com.paddx.test.concurrent;
public class Singleton {
public static volatile Singleton singleton;
/**
* 构造函数私有,禁止外部实例化
*/
private Singleton() {};
public static Singleton getInstance() {
if (singleton == null) {
synchronized (singleton) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
接下来我们分析一下为什么要在变量singleton
之前加上volatile
关键字。首先要了解对象的构造过程,实例化一个对象其实可分为三个步骤:
- 分配内存空间
- 初始化对象
- 将内存空间的地址赋值给对应的引用
但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下的操作:
- 分配内存空间
- 将内存空间的地址赋值给对应的引用
- 初始化对象
如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果,因此,为了防止这个过程的重排序,我们需要将变量设置为volatile
类型的变量。
实现可见性
可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓冲区–线程工作内存。volatile
关键字能够有效的解决这个问题,举例如下:
package com.paddx.test.concurrent;
public class VolatileTest {
int a = 1;
int b = 2;
public void change(){
a = 3;
b = a;
}
public void print(){
System.out.println("b="+b+";a="+a);
}
public static void main(String[] args) {
while (true){
final VolatileTest test = new VolatileTest();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.change();
}
}).start();
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test.print();
}
}).start();
}
}
}
直观上说,这段代码的结果只可能有两种情况:b=3,a=3
或b=2,a=1
。但是运行上面的代码,会发现还会出现第三种结果:b=3,a=1
。
出现这种情况的原因是:第一个线程将值a=3
修改后,对第二个线程是不可见的,所以才会出现这一结果。如果将a
和b
都改成volatile
类型的变量再执行,就不会出现b=3,a=1
的情况了。
保证原子性
volatile
只保证对单次读/写的原子性。普通的long或double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型的读写可能不是原子的。因此,鼓励大家将long
和double
变量设置为volatile
类型,这样能保证任何情况下对long
和double
的单次读写操作都具有原子性。
注意问题:
package com.paddx.test.concurrent;
public class VolatileTest01 {
volatile int i;
public void addI(){
i++;
}
public static void main(String[] args) throws InterruptedException {
final VolatileTest01 test01 = new VolatileTest01();
for (int n = 0; n < 1000; n++) {
new Thread(new Runnable() {
@Override
public void run() {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
}
test01.addI();
}
}).start();
}
Thread.sleep(10000);//等待10秒,保证上面程序执行完成
System.out.println(test01.i);
}
}
上述代码的运行结果为:
可以看出volatile
是无法保证原子性的,是因为**i++
是一个复合操作**,包括三步:
(1)读取i
的值
(2)对i
加1
(3)将i
的值写回内存
volatile
是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger
或者Synchronized
来保证加1操作的原子性。
三. volatile
的原理
可见性的实现
线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作,这也是导致线程间数据不可见的本质原因,因此要实现volatile
变量的可见性,直接在这方面入手即可,对volatile
变量的写操作与普通变量的主要区别有两点:
- 修改
volatile
变量时会强制将修改后的值刷新到主内存中 - 修改
volatile
变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从主内存中读取。
通过这两个操作,就可以解决volatile
变量的可见性问题。
有序性的实现
java中的happen-before
定义:
Two actions can be ordered by a happens-before relationship.If one action happens before another, then the first is visible to and ordered before the second.
大概意思就是如果a happen-before b
,则a
所做的任何操作对b
是可见的。
happen-before
的规则定义:
- 同一个线程中,前面的操作
happen-before
后续的操作(即单线程内按代码顺序执行,但是在不影响单线程环境执行结果的前提下,编译器和处理器可以进行重排序,这是合法的)。 - 监视器上的解锁操作
happen-before
其后续的加锁操作。(synchronized
规则) - 对
volatile
变量的写操作happen-before
后续的读操作(volatile
规则) - 线程的
start()
方法happen-before
该线程所有的后续所有操作(线程启动规则) - 线程所有的操作
happen-before
其他线程在该线程上调用join()
返回成功后的操作 - 如果
a happen-before b, b happen-before c
,则a happen-before c
。(传递性)
内存屏障
为了实现volatile
可见性和happen-before
的语义,JVM底层是通过一个叫做内存屏障的东西来完成。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制。
-
LoadLoad
屏障
执行顺序:Load1->LoadLoad->Load2
确保Load2
及后续Load
指令加载数据之前能访问到Load1
加载的数据 -
StoreStore
屏障
执行顺序:Store1->StoreStore->Store2
确保Store2
以及后续store
执行执行前,store1
操作的数据对其他处理器可见。 -
LoadStore
屏障
执行顺序:Load1->LoadStore->Store2
确保Store2
和后续store
指令执行前,可以访问到Load1
加载的数据 -
StoreLoad
屏障
执行顺序:Store1->StoreLoad->Load2
确保Load2
和后续的Load
指令读取之前,Store1
的数据对其他处理器是可见的。
四. 总结
volatile
是并发编程中的一种优化,在某些场景下可以代替synchronized
。但是 volatile
是不能完全取代synchronized
的位置,只有在一些特殊的场景下,才能适用 volatile
。总的来说,必须同时满足下面两个条件才能保证在并发环境下的线程安全:
- 对变量的写操作不依赖于当前值
- 该变量没有包含在具有其它变量的不变式中
感谢并参考: