在JAVA内存模型中介绍了内存模型的可见性、原子性以及时序性,要理解好volatile就必须很好地理解JAVA内存模型。本篇主要是对volatile进行一个总结,对JAVA内存模型不了解的可以看看JAVA内存模型这篇文章。
在JAVA内存模型中介绍了volatile可以保证可见性、一定程度上禁止重排序以及无法保证原子性;下面对其一一进行分析,
volatile保证可见性
先看一段普通变量的代码
private int value = 3;
private int getValue() {
int temp = value;
return temp;
}
private void setValue(int value) {
this.value = value
}
上面的代码中value是普通变量,在Java的内存模型中,每个线程都有一个工作内存,线程操作的普通变量都是直接操作工作内存,然后由工作内存刷新到主内存中去;由于每个线程的工作内存都是独立的,从而在并发编程中产生了可见性的问题。
如上面代码,假如当前线程1的调用了setValue()设置value为4,且value的值未从工作内存同步到主内存中去。当线程1调用getValue()从主内存中获取value的值依然3,对线程1的工作内存value的值不可见,导致线程2读取到的值不正确,这就是并发编程下的可见性问题;
通过volatile能够保证可见性,用volatile修饰的变量,当线程对其进行读取时,会跳过线程的工作内存,直接从主内存中读取;当线程对其进行写入时,会将变量的值写入到主内存中去。
private volatile int value = 3;
private int getValue() {
int temp = value;
return temp;
}
private void setValue(int value) {
this.value = value
}
将value用volatile修饰,当线程1调用setValue()设置value为4时,会直接将value为4的值跳过工作内存写入主内存,此时另外的线程调用getValue()读取value的值时,也会跳过工作内存,直接从主内存中读取value的值为4,从而保证了并发编程下的可见性。
volatile无法保证原子性
volatile能保证可见性,但是不能保证原子性。一个比较常见的例子是i++
private volatile int i = 0;
private void test() {
i ++;
}
i++ 的操作非原子操作的,它分为三步:从内存中读取i的值、将i加1、将i的值写入到内存中;
private void test() {
i ++;//分为三步:1.从内存中读取值;2。将i加1;3.将i的值写入内存中。
}
当从内存中读取到i的值后,若其他的线程此时也读取i的值进行加1,并且成功写入内存中,此时当前工作线程内存的值还是原先的值,并非最新的值;因此volatile不能保证并发编程下的原子性。
volatile禁止重排序
在JAVA内存模型中介绍了重排序,重排序会给并发编程下带来什么样的影响呢?
private boolean hasInited;
private Context context;
private void initContext() {
context = init();//进行初始化
hasInited = true;
}
private void doSomething() {
if(hasInited) {
Intent intent = new Intent();
context.startActivity(intent);
}
}
上面的代码doSomething()函数只有在context进行了初始化之后,才能继续执行里面的内容;在initContext()函数中,因为 context = init();与hasInited = true;不能存在数据依赖关系,因此可能被重排序,hasInited = true;语句先执行,然后再执行context = init();进行初始化。若线程1执行完hasInited = true;但未执行context = init();时,线程2调用doSomething(),此时hasInited为true,会继续调用context的相关函数,但是线程1并未完成对context`的初始化,从而产生异常。
通过volatile修饰来禁止重排序,volatile通过内存屏障来禁止重排序。为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
在每个volatile写操作的前面插入一个StoreStore屏障。
在每个volatile写操作的后面插入一个StoreLoad屏障。
在每个volatile读操作的后面插入一个LoadLoad屏障。
在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
上图中的StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为StoreStore屏障将保障上面所有的普通写在volatile写之前刷新到主内存。
这里比较有意思的是volatile写后面的StoreLoad屏障。这个屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面,是否需要插入一个StoreLoad屏障(比如,一个volatile写之后方法立即return)。为了保证能正确实现volatile的内存语义,JMM在这里采取了保守策略:在每个volatile写的后面或在每个volatile读的前面插入一个StoreLoad屏障。从整体执行效率的角度考虑,JMM选择了在每个volatile写的后面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里我们可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率。
下面是在保守策略下,volatile读插入内存屏障后生成的指令序列示意图:
上图中的LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
这段文字参考此篇文章,更详细的了解volatile的内存模型,可以阅读此篇文章;
如何正确地使用volatile
只能在有限的一些情形下使用 volatile 变量替代锁。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:
对变量的写操作不依赖于当前值。
该变量没有包含在具有其他变量的不变式中。
实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
1.对变量的写操作不依赖于当前值。
第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(x++)看上去类似一个单独操作,实际上它是一个由读取-修改-写入操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。实现正确的操作需要使 x 的值在操作期间保持不变,而 volatile 变量无法实现这点。(然而,如果将值调整为只从单个线程写入,那么可以忽略第一个条件。)
2.该变量没有包含在具有其他变量的不变式中。
大多数编程情形都会与这两个条件的其中之一冲突,使得 volatile 变量不能像 synchronized 那样普遍适用于实现线程安全。清单 1 显示了一个非线程安全的数值范围类。它包含了一个不变式 —— 下界总是小于或等于上界。
@NotThreadSafe
public class NumberRange {
private int lower, upper;
public int getLower() { return lower; }
public int getUpper() { return upper; }
public void setLower(int value) {
if (value > upper)
throw new IllegalArgumentException(...);
lower = value;
}
public void setUpper(int value) {
if (value < lower)
throw new IllegalArgumentException(...);
upper = value;
}
}
这种方式限制了范围的状态变量,因此将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;从而仍然需要使用同步。否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是 (0, 5),同一时间内,线程 A 调用 setLower(4) 并且线程 B 调用 setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是 (4, 3) —— 一个无效值。至于针对范围的其他操作,我们需要使 setLower() 和 setUpper() 操作原子化 —— 而将字段定义为 volatile 类型是无法实现这一目的的。