java基础----volatile

一.volatile是什么

  如果用一句话概括volatile的话,那volatile其实就是java虚拟机提供的轻量级的同步机制。它具有一下三个特点:

  1.保证可见性

  2.不保证原子性(因为不保证原子性,所以他是轻量级的)

  3.禁止指令重排

 

二.保证可见性

  首先,我们先看看下面的代码

import java.util.concurrent.TimeUnit;

class MyData{
    //int testData = 0;
    volatile int testData = 0;

    public void addTo60(){
        this.testData = 60;
    }
}

public class volatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        //工作线程
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.addTo60();
            System.out.println(Thread.currentThread().getName() + "\t update number value: " + myData.testData);
        }, "test").start();

        while (myData.testData == 0)
        {
            //假如不使用volatile修饰变量
            //这里main线程会一直在这里等待
            //因为工作线程修改了变量,却不能对main线程可见,main线程会一直拿着初始值的副本,也就是0
        }

        System.out.println(Thread.currentThread().getName() + "\t mission is over");
    }
}

关于volatile是怎么实现可见性的,可以总结为以下两点:

  1.从主内存到工作内存<读>:每次使用变量前  先从主内存中刷新最新的值到工作内存,用于保证能看见其他现场对变量修改的最新值。

  2.从工作内存到主内存<写>:每次修改变量后必须立刻同步到主内存中,用于保证其他线程可以看到自己对变量的修改。(因为cpu比内存的速度要快很多,因此这中间有一个高速缓存的概念,一般修改后的变量都是先存进高速缓存,然后再刷新到主内存中)

  3.指令重排序:保证代码的执行顺序和程序的执行顺序一致。(并发环境下 代码的执行顺序与程序的执行顺序有时并不一致,会出现串行的现象固有指令重排序优化一说。JAVA1.5之后彻底修复了这个BUG在用volatile变量的时)

 

三.不保证原子性

  volatile有个比较不好的地方就是它不保证原子性,按照惯例,下面还是先上一段代码

import java.util.concurrent.TimeUnit;

class MyData{
    volatile int testData = 0;

    public void add(){
        this.testData ++;
    }
}

public class volatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        for (int i = 0; i < 20; i++){
            new Thread(() -> {
                for (int j = 0; j < 1000; j++){
                    myData.add();
                }
            },"test"+i).start();
        }

     //后台默认有main线程和gc线程,因此这里选择当线程数量大于2时候,main线程退出暂停。 while (Thread.activeCount() > 2){ Thread.yield(); }
     //这里每次运行出来的结果都是不同的 System.out.println(Thread.currentThread().getName() + "\t final number is " + myData.testData); } }

   上述代码运行出来的结果不是我们想要的结果,导致这种情况出现的原因是,在并发编程中,可能存到两个线程同时去主内存中拿到变量,然后进行计算操作,例如线程a、b,同时拿到主内存中的变量x1到自己的工作内存里面,然后计算后得出同样的x2,当要把x2写回主内存时,其中一个会将数据刷新到主内存中,而另一个线程处于挂起状态,当前一个线程写入操作完成后,后一个线程被唤醒,在还没获取变量最新值的时候,立即进行写入操作(写覆盖),这时候主内存中的变量被刷新了两次,但是它的数值只增加了1,因为两个线程算出来的结果时一样的,这时候就会导致变量最后自增的结果不是我们想要的结果。

  那么我们如何去解决这个问题呢?其实很简单,juc包里面有一个atomic类的数据,我们使用它来作为我们操作的对象就可以了。

import java.util.concurrent.atomic.AtomicInteger;

class MyData{
    volatile int testData = 0;

    public void add(){
        this.testData ++;
    }

    volatile AtomicInteger atomicInteger = new AtomicInteger();

    public void addAtom(){
        atomicInteger.getAndIncrement();
    }
}

public class volatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();

        for (int i = 0; i < 20; i++){
            new Thread(() -> {
                for (int j = 0; j < 1000; j++){
                    myData.add();
                }
            },"test"+i).start();
        }

        for (int i = 0; i < 20; i++){
            new Thread(() -> {
                for (int j = 0; j < 1000; j++){
                    myData.addAtom();
                }
            },"test-atomic"+i).start();
        }

        //后台默认有main线程和gc线程,因此这里选择当线程数量大于2时候,main线程退出暂停。
        while (Thread.activeCount() > 2){
            Thread.yield();
        }

        //这里每次运行出来的结果都是不同的
        System.out.println(Thread.currentThread().getName() + "\t final number is " + myData.testData);
        System.out.println(Thread.currentThread().getName() + "\t final number is " + myData.atomicInteger);
    }
}

  可是为什么使用atomic类的对象就可以解决原子性的问题呢?其实atomic是通过CAS来保证他的原子性的。

  那,CAS又是什么呢?简单来说,CAS是compareAndSwap的缩写,意思是对比和交换。

  java中CAS操作依赖于Unsafe类,Unsafe类所有方法都是native的,直接调用操作系统底层资源执行相应任务,它可以像C一样操作内存指针,是非线程安全的。

//第一个参数o为给定对象,offset为对象内存的偏移量,通过这个偏移量迅速定位字段并设置或获取该字段的值,
//expected表示期望值,x表示要设置的值,下面3个方法都通过CAS原子指令执行操作。
public final native boolean compareAndSwapObject(Object o, long offset,Object expected, Object x);                                                                                                   
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x); 
public final native boolean compareAndSwapLong(Object o, long offset,long expected,long x);

  而atomicInteger在更新他的value的时候,就是调用了unsafe类中的compareAndSwapInt方法去更新的。

  举个例子,在线程需要对一个变量进行写操作的时候,会先对比这个变量是否符合预期值,如果符合,则会进行写操作,如果不符合,代表有其他线程对这个变量进行了修改,则获取变量当前值作为最新值,返回重新进行计算操作,依次循环,知道在对比的时候符合预期值。例如当前变量x1=1,同时有线程a,b过来获取变量,并进行+1操作,此时线程a计算完毕,对比主内存中变量是否为1,假如是,就将结果2写进主内存中,此时线程b也计算完毕了,对比主内存中的变量值,发现2!=1,意思是有其他线程已经对这个变量进行修改了,就会把2拿回去,重新进行+1的操作,然后再次对比。

 

四.禁止指令重排

  计算机在执行程序时,为了提供性能,编译器和处理器常常会对指令进行重排

    1.源代码

    2.编译器优化的重排

    3.指令并行的重排

    4.内存系统的重排

    5.最终执行指令

  单线程环境下,不需要考虑指令重排的情况,程序最终执行的顺序和代码的顺序是一致的。

  处理器在进行指令重排时,必需要考虑指令之间的数据依赖性,例如 Int x = 0; x = x + 5,这样的语句是不能被重排的,因为x需要先初始化。

  多线程环境下,线程交替执行,由于编译器优化重排的存在,实际运行的结果是无法保证与代码里面预期的结果一致的。

  而volatile是通过插入内存屏障,禁止内存屏障前后的指令执行指令重排序优化。

  举个例子,在一个使用了双端检查机制的单实例中,假如不用volatile来修饰实例的话,将会产生预期之外的结果

public class SingletonDemo {
    private static volatile SingletonDemo instance = null;

    private SingletonDemo(){
        System.out.println(Thread.currentThread().getName() + "\t 创建用例");
    }

    //双端检查机制
    public static SingletonDemo getInstance(){

        //这种是常规写法,但是在多线程环境下会有问题
        //if (instance == null){
        //    instance = new SingletonDemo();
        //}

        //假如a,b,c线程同时过来,发现instance == null 则会进入锁代码块
        //假如这里不加检查,则无论实例是否初始化好,线程进来都会有一个抢夺锁资源的操作,很浪费
        if(instance == null)
        {
            //因为上锁了,假如a获得锁,b,c只能等着
            synchronized (SingletonDemo.class)
            {
                //这里之所以要在检查一次,是因为假如不做判断
                //当a线程初始化了这个实例后,b,c线程相继获取到锁资源然后进来执行锁代码块
                //如果没有判断,则会直接再执行一次初始化实例
                //这样明显不符合我们的要求,因此进来的时候需要再判断一次,b,c线程进来后发现instance以及不为null了,就不会初始化实例了
                if(instance == null)
                {
                    instance = new SingletonDemo();
                }
            }
        }

        return instance;
    }

    public static void main(String[] args) {
        for(int i = 0; i < 10; i++){
            new Thread(()->{
                System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
                System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
                System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
            },"test"+i).start();
        }

    }
}

 

  假如不在instance那里加上volatile修饰的话,则会有可能有某个线程在调用SingletonDemo.getInstance()的时候,获得一个null值,要想知道这里面的原因,首先我们需要知道在getInstance()方法中,instance = new SingletonDemo()这句代码具体执行了什么操作。

    1.memory = allocate()  //给对象分配内存

    2.instance(memory)   //初始化对象

    3.instance = memory //将instance变量指向对象的内存地址 

    一般来说,都是按照以上顺序执行的,但是因为操作不存在变量依赖,所以在多线程环境下,可能会发生指令重排操作,将会变成

    1.memory = allocate()  //给对象分配内存

    2.instance = memory //将instance变量指向对象的内存地址 

    3.instance(memory)   //初始化对象

  这样就导致一种情况,就是a线程进来后,发现instance==null,就会执行instance = new SingletonDemo()这条代码,然后这时候b线程进来了,而a线程初始化对象已经完成了“分配内存”和“修改变量引用”这两步,但是却还没有来得及执行初始化对象这一步。这时候线程b发现instance!=null,不会进入到锁代码块,会直接return instance,然而,这时候的instance所指向的内存区域,其实还没完成对象的初始化,是空的,这样,就会出现预期之外的异常了。

  因此,用volatile修饰instance,这样在执行instance = new SingletonDemo()这句代码的时候,就不会发生指令重排了,也不会出现异常。

 

posted @ 2020-09-08 12:08  喜欢it的小聪聪  阅读(342)  评论(0编辑  收藏  举报