被volatile关键字修饰的变量的特性:
- 保证了不同线程对该变量操作的可见性
- 禁止指令重排序(有序性)
有关volatile,就不得不提及并发编程中的三个特性及其与volatile的关系:原子性、可见性、有序性
可见性
可见性:确保多个线程在读取变量时总能获取到变量的最新值。
为何可见性会是有关并发编程中的一个问题?这涉及到Java内存模型(JMM)
JMM图解
线程会将主内存中的变量拷贝到自己的工作内容中,在对工作内存中的变量进行操作后,会将变量再写回主内存中。
各个工作内存对应于CPU各个核心的缓存(通常每个核心的L1与L2缓存是各核心独立拥有,L3缓存则是所有核心共享的),主内存对应于内存。
注意:线程在自己的工作内存中修改变量后,并不一定会立即将改动更新到主内存中。这就引起了可见性问题。如果更新不及时,其他线程将不会获知变量的新改动,而是读取主内存中的旧值。
每个线程只能访问自己的工作内存,本线程的工作线程对于其他线程是不可见的。
Java定义了8种与主内存、工作内存交互相关的原子操作:
- lock 将对象变成线程独占的状态
- unlock 将线程独占状态的对象的锁释放出来
- read 从主内存读数据
- load 将从主内存读取的数据写入工作内存
- use 工作内存使用对象
- assign 对工作内存中的对象进行赋值
- store 将工作内存中的对象传送到主内存当中
- write 将对象写入主内存当中,并覆盖旧值
其中read、load必须成对出现,store、write必须成对出现。
volatile变量的可见性
两个线程对一个volatile变量的读写过程
对volatile变量的内存操作控制包括:
- 在本线程use之前,不允许其他线程对该变量进行read load操作;
- 在本线程assign之后,必须紧接着连续进行store write操作。
这相当于将线程的read-load-use操作绑定为一个原子操作,将assign-store-write绑定为一个原子操作。
注意:volatile的可见性仅保证了主内存中的数据一致性,不保证多个线程的工作内存之间的数据一致性。如果线程2在线程1use操作后assign操作前读取了主内存中的volatile变量(这一操作不违反volatile的内存操作控制),那么在线程1assign操作后,线程1与线程2工作内存中的volatile变量存在数据不一致。
有序性
volatile变量的有序性
编译器为了提高程序的执行效率有时会进行指令重排,改变部分代码的执行顺序。
volatile变量的有序性在于:
- 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见(结果已经被刷新到主内存中),在其后面的操作肯定还没有进行;
- 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
有序性通过限制指令重排实现,具体由Java编译器放置的内存屏障(Memory Barrier)实现。
内存屏障的功能:
- 重排序时不能把后面的指令重排序到内存屏障之前的位置;
- 使得本CPU核心的Cache写入内存 ;
- 写入动作也会引起别的CPU核心无效其Cache,相当于让新写入的值对别的线程可见。
内存屏障包括:
- **LoadLoad屏障:**对于这样的语句 Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
- **StoreStore屏障:**对于这样的语句 Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
- **LoadStore屏障:**对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
- **StoreLoad屏障:**对于这样的语句 Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。
原子性
volatile变量缺失的原子性
volatile变量及对其的读写操作具有可见性、有序性。但volatile变量依然只有在某些情况下才是线程安全的,因为其不具备原子性。
public class Test {
public volatile int inc = 0;
public void increase() {
inc++;
}
public static void main(String[] args) throws InterruptedException {
final Test test = new Test();
for(int i=0;i<10;i++){
new Thread(){
public void run() {
for(int j=0;j<1000;j++)
test.increase();
};
}.start();
}
Thread.sleep(3000);
System.out.println(test.inc);
}
在此例中多线程的运行结果不会与预期一致。
一个具体的情形:线程1从主内存读取到 i==10 后拥塞(此时线程1还未进入assign阶段),由线程2继续执行。线程2从主内存中读取到 i==10 后完成自增,并根据volatile的性质,立即刷新入主内存。此时主内存中 i == 11。然后线程2恢复,继续自增操作、将 i == 11写入主内存。2个线程分别进行了1次自增操作,但主内存中的volatile变量值却只增加了1。
问题在于volatile变量在一方线程仅写入,一方线程仅读取的情况下具有原子性(因为volatile变量的read-load-use和assign-store-write都是原子操作)。但对于多个线程都需要读取+写入的情况下,无法保证这种复合操作的原子性,如例子中 [线程1读取-线程2读取-线程2写入-线程1写入] 的情况。
注意:线程2将11写回主存,不会把线程A的缓存行设为无效吗?但是线程1的读操作已经完成,只有在做读操作时,发现自己缓存行无效,才会去读主存的值,所以这里线程1继续做自增了。这就是***”可见性:确保多个线程在==读取变量时==总能获取到变量的最新值。“*** 对于读取阶段之后线程,volatile变量刷新回主内存的行为不会导致该线程缓存行无效化。如果线程1对volatile变量完成了一次写入,新值会刷新到主内存中;同时线程2尚未开始读操作,此时线程2的缓存行将会无效化,线程2将去主内存中读入变量新值。
volatile变量的适用场景
状态量标记(利用可见性与有条件的原子性)
一个线程仅写状态量标记,一个线程仅读状态量标记。volatile可见性保证监控线程总能获取flag的最新值,volatile变量的单纯写操作也具有原子性。利用volatile进行状态量标记的方法脚synchronized、Lock有效率提升。
int a = 0;
volatile bool flag = false;
// Thread-1
public void write() {
a = 2;
flag = true;
}
// Thread-2
public void multiply() {
if (flag) {
int ret = a * a;
}
}
单例模式的实现,双重检查锁定
利用volatile的有序性确保单例对象的创建过程不会出现指令重排。对象创建的过程本因先对对象进行初始化,再先将指针指向对象;但编译器的指令重排可能会颠倒这两个过程,导致并行编程中访问未完全初始化的对象。
为什么单例模式要double check?
假设有2个线程调用了getInstance(),线程1和线程2都通过了第一层null check。随后线程1竞争到了锁并通过了第二次null check,创建了单例对象并释放了锁。此时线程2获取了锁,第二次null check防止线程2再创建一个单例对象(如果创建了就与单例对象的设计模式相违背了)。
class Singleton{
private volatile static Singleton instance = null;
private Singleton() {
}
public static Singleton getInstance() {
if(instance==null) {
synchronized (Singleton.class) {
if(instance==null)
instance = new Singleton();
}
}
return instance;
}
}