高并发(三)--volatile的使用及其原理

一、volatile的作用

通常我们通过synchronized关键字来解决可见性、有序性以及原子性问题,但是synchronized是一个比较重量级的操作,对系统的性能有比较大的影响,所以如果有其他解决方案的话,我们通常会避免使用synchronized来解决问题。
仅仅为了读写一个或两个实例域就使用同步,显得开销过大了。

  • 多处理器的计算机能够暂时在寄存器或本地内存缓冲区中保存内存中的值。结果是,运行在不同处理器上的线程可能在同一个内存位置取到不同的值。
  • 编译器可以改变指令的执行顺序以使吞吐量最大化。这种顺序上的变化不会改变代码语义,但是编译器假定内存的值仅仅在代码中有显式的修改指令时才会改变。然而,内存的值可以被另一个线程改变。

volatile关键字就是java中提供的另外一种解决可见性和有序性问题的方案,这里需要注意的是:**对volatile变量的单次读/写操作可以保证原子性的,如long和double类型的变量,但是并不能保证i++这种操作的原子性,因为它本质上是读写两次操作。
volatile的机制:编译器被要求同过在必要的时候刷新本地缓存来保持锁的效应,并且不能不正当的重新排序指令

二、volatile的作用

防止重排序

并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现,源码如下:

package com.paddx.test.concurrent;

public class Singleton {
    public static volatile Singleton singleton;

    /**
     * 构造函数私有,禁止外部实例化
     */
    private Singleton() {};

    public static Singleton getInstance() {
        if (singleton == null) {
            synchronized (singleton) {
                if (singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

接下来我们分析一下为什么要在变量singleton之前加上volatile关键字。首先要了解对象的构造过程,实例化一个对象其实可分为三个步骤:

  • 分配内存空间
  • 初始化对象
  • 将内存空间的地址赋值给对应的引用

但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下的操作:

  • 分配内存空间
  • 将内存空间的地址赋值给对应的引用
  • 初始化对象
    如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果,因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

实现可见性

可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓冲区–线程工作内存volatile关键字能够有效的解决这个问题,举例如下:

package com.paddx.test.concurrent;

public class VolatileTest {
    int a = 1;
    int b = 2;

    public void change(){
        a = 3;
        b = a;
    }

    public void print(){
        System.out.println("b="+b+";a="+a);
    }

    public static void main(String[] args) {
        while (true){
            final VolatileTest test = new VolatileTest();
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.change();
                }
            }).start();

            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test.print();
                }
            }).start();

        }
    }
}

直观上说,这段代码的结果只可能有两种情况:b=3,a=3b=2,a=1。但是运行上面的代码,会发现还会出现第三种结果:b=3,a=1
出现这种情况的原因是:第一个线程将值a=3修改后,对第二个线程是不可见的,所以才会出现这一结果。如果将ab都改成volatile类型的变量再执行,就不会出现b=3,a=1的情况了。

保证原子性

volatile只保证对单次读/写的原子性。普通的long或double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型的读写可能不是原子的。因此,鼓励大家将longdouble变量设置为volatile类型,这样能保证任何情况下对longdouble的单次读写操作都具有原子性。
注意问题:

package com.paddx.test.concurrent;

public class VolatileTest01 {
    volatile int i;

    public void addI(){
        i++;
    }

    public static void main(String[] args) throws InterruptedException {
        final  VolatileTest01 test01 = new VolatileTest01();
        for (int n = 0; n < 1000; n++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(10);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    test01.addI();
                }
            }).start();
        }

        Thread.sleep(10000);//等待10秒,保证上面程序执行完成

        System.out.println(test01.i);
    }
}

上述代码的运行结果为:
在这里插入图片描述
可以看出volatile是无法保证原子性的,是因为**i++是一个复合操作**,包括三步:
(1)读取i的值
(2)对i加1
(3)将i的值写回内存
volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证加1操作的原子性

三. volatile的原理

可见性的实现

线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作,这也是导致线程间数据不可见的本质原因,因此要实现volatile变量的可见性,直接在这方面入手即可,对volatile变量的写操作与普通变量的主要区别有两点:

  • 修改volatile变量时会强制将修改后的值刷新到主内存中
  • 修改volatile变量后会导致其他线程工作内存中对应的变量值失效。因此,再读取该变量值的时候就需要重新从主内存中读取。

通过这两个操作,就可以解决volatile变量的可见性问题。

有序性的实现

java中的happen-before定义:

Two actions can be ordered by a happens-before relationship.If one action happens before another, then the first is visible to and ordered before the second.

大概意思就是如果a happen-before b,则a所做的任何操作对b是可见的。
happen-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可见性和happen-before的语义,JVM底层是通过一个叫做内存屏障的东西来完成。内存屏障,也叫做内存栅栏,是一组处理器指令,用于实现对内存操作的顺序限制

  • LoadLoad屏障
    执行顺序:Load1->LoadLoad->Load2
    确保Load2及后续Load指令加载数据之前能访问到Load1加载的数据

  • StoreStore屏障
    执行顺序:Store1->StoreStore->Store2
    确保Store2以及后续store执行执行前,store1操作的数据对其他处理器可见。

  • LoadStore屏障
    执行顺序:Load1->LoadStore->Store2
    确保Store2和后续store指令执行前,可以访问到Load1加载的数据

  • StoreLoad屏障
    执行顺序:Store1->StoreLoad->Load2
    确保Load2和后续的Load指令读取之前,Store1的数据对其他处理器是可见的。

四. 总结

volatile是并发编程中的一种优化,在某些场景下可以代替synchronized。但是 volatile是不能完全取代synchronized的位置,只有在一些特殊的场景下,才能适用 volatile。总的来说,必须同时满足下面两个条件才能保证在并发环境下的线程安全:

  • 对变量的写操作不依赖于当前值
  • 该变量没有包含在具有其它变量的不变式中

感谢并参考

www.cnblogs.com/paddix/p/5428507.html

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值