暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

学习volatile这一篇就够了

Java贼船 2020-07-31
145

 

湿兄会持续更新和分享技术文章,点赞关注不迷路~


在这之前的几篇文章:


我们上一篇说到了元老级角色Synchronized锁,分析了使用和底层原理,在面试中只要提及Synchronized,十次有九次都会提及volatile,另外一次估计是忘了,所以这一篇volatile很重要,建议按照顺序阅读

本篇我们主要开说的是volatile关键字,对于这个关键字,很多朋友都听说过,甚至使用过,但是可能很多朋友的认知也只是处于使用阶段,今天我们一起来换个角度来重新认识一下吧!

前言

让我们先来看一段代码:

按照正常逻辑来讲,应该是主线程在一秒之后将flag置为true,然后子线程testThread检测到然后跳出while循环,打印status is stop这些

可是,你会发现死循环了,永远不会输出这个,按照正常来说flag变为true,主线程也能访问到啊?可是为什么访问不到了呢?接下来我们来看看

计算机模型和JMM内存模型

首先科普一下CPU缓存,CPU缓存是指可以进行高速数据交换的存储器,它先于内存与CPU交换数据,因此速率很快。

缓存的工作原理是当CPU要读取一个数据的时候,首先在CPU缓存中查找,找到就立即读取并送给CPU处理;没有找到,就从速率相对较慢的内存中读取并送给CPU处理,同时把这个数据所在的数据块调入缓存中,可以使得以后对整块数据的读取都从缓存中进行,不必再调用内存。

当程序在运行过程中,会将运算需要的数据从主存复制一份到CPU的高速缓存当中,那么CPU进行计算时就可以直接从它的高速缓存读取数据和向其中写入数据,当运算结束之后,再将高速缓存中的数据刷新到主存当中。

举个例子:

    a = a + 1

    检查在高速缓存中是否存在a这个值,如果有直接从缓存中取得,并且放入到缓存中,然后cpu对a进行加一,然后把数据写入缓存,最后再把缓存中的数据刷新到主内存中去

    其实你会发现这一过程在单线程中是没有问题的,但是在多线程中会出现问题。现在的电脑都是多核cpu,这样每个线程可能会运行在不同的cpu,造成cpu缓存也会存在多个。这时如果同一个变量出现在多个高速缓存中就有可能出现问题

    两个线程m、n,分别读取了a的值,假设此时的值是0,并且把这个值读取到cpu缓存中。线程m读取a值并加一,变成1,写回到主存中。线程n读取自己缓存的值0并加一也变成1,写回主存。此时a的值应该是2,而事实却是1,出现了线程不安全的问题

    JMM(JavaMemoryModel):Java内存模型,是java虚拟机规范的一种内存模型,属于一个标准,屏蔽掉了底层(和JVM不是一个东西),描述了Java中各种变量的访问规则

    所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。

    每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本线程对变量的所有操作都必须在工作内存中完成,而不能直接读写主内存的变量

    本地变量和主内存的关系:


    正是因为上面这种,导致出现了多线程不安全的问题,也就是可见性问题

    volatile关键字

    volatile有这么几个特点

    • 可见性:主要解决上面多线程问题

    • 有序性:编译优化

    • 不具备原子性

    我们来看上面的代码如何修改:

    加上了volatile关键字,会发现这个死循环消失了,退出while循环并且成功执行后面的语句了,我们来分析它是如何保证的线程安全问题的

    可见性

    多线程环境下,每个线程操作数据的时候会把数据从主内存读取到自己的工作内存,如果它操作了数据并且写入了,其它已经读取的线程的变量副本就会失效了,需要都数据进行操作又要再次去主内存中读取了。

    volatile保证的是不同线程对共享变量的操作的可见性,也就是说一个线程修改了volatile变量之后,其余线程的变量副本立即失效,并且该变量立即写回到主内存中,另外的线程也可以看到最新的变量

    比如我们上面分析的代码例子,flag是volatile变量时,主线程修改成true时立即使子线程testThread的变量副本失效,并且同时把这个变量的值刷新到主内存中,这样便保证了下次testThread子线程再需要这个值的时候只能从主内存中读取最新值true

    可见性原理:


    为了解决可见性问题,需要各种类型处理器访问缓存的时候都需要遵循一些协议,在读写时要根据协议来进行操作,这类协议有很多,我们主要看Intel的MESI缓存一致性协议

    MESI缓存一致性协议:当CPU写数据的时候,如果发现操作的这个数据是共享变量,即把其它缓存了该变量副本的cpu缓存设置为无效状态,如果其它cpu使用这个变量的时候发现自己的缓存中变量的缓存行是无效的,那么它就会从内存中读取

    如何发现数据是否失效?嗅探线程中的处理器会一直在总线上嗅探其内部缓存中的内存地址在其他处理器的操作情况,一旦嗅探到某处处理器打算修改其内存地址中的值,而该内存地址刚好也在自己的内部缓存中,那么处理器就会强制让自己对该缓存地址的无效。所以当该处理器要访问该数据的时候,由于发现自己缓存的数据无效了,就会去主存中访问。

    嗅探也是存在缺点的:总线风暴,由于volatile关键字的MESI缓存一致性协议,需要不断的从主内存通过嗅探和CAS不断循环,无效的交互会导致总线的宽带到达一个峰值,所以使用这个关键字也要根据场景来看

    有序性:

    当我们把代码写好之后,虚拟机并不一定会按照我们写代码的顺序来执行,虚拟机会为了提高性能,编译器和处理器来对代码执行顺序进行指令的重排序

    关于有序性的一些,我们在一篇搞懂Synchronized底层实现这一篇已经介绍过了,关于概念这块不多解释了,举个例子介绍

      int a =1; int b = 2;

      对于这两个代码,会发现两者之间没关系,先执行哪一句都不会对最终结果造成影响,所以虚拟机则有可能对他们进行重排序执行

      为什么呢?你想啊,如果int a = 1这一句代码需要执行100ms,而执行int b = 2这一句代码可能需要执行2ms时间,并且这两个先执行哪个都不会对结果造成影响,所以虚拟机会先选择时间短的来执行

      看一张volatile的重排序规则表

      举例来说:第三行最后一个单元的意思是,程序中当第一个操作变量为普通变量的读或者写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作

      虽然重排序可以提高效率,对最终结果没有影响,但是在多线程下会有安全问题

      那volatile是如何保证不会被重排序,保证线程安全的呢?

      内存屏障:为了实现volatile内存语义,编译器在生成字节码的时候,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎是不可能的,所以JMM采取保守策略,我们看屏障如何插入

      需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读则是在后面插入两个内存屏障

      写的示意图:


      读的示意图:

      如果一个变量被声明volatile的话,那么这个变量不会被进行重排序,也就是说,虚拟机会保证这个变量之前的代码一定会比它先执行,而之后的代码一定会比它慢执行。

      不过需要注意的一点是,虚拟机只是保证这个变量之前的代码一定比它先执行,并没有保证这个变量之前的代码不可以重排序,之后的也是同理

      无法保证原子性:

      我们发现volatile关键字还是很强大的,不但能够保证可见性,还可以保证有序性,但是这个关键字修饰的变量在多线程环境下真的一定能够被正确使用吗?

      既然我都这么问了,大家也都知道了,肯定是否定的,因为volatile并不能保证变量的操作的原子性

      不过需要注意的一点是,虚拟机只是保证这个变量之前的代码一定比它先执行,并没有保证这个变量之前的代码不可以重排序,之后的也是同理

      原子性:一个操作,要么全部执行成功,要么全部失败

      看个例子:

         public static volatile int t = 0;


        public static void main(String[] args){


        Thread[] threads = new Thread[10];
        for(int i = 0; i < 10; i++){
        每个线程对t进行1000次加1的操作
        threads[i] = new Thread(new Runnable(){
        @Override
        public void run(){
        for(int j = 0; j < 1000; j++){
        t = t + 1;
        }
        }
        });
        threads[i].start();
        }


        等待所有累加线程都结束
        while(Thread.activeCount() > 1){
        Thread.yield();
        }


        打印t的值
        System.out.println(t);
        }


        正常来说,最终的结果是10000,答案是这个样子吗?我们发现运行了很多次却得不到这个结果,怎么回事?

        问题就出现在t = t + 1这句代码中。我们来分析一下:

        线程1读取了t的值,假如t = 0。之后线程2读取了t的值,此时t = 0。
        然后线程1执行了加1的操作,此时t = 1。但是这个时候,处理器还没有把t = 1的值写回主存中。这个时候处理器跑去执行线程2,注意,刚才线程2已经读取了t的值,所以这个时候并不会再去读取t的值了,所以此时t的值还是0,然后线程2执行了对t的加1操作,此时t =1 。

        所以这个时候就出现线程安全的问题了,volatile并不一定能保证变量的安全性

        什么情况下使用volatile能保证线程安全?

        volatile关键字不一定能够保证线程安全的问题,其实,在大多数情况下volatile还是可以保证变量的线程安全问题的。所以,在满足以下两个条件的情况下,volatile就能保证变量的线程安全问题:

        • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。

        • 变量不需要与其他状态变量共同参与不变约束。

        volatile和Synchronized的区别:

        • Synchronized可以修饰方法、代码块,volatile只能修饰实例变量或者类变量

        • Synchronized是一种互斥机制,volatile可以保证数据的可见性、但是不保证原子性

        • volatile可以看做是轻量级的Synchronized如果是对一个共享变量进行多个线程的赋值,而没有其他的操作,那么就可以用volatile来代替synchronized

        • volatile用于禁止指令重排序,可以解决单例双重检查对象初始化代码执行乱序问题

        絮叨叨

        你知道的越多,你不知道的也越多。

        滴滴:如果能掌握上面说的volatile这些,相信在面试官那里也会加分了,大家还是多读一些相关的书去完善自己的知识体系,学习急不得,慢慢来


         觉得不错的可以给大湿兄来个关注,也欢迎可爱帅气的你推荐给你的朋友,转发和点赞是可以给湿兄多打打气~~


        文章转载自Java贼船,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

        评论