由于现代操作系统都是多处理器操作系统,每个处理器都会有自己的缓存,可能存再不同处理器缓存不一致的问题,而且由于操作系统可能存在重排序,导致读取到错误的数据,因此,操作系统提供了一些内存屏障以解决这种问题:
当我们声明某个变量为volatile时,这个变量便具有了线程可见性。volatile通过在读写操作前后添加内存屏障,完成了数据的及时可见性,java并发编程实战上给出了一个volatile行为理解代码:
public class SynchronizedInteger {
private long value;
public synchronized int get() {
return value;
}
public synchronized void set(long value) {
this.value = value;
}
}
我们可以具体分析下这段代码都做了什么事。
在get/set value之前,所有对value做的操作都已生效,这点由synchronized的happends-before语义来保证。
synchronized用于方法时,锁定的当前对象,因此get和set都像是具有了原子语义一样,即使它是long型变量。
简而言之,volatile具有以下特性
- 可见性,对一个volatile的读,一定能看到读之前最后的写入。
- 原子性,对volatile变量的读写具有原子性,但是类似volatile++这种复合操作不具有原子性,
如下示例:
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; //1
flag = true; //2
}
public void reader() {
if (flag) { //3
int i = a; //4
……
假设我们在同一个线程A顺序执行writer()和reader()方法
对于volatile型变量,由一下规则保证:
- 在flag写入之前的所有写,均对flag可见,即1 happens before 2
- 在flag读取之后的所写,均在flag之后执行,即3 happens before 4
- volatile变量自身的读写具有happens before规则,即2 happens before 3
- 根据happens before的传递性,1 happens before 4
以上面示例程序为例,假设线程A执行writer()方法,线程B执行reader()方法,初始时两个线程的本地内存中的flag和a都是初始状态。
- A线程写入volatile,在A写入之前所有的写操作都已经完成。
- B线程读取volatile,在B读取的时候,由于volatile型变量特性,B会强制刷新内存,使得所有对A可见的变量对B可见。
看起来像是A向B发了一条消息,使得A写入volatile之前的所有写操作都对B可见
JMM对读写的重排序有以下规则,纵轴为操作1,横轴为操作2:
\ | 普通读/写 | volatile读 | volatile写 |
---|---|---|---|
普通读/写 | NO | ||
volatile读 | NO | NO | NO |
volatile写 | NO | NO |
从表中可以看出,
- 在第一个操作为volatile读的时候,其后的所有变量读写操作都不会重排序到前面。
- 在第二个操作为volatile读的时候,其之前的所有volatile读写操作都已完成,
- 在第一个操作为volatile写的时候,其后的volatile变量读写操作都不会重排序到前面。
- 在第二个操作为volatile写的时候,其之前的所有变量的读写操作都已完成。
可以得出以下内存屏障表格
\ | 普通读 | 普通写 | volatile读 | volatile写 |
---|---|---|---|---|
普通读 | LoadStore | |||
普通写 | StoreStore | |||
volatile读 | LoadLoad | LoadStore | LoadLoad | LoadStore |
volatile写 | StoreLoad | StoreStore |
根据JMM规则,结合内存屏障的相关分析,可以得出以下保守策略:
- volatile读之前,会添加LoadLoad内存屏障。
- volatile读之后,会添加LoadStore内存屏障。
- volatile写之前,会添加StoreStore内存屏障。
- volatile写之后,会添加StoreLoad型内存屏障。
(此处存疑,内存屏障之间存在包含关系?)
再回头看之前的测试代码:
class VolatileExample {
int a = 0;
volatile boolean flag = false;
public void writer() {
a = 1; //1
//StoreStore,a对flag可见
flag = true; //2
//StoreLoad,flag和a对后续可见
}
public void reader() {
//LoadLoad,flag和a可见
if (flag) { //3
//LoadStore,flag和a可见
int i = a; //4
……
再来看一个经典的double check的问题:
public static Singleton instance;
public static Singleton getInstance()
{
if (instance == null) //1
{ //2
synchronized(Singleton.class) { //3
if (instance == null) //4
instance = new Singleton(); //5
}
}
return instance;
}
对于以上经典写法,相信大家都不陌生,但是对java这种,缺存在问题。
对于第5步,会完成两件事:
- 在内存中创建对象
- 分配内存,将指针指向这块区域
很遗憾,这两个动作的顺序并不能保证,那么当先完成2,后完成1的时候会发生什么事呢?假设A、B两个线程按照下面顺序执行:
- A线程获取锁,并完成初始化instance的动作2,完成1之前发生线程切换。
- B线程判断instance != null,返回instance,并做动作。
- A线程获取时间片,完成初始化动作1.
很明显,在动作2的时候,会拿到一个未初始化完成的对象,很可能会导致程序异常。
如果使用volatile呢?
在jdk1.5及以后,对volatile修饰的引用的初始化,能够保证1和2动作的顺序,从而避免了这个问题。