volatile学习

作者: lbcBoy | 来源:发表于2018-05-14 16:39 被阅读4次

    目录:
    1.volatile是什么
    2.volatile的作用
    3.volatile的原理
    4.volatile与synchronized有什么区别
    5.并发编程中的三个概念
    6.使用volatile关键字的场景
    7.CPU缓存的相关知识
    8.学习成果检查

    1.volatile是什么

    volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销,倘若能恰当的合理的使用volatile,自然是美事一桩。
    Java语言规范对volatile的定义如下:
    Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
    上面比较绕口,通俗点讲就是说一个变量如果用volatile修饰了,则Java可以确保所有线程看到这个变量的值是一致的,如果某个线程对volatile修饰的共享变量进行更新,那么其他线程可以立马看到这个更新,这就是所谓的线程可见性


    2.volatile的作用

    2.1.volatile保证可见性

    一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
    1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
    2)禁止进行指令重排序。

    2.2.volatile不能确保原子性

    原因:自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。
    解决方案:可以通过synchronized或lock,进行加锁,来保证操作的原子性。也可以通过AtomicInteger。
    在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

    2.3.volatile保证有序性

    在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
    volatile关键字禁止指令重排序有两层意思:
    1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
    2)在进行指令优化时,不能将在对volatile变量的读操作或者写操作的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。


    3.volatile的原理

    3.1.可见性

    处理器为了提高处理速度,不直接和内存进行通讯,而是将系统内存的数据独到内部缓存后再进行操作,但操作完后不知什么时候会写到内存。

    如果对声明了volatile变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会到系统内存。 这一步确保了如果有其他线程对声明了volatile变量进行修改,则立即更新主内存中数据。

    但这时候其他处理器的缓存还是旧的,所以在多处理器环境下,为了保证各个处理器缓存一致,每个处理会通过嗅探在总线上传播的数据来检查 自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据读到处理器缓存里。 这一步确保了其他线程获得的声明了volatile变量都是从主内存中获取最新的。

    指令重排:指令重排序是JVM为了优化指令,提高程序运行效率,在不影响单线程程序执行结果的前提下,尽可能地提高并行度。编译器、处理器也遵循这样一个目标。注意是单线程。多线程的情况下指令重排序就会给程序员带来问题。

    3.2.有序性

    Lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成。

    3.3 lock前缀

    下面这段话摘自《深入理解Java虚拟机》:

    “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”
      lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:
      1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;(有序性)
      2)它会强制将对缓存的修改操作立即写入主存;(可见性)
      3)如果是写操作,它会导致其他CPU中对应的缓存行无效。(可见性)


    4.volatile与synchronized有什么区别

    volatile synchronized
    volatile是一个变量修饰符 synchronized是一个方法或块的修饰符
    volatile本质是在告诉jvm当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取 synchronized则是锁定当前变量,只有当前线程可以访问该变量,其他线程被阻塞住
    volatile仅能使用在变量级别 synchronized则可以使用在变量、方法、和类级别的
    volatile仅能实现变量的修改可见性,不能保证原子性 而synchronized则可以保证变量的修改可见性和原子性
    volatile不会造成线程的阻塞 synchronized可能会造成线程的阻塞
    volatile标记的变量不会被编译器优化 synchronized标记的变量可以被编译器优化

    5.并发编程中的三个概念

    在并发编程中,我们通常会遇到以下三个问题:原子性问题,可见性问题,有序性问题。我们先看具体看一下这三个概念:

    5.1.原子性

    原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

    范例1-银行账户转账问题

    比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
    
    试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。
    然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。
    这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
    所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
    

    同样地反映到并发编程中会出现什么结果呢?

    范例2:32位的变量赋值过程:

    i = 9
    假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
    那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。
    

    5.2.可见性

    可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

    举个简单的例子,看下面这段代码:

    //线程1执行的代码
    int i = 0;
    i = 10;
    
    //线程2执行的代码
    j = i;
    

    假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。

    此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.

    这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。

    5.3.有序性

    有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:

    int i = 0;              
    boolean flag = false;
    i = 1;                //语句1  
    flag = true;          //语句2
    

    上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)

    下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

    比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。

    但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子1:

    int a = 10;    //语句1
    int r = 2;    //语句2
    a = a + 3;    //语句3
    r = a*a;     //语句4
    

    这段代码有4个语句,那么可能的一个执行顺序是:
    语句2->语句1->语句3->语句4

    那么可不可能是这个执行顺序呢:
    语句2->语句1->语句4->语句3

    不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。

    虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子2:

    //线程1:
    context = loadContext();   //语句1
    inited = true;             //语句2
     
    //线程2:
    while(!inited ){
      sleep()
    }
    doSomethingwithconfig(context);    
    

    上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。

    从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性

    也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。


    6.使用volatile关键字的场景

    synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
    1)对变量的写操作不依赖于当前值
    2)该变量没有包含在具有其他变量的不变式中

    实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。
    事实上,我的理解就是上面的2个条件需要保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

    下面列举几个Java中使用volatile的几个场景。

    6.1.状态标记量
        volatile boolean flag = false;
         
        while(!flag){
            doSomething();
        }
         
        public void setFlag() {
            flag = true;
        }
    

    这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。

    下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将flag变量的值拷贝一份放在自己的工作内存当中。
      那么当线程2更改了flag变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对flag变量的更改,因此还会一直循环下去。

    但是用volatile修饰之后就变得不一样了:
      第一:使用volatile关键字会强制将修改的值立即写入主存;
      第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量flag的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
      第三:由于线程1的工作内存中缓存变量flag的缓存行无效,所以线程1再次读取变量flag的值时会去主存读取。
      那么在线程2修改flag值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量flag的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。
      那么线程1读取到的就是最新的正确的值。


        volatile boolean inited = false;
        //线程1:
        context = loadContext();  
        inited = true;            
         
        //线程2:
        while(!inited ){
        sleep()
        }
        doSomethingwithconfig(context);
    

    解释参考5.3的例子2

    6.2.单例模式中的double check
        class Singleton{
        private volatile static Singleton instance = null;
     
        private Singleton() {
     
        }
     
        public static Singleton getInstance() {
            if(instance==null) {
                synchronized (Singleton.class) {
                    if(instance==null)
                        instance = new Singleton();
                }
            }
            return instance;
        }
    }
    

    为什么要使用volatile 修饰instance?
    主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:
    1.给 instance 分配内存
    2.调用 Singleton 的构造函数来初始化成员变量
    3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。

    但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。


    7.CPU缓存的相关知识

    7.1.CPU缓存

    CPU缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾,因为CPU运算速度要比内存读写速度快得多,举个例子:

    • 一次主内存的访问通常在几十到几百个时钟周期
    • 一次L1高速缓存的读写只需要1~2个时钟周期
    • 一次L2高速缓存的读写也只需要数十个时钟周期

    这种访问速度的显著差异,导致CPU可能会花费很长时间等待数据到来或把数据写入内存。

    基于此,现在CPU大多数情况下读写都不会直接访问内存(CPU都没有连接到内存的管脚),取而代之的是CPU缓存,CPU缓存是位于CPU与内存之间的临时存储器,它的容量比内存小得多但是交换速度却比内存快得多。而缓存中的数据是内存中的一小部分数据,但这一小部分是短时间内CPU即将访问的,当CPU调用大量数据时,就可先从缓存中读取,从而加快读取速度。

    按照读取顺序与CPU结合的紧密程度,CPU缓存可分为:

    • 一级缓存:简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存。
    • 二级缓存:简称L2 Cache,分内部和外部两种芯片,内部芯片二级缓存运行速度与主频相同,外部芯片二级缓存运行速度则只有主频的一半。
    • 三级缓存:简称L3 Cache,部分高端CPU才有。

    每一级缓存中所存储的数据全部都是下一级缓存中的一部分,这三种缓存的技术难度和制造成本是相对递减的,所以其容量也相对递增。

    CPU要读取一个数据时,首先从一级缓存中查找,如果没有再从二级缓存中查找,如果还是没有再从三级缓存中或内存中查找。一般来说每级缓存的命中率大概都有80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取。

    7.2.使用CPU缓存带来的问题

    用一张图表示一下 CPU –> CPU缓存 –> 主内存 数据读取之间的关系:

    image

    当系统运行时,CPU执行计算的过程如下:

    1. 程序以及数据被加载到主内存
    2. 指令和数据被加载到CPU缓存
    3. CPU执行指令,把结果写到高速缓存
    4. 高速缓存中的数据写回主内存

    如果服务器是单核CPU,那么这些步骤不会有任何的问题,但是如果服务器是多核CPU,那么问题来了,以Intel Core i7处理器的高速缓存概念模型为例(图片来自《深入理解计算机系统》):

    image

    试想下面一种情况:

    1. 核0读取了一个字节,根据局部性原理,它相邻的字节同样被被读入核0的缓存
    2. 核3做了上面同样的工作,这样核0与核3的缓存拥有同样的数据
    3. 核0修改了那个字节,被修改后,那个字节被写回核0的缓存,但是该信息并没有写回主存
    4. 核3访问该字节,由于核0并未将数据写回主存,数据不同步

    为了解决这一问题,CPU制造商规定了一个缓存一致性协议

    7.3.缓存一致性协议

    每个CPU都有一级缓存,但是,我们却无法保证每个CPU的一级缓存数据都是一样的。 所以同一个程序,CPU进行切换的时候,切换前和切换后的数据可能会有不一致的情况。那么这个就是一个很大的问题了。 如何保证各个CPU缓存中的数据是一致的。就是CPU的缓存一致性问题。

    7.4.总线锁

    一种处理一致性问题的办法是使用Bus Locking(总线锁)。当一个CPU对其缓存中的数据进行操作的时候,往总线中发送一个Lock信号。 这个时候,所有CPU收到这个信号之后就不操作自己缓存中的对应数据了,当操作结束,释放锁以后,所有的CPU就去内存中获取最新数据更新。

    但是用锁的方式总是避不开性能问题。总线锁总是会导致CPU的性能下降。所以出现另外一种维护CPU缓存一致性的方式,MESI。

    7.5.MESI

    MESI是保持一致性的协议。它的方法是在CPU缓存中保存一个标记位,这个标记位有四种状态:

    • M: Modify,修改缓存,当前CPU的缓存已经被修改了,即与内存中数据已经不一致了;
    • E: Exclusive,独占缓存,当前CPU的缓存和内存中数据保持一致,而且其他处理器并没有可使用的缓存数据;
    • S: Share,共享缓存,和内存保持一致的一份拷贝,多组缓存可以同时拥有针对同一内存地址的共享缓存段;
    • I: Invalid,失效缓存,这个说明CPU中的缓存已经不能使用了。

    CPU的读取遵循下面几点:

    • 如果缓存状态是I,那么就从内存中读取,否则就从缓存中直接读取。
    • 如果缓存处于M或E的CPU读取到其他CPU有读操作,就把自己的缓存写入到内存中,并将自己的状态设置为S。
    • 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M。

    这样,每个CPU都遵循上面的方式则CPU的效率就提高上来了。


    8.学习成果检查

    面试官:Java并发这块了解的怎么样?说说你对volatile关键字的理解。
    面试官:能不能详细说下什么是内存可见性,什么又是指令重排呢?
    面试官:那你具体说说并发编程的三个特性呢?
    面试官:volatile关键字如何满足并发编程的三大特性的?
    面试官:volatile的两点内存语义能保证可见性和有序性,但是能保证原子性吗?
    面试官:那你知道volatile底层的实现机制?
    面试官:你在哪里会使用到volatile,举两个例子呢?
    面试官:单例模式的几种实现方式?能手写一下代码吗?


    感谢网友的分享:
    http://www.importnew.com/24082.html
    http://www.cnblogs.com/dolphin0520/p/3920373.html
    https://blog.csdn.net/wanghai__/article/details/6260178
    http://www.techug.com/post/java-volatile-keyword.html

    相关文章

      网友评论

      本文标题:volatile学习

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