美文网首页
JUC之volatile详解

JUC之volatile详解

作者: 西界__ | 来源:发表于2020-12-26 09:44 被阅读0次

volatile

<mark>volatile是Java虚拟机提供的轻量级的同步机制,保证可见性,不保证原子性,禁止指令重排(保证可序性)</mark>

Java内存模型JMM

验证可见性

public class VolatileDemo {

    public static void main(String[] args) {
        //volatile 可以保证可见性,及时通知其他线程,主物理内存的值已经被修改
        MyData myData = new MyData();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t come in");
            //暂停
            try {
                //确保后面的main线程启动,进入循环
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myData.addT060();
            System.out.println(Thread.currentThread().getName() + "\t update number value" + myData.number);
        }, "AAA").start();

        //第二个线程就是我们的main线程
        while (myData.number==0){
            //直到number不为0才退出循环
        }

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


    }
}


class MyData {
    int number = 0;
    public void addT060() {
        this.number = 60;
    }
    
}

首先创建MyData类,定义一个int型的number变量值为0,定义一个addT060()方法,将number值变成60。在从VolatileDemo中的主方法创建线程A,线程A被执行调用后先暂停3秒,以取保此时的主线程已经开始执行while循环。3秒后A线程调用addT060()方法,将number值修改成60,A线程执行结束。但是main线程中的number取依旧还是0,而导致无法退出while循环!

运行查看结果~

通过画图可以清晰的看到main线程为啥会nmber值为60后,还处于while循环而导致的死循环。这都是当前变量没有可见性所导致的结果。

下面验证volatile保证可见性,只需要在number变量中添加volatile关键字即可。

再次运行程序查看结果。

线程A修改number的值后,就值写入主内存,main线程从主内存中获取最新的number值拷贝到工作内存中,此时nuber值不等于0main线程退出循环,程序结束。

验证原子性

原子性就是不可分割,具有完整性,也即某个线程正在做某个具体业务时,中间不可以被加塞或者被分割。需要整体完整要么同时成功,要么同时失败。

在上文中的MyData中添加addPlusPlus方法。创建20个线程每个循环调用1000次addPlusPlus方法。

public class VolatileDemo {

    public static void main(String[] args) {
        MyData myData = new MyData();
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    myData.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }

        //需要等待前面20个线程全部执行完,在用main线程取得最终的结果值是多少
        while (Thread.activeCount() > 2) {
            //main 线程 GC线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t int type final number value:" + myData.number);
    }
}


class MyData {

    volatile int number = 0;


    public void addT060() {
        this.number = 60;
    }

    //number加了volatile关键字
    public void addPlusPlus() {
        number++;
    }
}

运行查看结果~

number值大多不等于20000(存在等于20000情况),出现了数值丢失写值的情况。所以volatite并不保证原子性!

例如现如今有两个线程A和B,此时的number值为0。首先线程A被CPU调度执行,被number值加1变成了1,正准备将工作内存中的值同步更新到主内存时。CPU调度执行了线程B,线程A被挂起,此时主内存的number值还是为0,线程B将number值加一变成了1,并且成功的将number值同步更新到了主内存中,主内存中的值也变成了1。然后线程A被调度执行继续将工作内存中更新的number值同步更新到主内存中,更新成功后当前主内存中的最新的number值还是为1。这样也就丢失了一次数值加一的操作。

解决原子性

首先可以使用synchronized关键字,不过此操作太过重,不推荐。其次我们可以使用JUC包下的atomic也就是java.util.concurrent.atomic

int类型的使用AtomicInteger

查看方法,使用getAndIncrement方法,将值自增加一。

添加如下代码

public class VolatileDemo {

    public static void main(String[] args) {
        MyData myData = new MyData();
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    myData.addPlusPlus();
                    myData.addMyAtomic();
                }
            }, String.valueOf(i)).start();
        }

        //需要等待前面20个线程全部执行完,在用main线程取得最终的结果值是多少
        while (Thread.activeCount() > 2) {
            //main 线程 GC线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t int type final number value:" + myData.number);
        System.out.println(Thread.currentThread().getName() + "\t AtomicInteger type final number value:" + myData.atomicInteger);
    }
}


class MyData {
    volatile int number = 0;

    public void addT060() {
        this.number = 60;
    }

    //number加了volatile关键字
    public void addPlusPlus() {
        number++;
    }

    AtomicInteger atomicInteger = new AtomicInteger();

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

运行查看结果~成功保证了原子性!

指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分以下3种:

  • 编译器优化的重排
  • 指令并行的重排
  • 内存系统的重排

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。

<mark>处理器在进行重排顺序是必须要考虑指令之间的数据依赖性</mark>

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,导致结果无法预测!

所谓数据依赖性如图

此代码我们可以指令重排为1234,2134,1324。

但是我们能将语句4重排后变成第一个吗?答案是不能因为语句4y = x * x必须要先声明y和x,x的值也要是最终的。所以这就是存在数据依赖性,指令重排必须考虑数据依赖性否则会导致程序报错或者最终结果不一致问题!

一般情况下是当先调用method1方法后,语句1语句2一次执行。a变成1,flag变为true。这时调用method2的线程才能执行语句三将a变成6,打印结果。

但是在多线程的情况下,该两个变量可能会被指令重排成语句2在语句1前被声明,两语句之间没有数据依赖,所以存在这种情况。

此时在多线程的情况下,线程A调用执行method1将flag变成了true后,还没有继续执行。线程B就被CPU调度执行,线程A挂起。线程B执行method2,此时flag为true,执行if语句中的代码将a加5,此时a还为0,所以0+5变成5,打印出来的a就变成了5。接着线程A继续执行a变成了1。

为了防止这种情况的出现,所以我们要对关键变量禁止指令重排!

<mark>volatile实现禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。</mark>

在JVM底层volatile是采用“<mark>内存屏障</mark>”来实现的。观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1. 它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成.
  2. 它会强制将对缓存的修改操作立即写入主存
  3. 如果是写操作,它会导致其他CPU中对应的缓存行无效。

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier内存屏障则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重新排序优化。内存屏障另外一个作用是强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这些数据的最新版本。

在对Volatile变量进行写操作时,会在写操作后加入一个store屏障指令,将工作内存中的共享变量值刷新回到主内存。

对Volatile变量进行读操作时,会在读操作前加入一个load屏障指令,从主内存中读取共享变量。

单例模式volatile分析

单例模式详解

public class SingletonDemo {

    private static SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance() {
        if (instance == null) {
            instance = new SingletonDemo();
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 1; i <= 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }

}

单线程下是可行的就不测试了,但是多线程下却出现了问题!

为了解决不安全问题我们可以使用Double-Check双重检查来实现。在加入同步代码块之前和之后分别对是否存在实例对象进行判断。

从而防止如果在多线程下,一个线程进入了if (singleton == null)判断语句块,还未来得及往下执行,另一个线程也通过了这个判断语句,这时便会产生多个实例。

查看运行结果~

虽然目前还是成功的,但是还是存在问题的!也就是指令重排的问题。

原因在于某一个线程在执行到第一次检测,读取到的instance不为null时,instance的引用对象 可能没有完成初始化.

instance=new SingletonDem(); 可以分为以下步骤(伪代码)

  • memory=allocate();//1.分配对象内存空间

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

  • instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null

步骤2和步骤3不存在数据依赖关系.而且无论重排前还是重排后程序执行的结果在单线程中并没有改变,因此这种重排优化是允许的.

memory=allocate();//1.分配对象内存空间

<mark>instance=memory;// 3 .设置instance的指向刚分配的内存地址,此时instance!=null 但对象还没有初始化完。会导致对象为空</mark>

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

但是指令重排只会保证串行语义的执行一致性(单线程) 并不会关心多线程间的语义一致性

所以当一条线程访问instance不为null时,由于instance实例未必完成初始化,也就造成了线程安全问题。因此加入volatile可以禁止指令重排。

public class SingletonDemo {

    private static volatile SingletonDemo instance = null;

    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t 我是构造方法SingletonDemo()");
    }

    //DCL 双重检查
    public static SingletonDemo getInstance() {
        if (instance == null) {
            synchronized (SingletonDemo.class) {
                if (instance == null) {
                    instance = new SingletonDemo();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        for (int i = 1; i <= 1000; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }

}

总结

工作内存和主内存同步延迟现象导致的可见性问题,可以使用synchronizedvolatile关键字解决,他们都可以使用一个线程修改后的变量立即对其他线程可见。

对于指令重排导致的可见性问题和有序性问题,可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止指令重排序优化。

相关文章

网友评论

      本文标题:JUC之volatile详解

      本文链接:https://www.haomeiwen.com/subject/obddnktx.html