美文网首页
volatile的作用和实现

volatile的作用和实现

作者: 前度天下 | 来源:发表于2020-05-26 09:52 被阅读0次

    volatile关键词已出现就应该联想到两个方面一个是JAVA内存模型(JMM)、一个是多线程编程。看来得写一篇多线程编程,防止忘记!
    volatile可以当之无愧的被称为Java并发编程中“出现频率最高的关键字”。

    随着计算机的发展,CPU和内存之间有一个高速缓存,单核CPU的性能不可能无限增长,所以就有了多个CPU协同工作。这样每个cpu都有一个自己的高速缓存,这样引入了一个新的问题就是缓存一致性问题



    那就要解决这个问题(缓存一致性问题)intel公司就搞了一个缓存一致性协议 MESI协议(他的目的是保证每个缓存中使用的共享变量的副本是一致的)
    MESI协议的核心思想:当cpu写数据时,如果发现操作的变量是共享变量(也就是说在其他cpu中也存在这个变量的副本),会发出信号通知其他cpu将该变量的缓存设置为无效状态,因此当其他cpu需要读取这个变量的时候,发现自己缓存中该变量是无效的,他就会从main内存中重新读取。

    上面说的是现代计算机的内存模型。

    下面这个是java内存模型 (其实跟上面的结构是差不多的,把工作内存看成是高速缓存)

    JMM(JavaMemoryModel)中文名叫Java内存模型

    为啥要搞一个这个?是java虚拟机规范中所定义的一种内存模型,屏蔽掉了底层不同计算机的区别。
    既然是规范那就有一些规矩:

    • 所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
    • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
    • 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。
    • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。
    public class App {
    
        static class Test extends Thread {
            private boolean flag = false;
    
            public boolean isFlag() {
                return flag;
            }
    
            @Override
            public void run() {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                flag = true;
                System.out.println("flag = "+flag);
            }
        }
    
        public static void main(String[] args) {
            Test test = new Test();
            test.start();
            for (;;){
                if(test.isFlag()){
                    System.out.println("有点东西");
                }
            }
        }
    }
    

    这段代码主线程一直都不会输出“有点东西”这几个字。这就是变量是对应其他线程是不可见的。
    那如何解决可见性问题呢?
    我们只需要把工作内存的值清空,去主内存获取最新的值不就把问题解决了。

    public class App {
    
        static class Test extends Thread {
            private boolean flag = false;
    
            public boolean isFlag() {
                return flag;
            }
    
            @Override
            public void run() {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                flag = true;
                System.out.println("flag = "+flag);
            }
        }
    
        public static void main(String[] args) {
            Test test = new Test();
            test.start();
            for (;;){
                synchronized (test){
                    if(test.isFlag()){
                        System.out.println("有点东西");
                    }
                }
            }
        }
    }
    

    我们加了synchronized代码块就解决了,为什么就解决了呢?因为线程获得锁的同时会清空工作内存,从主内存中拷贝最新的值到工作内存,执行代码,将修改后的副本的值刷新回主内存中,线程释放锁。而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。其实通过synchronized和Lock也能够保证可见性,线程在释放锁之前,会把共享变量值都刷回主存,但是synchronized和Lock的开销都更大。

    public class App {
    
        static class Test extends Thread {
            private volatile boolean flag = false;
    
            public boolean isFlag() {
                return flag;
            }
    
            @Override
            public void run() {
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                flag = true;
                System.out.println("flag = "+flag);
            }
        }
    
        public static void main(String[] args) {
            Test test = new Test();
            test.start();
            for (;;){
                if(test.isFlag()){
                    System.out.println("有点东西");
                }
            }
        }
    }
    

    不要synchronized同步锁,直接在变量上加上volatile修饰也可以打印出“有点东西”,说明这也是保证可见性的解决方法。
    我们把synchronized锁给去掉了,就会存在线程之间的竞争关系,就会引发原子性的问题。



    如果两个线程按照上面的执行流程,那么i最后的值居然是1了。如果最后的写回生效的慢,你再读取i的值,都可能是0,这还是会引发缓存不一致问题。

    先记住volatile的作用就是保证变量的可见性和防止指令重排,弊端是不能保证原子性操作。

    到这里为止,就引发了以下个问题:

    • 第一个就是volatile是如何解决可见性问题?
    • 第二个问题是如何解决原子性问题?
    • 第三个问题如何防止指令重排?

    要想解决缓存一致性问题。就必须要保证原子性操作、可见性和有序性(防止指令重排)

    volatile是如何解决可见性问题?

    可见性:所有线程都可以看到共享内存中最新的状态。

    要想实现变量的可见性,就必须每次读取前必须先从主内存刷新最新的值;每次写入后必须立即同步回主内存当中。

    java线程读取一个变量的过程(也就是从主内存读取到工作内存的过程)也就是 read 、load、 use过程
    java线程写会一个变量的过程(也就是从工作内存写到主内存的过程)也就是assign、store、write过程

    以下八种就是java的原子操作完成工作内存与主内存的交互:

    • lock:作用于主内存,把变量标识为线程独占状态。
    • unlock:作用于主内存,解除独占状态。
    • read:作用主内存,把一个变量的值从主内存传输到线程的工作内存。
    • load:作用于工作内存,把read操作传过来的变量值放入工作内存的变量副本中。
    • use:作用工作内存,把工作内存当中的一个变量值传给执行引擎。
    • assign:作用工作内存,把一个从执行引擎接收到的值赋值给工作内存的变量。
    • store:作用于工作内存的变量,把工作内存的一个变量的值传送到主内存中。
    • write:作用于主内存的变量,把store操作传来的变量的值放入主内存的变量中。

    volatile 让read 、load 、use动作必须连续出现。assign、store、write动作连续出现就可以实现变量的可见性。

    以上就是解释了volatile是如何解决可见性问题的。

    如何解决原子性问题?

    一提到原子性问题,我们就要第一时间联想到 i++ 的操作,就必须要要借助锁的机制(synchronized或Lock)才能保证原子性的操作。那这里就是synchronized和Lock的内容了,看来这里又要写一篇文章了。

    如何防止指令重排?

    说到指令重排,就应该联想到:什么是指令重排?指令重排会在哪些时候触发,什么时候又不会触发?指令重排会引发什么问题?

    在计算机执行指令的顺序在经过程序编译器编译之后形成的指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。


    写后读     a = 1;b = a;    写一个变量之后,再读这个位置。  
    写后写     a = 1;a = 2;    写一个变量之后,再写这个变量。  
    读后写     a = b;b = 1;    读一个变量之后,再写这个变量。 
    

    进过分析,发现这里每组指令中都有写操作,这个写操作的位置是不允许变化的,否则将带来不一样的执行结果。
    编译器将不会对存在数据依赖性的程序指令进行重排,这里的依赖性仅仅指单线程情况下的数据依赖性;多线程并发情况下,此规则将失效。

    不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器,runtime 和处理器都必须遵守as-if-serial语义。

    double pi  = 3.14;    //A  
    double r   = 1.0;     //B  
    double area = pi * r * r; //C  
    

    分析代码: A->C B->C; A,B之间不存在依赖关系; 故在单线程情况下, A与B的指令顺序是可以重排的,C不允许重排,必须在A和B之后。

    as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

    核心点还是单线程,多线程情况下不遵守此原则。
    happens-before规则 : 有点复杂,没搞懂!不知道啥意思? 谁能解释一下这里 完全说明白!目前我不会。

    Volatile是怎么保证不会被执行重排序的呢?
    内存屏障 volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。

    哪些地方会使用到volatile?

    1、状态标记。
    2、单例模式,双重检查锁定。(听说单例有8种写法) 8种单例模式的写法


    为什么要双重检查呢?创建对象有三个步骤:
    1. 分配内存空间。
    2. 调用构造器,初始化实例。
    3. 返回地址给引用

    上面的三步步骤是可能发生指令重排序的,那有可能构造函数在对象初始化完成前就赋值完成了,在内存里面开辟了一片存储区域后直接返回内存的引用,这个时候还没真正的初始化完对象。

    但是别的线程去判断instance!=null,直接拿去用了,其实这个对象是个半成品,那就有空指针异常了。
    因为可见性,线程A在自己的内存初始化了对象,还没来得及写回主内存,B线程也这么做了,那就创建了多个对象,不是真正意义上的单例了。

    相关文章

      网友评论

          本文标题:volatile的作用和实现

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