2

1.Volatile关键字是什么?

2.Volatile关键字的作用

2.1 JMM(JAVA内存模型)的理解

2.2 Volatile的保证可见性

2.3 Volatile禁止指令重排序

3.用volitile改写单例模式

1.Volatile关键字是什么?
我们都知道synchronize关键字,是一种重量型的同步机制,相对而言,volitile是一种轻量级的同步机制,volatile字面有“易挥发”的意思,Volatile关键字用于修饰共享可变变量,因为被修饰的值容易变化(容易被其它线程更改),因为不确定。volatile它有与锁相同的作用:保证可见性和有序性,所不同的是,在原子性方面只保证写变量操作的原子性,单没有锁排他性。

2.Volatile关键字的作用
1.保证可见性
2.不保证原子性
3.禁止指令重排

我们先解释第一点,保证可见性,可见性我们要从JMM(JAVA内存模型)开始讲起

2.1 JMM(JAVA内存模型)的理解

JMM并不真实存在,它描述的是一组规范

JVM工作线程工作的时候,是有自己的工作内存的,工作内存是每个线程的私有区域,而java所有变量都保存在主存,主存是共享区域。线程在运行的时候,会先把变量拷贝一份到自己的工作内存,然后对变量进行赋值操作,操作完成后再将变量写会主存。并不能直接操作主内存变量,各个线程的工作内存中存储着主存的变量副本拷贝,因此不同的线程无法访问对方的工作主存,线程的通信必须通过内存来完成,这就是JMM。

image.png

2.2 Volatile的保证可见性
知道了JMM线程模型之后,我们就可以知道,主内存的变量,都是要被拷贝到工作线程的工作空间后,再进行操作。如果此时主内存理得变量发生变化,工作线程是感知不到的,我们可以用代码来进行示范:

public class MyData {
    int  number = 0;


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

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

我们先定义一个类,MyData,只有一个普通int变量。

再定义一个测试类:

public class VisibleTest {

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

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "comm in");
            myData.number = myData.number ++;
            System.out.println("myData"+myData.number);
        }).start();


        while(myData.number==0){
            System.out.println(myData.number);
        }
        System.out.println(Thread.currentThread().getName()+"over");

    }
}

此时我们通常会认为,number随着变量值的修改,会停止while循环的操作,但是结果并不会。
image.png

这是因为变量在拷贝回线程,修改值后并没有返回主存,导致另外一个线程感知不到,现在我们加上Volatile关键字:

volatile int  number = 0;

image.png
线程顺利结束了!

这是因为加上volatile后,每次线程都要去主存来读取值,每次写完值后都要放回主存!

我们查看字节码文件:
image.png

其中 getfield 和putfield 都是从主存中获取和修改值。

但是volatile只保证读和写的和见性,并没有保证写的排他性,所以也就没有保证原子性。

2.2.1 内存屏障指令

volatile之所以能保证可见性和防止重排序,是因为他的底层是内存屏障指令:

内存屏障,是一个cpu指令,作用有两个:
一是保证特定操作的执行顺序
二是保证某些变量的内存可见性

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

2.3 Volatile禁止指令重排序

关于指令重排,我们先来举一个例子:

        int x = 11; //1
        int y = 15; //2
        x = x + 5;  //3
        y = x * x;  //4

这是四条简单的代码,我们都知道最后x = 16,y = 16 * 16
如果我们不知道指令重排序的话,可能只是简单地以为,语句的执行顺序为1234而已。

其实,计算机在执行程序的时候,为了提高性能,编译器和处理器常常会对指令进行重排,一般有以下三种:

源代码-> 编译器优化的重排->指令并行的重排->内存系统的重排->最终执行的指令

单线程环境里确保程序最终执行结果和代码执行的结果一致:也就是说,单线程环境无论如何重排指令,最终的结果都是一致的。

处理器在进行重排序时必须考虑指令之间的数据依赖性:也就是说,上述命令,4一定在3后面,1一定在3前面,不然就无法保证最终一致性了。

好的,单线程环境下没什么问题,接下来我们看多线程环境下:
image.png

两个线程同时运行时吗,就会出现这种结果,也可能会出现下面这种结果:
image.png

这样最终的结果就会不确定了,而volatile关键字就避免了指令重排序,按照编码的顺序来进行编译执行!

3.用volitile改写单例模式
通常我们认为的单例模式,在单线程下,都是这么写的

public class SingletomDeomo {

    private static volatile SingletomDeomo instance = null;

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

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

但是在多线程环境下,运行起来就会产生多个实例,那是因为由于上下文的切换,很多线程在判断完if为空后,时间片被别的线程夺去,然后别的线程又new了instance,导致现在的线程夺回时间片后,又会继续new一个对象。

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                SingletomDeomo.getInstance();
            }).start();
        }
    }

为了解决这个问题,我们可以在这个方法上加上synchronize关键字,但是这样运行起来太重,我们可以使用dcl(double check lock) 双重校验锁来解决。

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

此时,如果instance 不加volatile关键字,还是会有问题。

我们要仔细分析一下instance = new SingletomDemo();里的步骤:

  • 1.memory = allocate();
  • 2.instance(memory);
  • 3.instance = memory;

大概就是这么三步:
1.分配一块内存区域
2.将这块内存区域初始化
3.将这块内存区域分配给instance

注意:此时,这三步也是可以被重排序的!

所以,在instance上加上volatile就没事了!

以上便是对volatile的学习笔记。


苏凌峰
73 声望38 粉丝

你的迷惑在于想得太多而书读的太少。