Volatile总结

来源:互联网 发布:淘宝商城首页女装 编辑:程序博客网 时间:2024/05/22 18:22

在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 类型是无法实现这一目的的。

原创粉丝点击