【JAVA基础】volatile关键字解析

一、volatile是什么?

volatile是一种轻量级的同步机制

二、volatile的三种特性?

1.保证可见性
2.不保证原子性
3.禁止指令重排

三、JMM(内存模型)的概念

JMM简单介绍

在说volatile之前,我们需要知道JMM。JMM是什么呢,JMM表示JAVA内存模型,他是一种抽象的概念,它表示一种约定,规范,实际上并不存在。
java在执行指令的时候,会涉及到对数据的读写,我们知道,数据是放在主存中的,当程序在运行的过程中,会将运行所需要的数据从主存复制一份到CPU的高速缓存当中,
这样CPU进行就可以直接从它的高速缓存读取数据并且向其中写入数据,当计算运行结束之后,将高速缓存的数据同步到主存中,
比如对于

i = i + 1;

对于这个例子来说,当线程执行到这儿的时候,先从主存中获取i的值,然后拷贝一份数据扔到高速缓存中,然后CPU将对变量i进行加1的操作,将结果写入高速缓存,最后,高速缓存将最终的结果同步到主存,对于单线程来说,这样是不存在问题的,取数、复制、计算、同步。
但是对于。多线程的情况下,比如两个线程,期望这两个线程执行完的结果使得i变成2,但是事实可能并没有我们想到这么顺利。每个CPU有自己的高速缓存,如果这两个线程被不同的CPU执行,此时可能会出现这样一种情况,线程都从主存1、2取出原始数据0,经过一番操作,线程1会将i变成1,然后同步到主存,线程2也是这样,这个时候结果就是1,而不是2了。
也就是说,如果一个变量在多个CPU中都存在缓存,那么就可能存在缓存不一致的问题。

为了解决这个问题,java使用锁机制来处理,那么对于并发编程来说,一般会遇见原子性问题,可见性问题,有序性问题三个问题。JMM对这种问题处理如下。

JMM关于同步的规定

1、线程解锁前,必须把共享变量的值刷新主内存
2、线程加锁前,必须读取主内存的最新值到自己的工作内存
3、加锁解锁是同一把锁
为了继续说明,和volatile穿插。先介绍一下原子性问题,可见性问题,有序性问题这三个概念。
先有一个概念:volatile不保证原子性,synchronized都保证。 可见性就是一种多线程信息的同步机制。

可见性

t1、t2、t3线程都是从主内存拿出变量,在自己的工作内存中做一个变量副本,然后操作副本的值,修改之后,在同步到主内存,但是 t1修改之后同步到主内存后,如何保证t2和t3和主内存保持一致呢,这个机制就是可见性。
举个例子
现在要对Data里面的a元素加一,期望主线程和其他一个线程都获取到初始值之后,由其他线程改变a的之后,然后主线程里面获取到,期望的场景就是这样

public class VolatileDemo {
    public static void main(String[] args) {
        Data data = new Data();
        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + " is coming");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            data.addOne();
            System.out.println(Thread.currentThread().getName() + " has updated...");
        },"thread1").start();
        
        while (data.a == 0) {
            // looping
        }
        System.out.println(Thread.currentThread().getName() + " job is done...");
    }

}

class Data{
    int a = 0;
    void  addOne(){
        this.a += 1;
    }
}

上面的这个代码就是验证了可见性,对于新的线程thread1和主线程来说,都是把data的值拷贝到自己线程的工作区间去操作的,但是thread1线程操作后,已经把值更新成1,并且写回主内存了,但是没有保证可见性,那么main线程的值一直就是0,所以这个程序会一直走while去判断0。所以就是不可见。那么加上了volatile之后,就可以验证可见性。volatile的就可以解决可见性的问题。

volatile int a = 0;

现在就是只要有一个线程修改了值马上刷新同步回主内存,同时对其他线程可见。

原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
volatile已经说了是不支持原子操作的。
为了检验volatile不能保证原子性,举一个例子,就是20个线程做i++操作,循环1000次,最后的i结果应该是20000。 下面这个结果有可能是20000。但是实际上很难,以此来验证volatile不能保证原子性。

class Data {
    volatile int num = 0;
    public void addSelf(){
        num++;
    }
}

public class VolatileDemo {
    public static void main(String[] args) {
        atomicByVolatile();//验证volatile不保证原子性
    }

    public static void atomicByVolatile() {
        Data myData = new Data();
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    myData.addSelf();
                }
            }, "Thread " + i).start();
        }
        //等待上面的线程都计算完成后,再用main线程取得最终结果值
        //设置为2的原因是,默认有两个线程,一个是主线程,另外一个是gc线程
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "\t finally num value is " + myData.num);
    }
}

打印输出

main	 finally num value is 18622

对于这20个线程来说,线程t1从主内存拿到i=0的值,改为t1的时候,正常情况下t2或者t3都应该立即同步主内存的值,若此时t2或者t3发生了阻塞加塞,那么t2或者t3就会把自己的值去同步到主内存,从而发生写覆盖。
那volatile不保证原子性怎么解决呢,一般有两种方法
第一种:方法加synchronized
第二种:加Atomic

用第二种方法来尝试解决这个问题

class Data {
    volatile int num = 0;
    public void addSelf(){
        num++;
    }
    AtomicInteger atomicInteger = new AtomicInteger();
    public void atomicAddSelf(){
        atomicInteger.getAndIncrement();
    }
}

public class VolatileDemo {
    public static void main(String[] args) {
        atomicByVolatile();//验证volatile不保证原子性
    }
    /**
     * volatile不保证原子性
     * 以及使用Atomic保证原子性
     */
    public static void atomicByVolatile() {
        Data myData = new Data();
        for (int i = 1; i <= 20; i++) {
            new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    myData.addSelf();
                    myData.atomicAddSelf();
                }
            }, "Thread " + i).start();
        }

        //等待上面的线程都计算完成后,再用main线程取得最终结果值
        //设置为2的原因是,默认有两个线程,一个是主线程,另外一个是gc线程
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }

        System.out.println(Thread.currentThread().getName() + "\t finally num value is " + myData.num);
        System.out.println(Thread.currentThread().getName()+"\t finally atomicnum value is "+myData.atomicInteger);
    }
}

输出结果

main	 finally num value is 19882
main	 finally atomicnum value is 20000

AtomicInteger表示带有原子性的int类型,调用getAndIncrement()表示++。

所以,多线程环境下不要++。用getAndIncrement()

那么Atomic为什么能保证原子性呢?底层是什么呢?这个就涉及到CAS了。具体看CAS这部分CAS浅析

有序性

volatile禁止指令重排,有序性就是指令重排


public class ReSortDemo {
    int a = 0;
    boolean flag = false;

    public void method1(){
        a = 1;  //语句一
        flag = true; //语句二
    }

    //多线程环境中线程交替执行,由于编译器优化重排的存在
    //两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
    public void method2(){
        if(flag){
            a = a + 5;
            System.out.println("****reValue:" + a);
        }
    }
}

在单线程环境下先走method1再走method2,结果是6,但是在多线程环境下,语句一和语句二在个线程的执行顺序是被优化的,若先走语句一,再走语句二,是6,但是先走语句二,还没有走到语句一之后,先走了method2,那么结果就是5。volatile就可以防止这种情况,也就是禁止指令重排。

volatile 实现禁止指令重排序的优化,从而避免了多线程环境下程序出现乱序的现象

volatile使用的例子

那么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) {
        //构造方法只会被执行一次
//        System.out.println(getInstance() == getInstance());
//        System.out.println(getInstance() == getInstance());
//        System.out.println(getInstance() == getInstance());

        //构造方法会在一些情况下执行多次
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, "Thread " + i).start();
        }
    }
}

上面这个代码每次创建的单例模式是有问题的,可能创建出来多个,我们可以对方法getInstance()加synchronized,但是这样的话,会对整个方法都锁住,并不是很理想。真正需要控制的就是里面new这一行,所以有一个DCL模式的单例模式,也就是双重检验锁的单例模式。也就是前后都判断一次。

// DCL Double check lock 双重检验锁,加锁前后都进行判断
    public static SingletonDemo getInstance() {

        if (instance == null) {
            synchronized (SingletonDemo.class) {
                if (instance == null) {
                    instance = new SingletonDemo();
                }
            }
        }
        return instance;
    }

但是,DCL不一定保证线程安全,因为多线程存在指令重排,指令重排是什么呢,先看一个概念,内存屏障。
内存屏障(Memory Barrier)又称内存栅栏,是一个 CPU 指令,他的作用有两个:

  • 保证特定操作的执行顺序
  • 保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)

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

对于这个单例模式的例子来说:指令重排只会保证串行语义保证一致性(单线程),并不会关心多线程条件下的语义一致性
所以当一条线程访问instance不加null时候,由于instance实例未必已经初始化完成,所以也就造成了线程安全性问题。
所以需要加volatile关键字去禁止指令重排。

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

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

    // DCL Double check lock 双重检验锁,加锁前后都进行判断
    public static SingletonDemo getInstance() {

        if (instance == null) {
            synchronized (SingletonDemo.class) {
                if (instance == null) {
                    instance = new SingletonDemo();
                }
            }
        }
        return instance;
    }


    public static void main(String[] args) {
        //构造方法只会被执行一次
//        System.out.println(getInstance() == getInstance());
//        System.out.println(getInstance() == getInstance());
//        System.out.println(getInstance() == getInstance());

        //构造方法会在一些情况下执行多次
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, "Thread " + i).start();
        }
    }
}

线程的安全性保证

工作内存和主内存之间的同步延迟现象导致的可见性问题,可以通过volatile和synchronized关键字来解决,他们都可以使得一个线程修改变量的值后立即对其他的值可见。 关于指令重排导致的可见性和有序性问题,可以用volatile关键字解决,因为volatile的另一个作用就是禁止指令重排。

posted @ 2020-03-08 17:25  晓看天色暮看云  阅读(175)  评论(0编辑  收藏  举报