volatile原理解析

目录

一、volatile型变量的内存语义

1、保证此变量对所有线程的可见性

2、禁止指令重排序优化。

二、volatile变量的非原子性实例

1、volatile i++;

2、典型的禁止重排优化的例子DCL(单例模式的双重检测)

三、happens-before原则和volatile的内存语义

1、volatile与happens-before

2、volataile的内存语义及其实现



总结:volatile变量只能保证可见性和顺序性而不能保证操作的原子性。

volatile经常用于两个两个场景:状态标记、double check

一、volatile型变量的内存语义

Java内存模型对volatile专门定义了一些特殊的访问规则,当一个变量被定义成volatile之后,他将具备两种特性:

1、保证此变量对所有线程的可见性

第一保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量是做不到这点,普通变量的值在线程在线程间传递均需要通过住内存来完成,例如,线程A修改一个普通变量的值,然后向主内存进行会写,另外一个线程B在线程A回写完成了之后再从主内存进行读取操作,新变量值才会对线程B可见。另外,java里面的运算并非原子操作,会导致volatile变量的运算在并发下一样是不安全的。由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性。

1).运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
2).变量不需要与其他的状态变量的共同参与不变约束。

java volatile1

2、禁止指令重排序优化。

普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量赋值操作的顺序与程序中的执行顺序一致,在单线程中,我们是无法感知这一点的。通过插入内存屏障来禁止指令重排

内存屏障,又称内存栅栏,是一个CPU指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。Memory Barrier的另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。总之,volatile变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。

在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序。
编译器重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
处理器重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
          指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么JVM是如何禁止重排序的呢?先看另一个原则happens-before,happen-before原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从happens-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规则:对volatile变量的写操作 happen-before 后续的读操作。为了实现volatile内存语义,JMM会重排序,其规则如下:

20170104-volatile

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。下图是完成上述规则所需要的内存屏障:

20170104-volatile2

 

二、volatile变量的非原子性实例

1、volatile i++;

public class VolatileFeaturesExample {
    volatile long vl = 0L;  //使用volatile声明64位的long型变量
    public void set(long l) {
        vl = l;   //单个volatile变量的写
    }
    public void getAndIncrement () {
        vl++;    //复合(多个)volatile变量的读/写
    }
    public long get() {
        return vl;   //单个volatile变量的读
    }
}
public class Mythread implements Runnable{
    VolatileFeaturesExample e;
    CountDownLatch countDownLatch;
    public Mythread(VolatileFeaturesExample e,CountDownLatch countDownLatch){
        this.e = e;
        this.countDownLatch = countDownLatch;
    }
    @Override
    public void run() {
        for (int i = 0; i < 1000; i++) {
            e.getAndIncrement();
        }
        countDownLatch.countDown();
    }
}
public class T1 {
    public static void main(String[] args) {
        CountDownLatch count = new CountDownLatch(10);
        VolatileFeaturesExample e = new VolatileFeaturesExample();
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(new Mythread(e,count));
            thread.start();
        }
        try {
            count.await();
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
        System.out.println("result: " + e.get());
    }
}

无论变量加不加volatile修饰都会出现多次运行输出和预期不一致的结果

2、典型的禁止重排优化的例子DCL(单例模式的双重检测)

public class DoubleCheckLock {

    private static DoubleCheckLock instance;

    private DoubleCheckLock(){}

    public static DoubleCheckLock getInstance(){

        //第一次检测
        if (instance==null){
            //同步
            synchronized (DoubleCheckLock.class){
                if (instance == null){
                    //多线程环境下可能会出现问题的地方
                    instance = new DoubleCheckLock();
                }
            }
        }
        return instance;
    }
}

上述代码一个经典的单例的双重检测的代码,这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可以出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化。因为instance = new DoubleCheckLock();可以分为以下3步完成(伪代码):
memory = allocate(); //1.分配对象内存空间
instance(memory);    //2.初始化对象
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null
由于步骤1和步骤2间可能会重排序,如下:
memory = allocate(); //1.分配对象内存空间
instance = memory;   //3.设置instance指向刚分配的内存地址,此时instance!=null,但是对象还没有初始化完成!
instance(memory);    //2.初始化对象

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢,很简单,我们使用volatile禁止instance变量被执行指令重排优化即可。
  //禁止指令重排优化
private volatile static DoubleCheckLock instance;

三、happens-before原则和volatile的内存语义

volatile可见性;对一个volatile的读,总可以看到对这个变量最终的写;
volatile原子性;volatile对单个读/写具有原子性(32位Long、Double),但是复合操作除外,例如i++;
JVM底层采用“内存屏障”来实现volatile语义;

1、volatile与happens-before

happens-before是用来判断是否存数据竞争、线程是否安全的主要依据,它保证了多线程环境下的可见性。下面我们就那个经典的例子来分析volatile变量的读写建立的happens-before关系。

public class VolatileTest {

    int i = 0;
    volatile boolean flag = false;

    //Thread A
    public void write(){
        i = 2;              //1
        flag = true;        //2
    }

    //Thread B
    public void read(){
        if(flag){                                   //3
            System.out.println("---i = " + i);      //4
        }
    }}

依据happens-before原则,就上面程序得到如下关系:
依据happens-before程序顺序原则:1 happens-before 2、3 happens-before 4;
根据happens-before的volatile原则:2 happens-before 3;
根据happens-before的传递性:1 happens-before 4
操作1、操作4存在happens-before关系,那么1一定是对4可见的。可能有同学就会问,操作1、操作2可能会发生重排序啊,会吗?volatile除了保证可见性外,还有就是禁止重排序。
所以A线程在写volatile变量之前可见所有的共享变量,在线程B读同一个volatile变量后,将立即变得对线程B可见。

2、volataile的内存语义及其实现

在JMM中,线程之间的通信采用共享内存来实现的。volatile的内存语义是:
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值立即刷新到主内存中。
当读一个volatile变量时,JMM会把该线程对应的本地内存设置为无效,直接从主内存中读取共享变量
所以volatile的写内存语义是直接刷新到主内存中,读的内存语义是直接从主内存中读取。
那么volatile的内存语义是如何实现的呢?对于一般的变量则会被重排序,而对于volatile则不能,这样会影响其内存语义,所以为了实现volatile的内存语义JMM会限制重排序。其重排序规则如下:
翻译如下:
如果第一个操作为volatile读,则不管第二个操作是啥,都不能重排序。这个操作确保volatile读之后的操作不会被编译器重排序到volatile读之前;
当第二个操作为volatile写是,则不管第一个操作是啥,都不能重排序。这个操作确保volatile写之前的操作不会被编译器重排序到volatile写之后;
当第一个操作volatile写,第二操作为volatile读时,不能重排序。
volatile的底层实现是通过插入内存屏障,但是对于编译器来说,发现一个最优布置来最小化插入内存屏障的总数几乎是不可能的,所以,JMM采用了保守策略。如下:
在每一个volatile写操作前面插入一个StoreStore屏障
在每一个volatile写操作后面插入一个StoreLoad屏障
在每一个volatile读操作后面插入一个LoadLoad屏障
在每一个volatile读操作后面插入一个LoadStore屏障
StoreStore屏障可以保证在volatile写之前,其前面的所有普通写操作都已经刷新到主内存中。
StoreLoad屏障的作用是避免volatile写与后面可能有的volatile读/写操作重排序。
LoadLoad屏障用来禁止处理器把上面的volatile读与下面的普通读重排序。
LoadStore屏障用来禁止处理器把上面的volatile读与下面的普通写重排序。
下面我们就上面那个VolatileTest例子分析下:

public class VolatileTest {
    int i = 0;
    volatile boolean flag = false;
    public void write(){
        i = 2;
        flag = true;
    }

    public void read(){
        if(flag){
            System.out.println("---i = " + i); 
        }
    }}

上面通过一个例子稍微演示了volatile指令的内存屏障图例。
volatile的内存屏障插入策略非常保守,其实在实际中,只要不改变volatile写-读得内存语义,编译器可以根据具体情况优化,省略不必要的屏障。如下(摘自方腾飞 《Java并发编程的艺术》):

public class VolatileBarrierExample {
    int a = 0;
    volatile int v1 = 1;
    volatile int v2 = 2;
    
    void readAndWrite(){
        int i = v1;     //volatile读
        int j = v2;     //volatile读
        a = i + j;      //普通读
        v1 = i + 1;     //volatile写
        v2 = j * 2;     //volatile写
    }}

没有优化的示例图如下:

我们来分析上图有哪些内存屏障指令是多余的
1:这个肯定要保留了
2:禁止下面所有的普通写与上面的volatile读重排序,但是由于存在第二个volatile读,那个普通的读根本无法越过第二个volatile读。所以可以省略。
3:下面已经不存在普通读了,可以省略。
4:保留
5:保留
6:下面跟着一个volatile写,所以可以省略
7:保留
8:保留
所以2、3、6可以省略,其示意图如下:

参考:

http://www.cnblogs.com/aigongsi/archive/2012/04/01/2429166.html#undefined

https://blog.csdn.net/javazejian/article/details/72772461

http://cmsblogs.com/?p=2092

http://cmsblogs.com/

方腾飞:《Java并发编程的艺术》

 

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值