zoukankan      html  css  js  c++  java
  • volatile关键字——深刻理解

    将并发分析的切入点分为两个核心,三大性质:

    两大核心:JMM内存模型(主内存和工作内存)以及happens-before

    三条性质:原子性可见性有序性

    1.Java内存模型及volatile基本概念

    1.1 计算机内存概要

    volatile修饰符其实不是Java语言首创,早在C和C++傍边就已经存在。在讲解Java的volatile关键字之前,有需要先了解一下Java的内存模型。

    背景:计算机执行程序的时候每条指令都是在cpu中执行的,那么执行指令的同时势必会有读取和写入的操作,那么这样就引申出了一个问题。那么在程序运行时数据的存储是在计算机中的主存中(物理内存)的而内存的读取和写入的速度与cpu的执行指令速度相比差距是很大的,这样就造成了与内存交互时程序执行效率大大降低,因此在cpu中就有了高速缓存。

    也就说计算机cpu在执行指令时将主存中的数据复制到高速缓存中,将结果运算完毕后在将运算结果刷新到主存中。废话不多说看图说话:

    计算机cpu会将运算数据复制到高速缓存中。但是这样如果在单线程是没有问题的(单核多线程也是有问题的)。举例说明:

    i=i+1;

    假设i变量初始值为0,这段代码理想结果是2,单CPU并不一定这样来计算,假如有AB两个线程,同时读取了这段代码,同时将变量复制到了高速缓存,A线程将数据执行完毕后将变量刷新到主存中i=1,而B线程也同时执行完毕将变量也刷新到主存中i=1,但是AB变量在运算时读取的都是高速缓存的,AB线程的高速缓存是互相不知道其中的值的,那么这样就引申出了缓存一致性问题

    为了解决缓存一致性问题,有两个办法:

    1. 通过总线加LOCK锁的方式
    2. 通过缓存一致协议
    早期的cpu中是通过在总线加锁来解决缓存不一致的,因为计算机cpu通信是通过总线来执行的,如果在总线上面加锁的话就阻塞来其他cpu对该变量的访问,如上面的代码在程序执行时总线发出lock指令,那么只有在这段代码执行完毕后其他cpu才能读取变量执行相应的指令,这样就解决了缓存一致性问题。

    但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

    所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

     

    1.2 并发编程三个概念

    1.原子性

    一个操过程作被任不何因素打会断,过程要么全部执行或者要么全部不执行。

    举例:A、B账户各1000元,从A账户转500到B账户,为保证正确,这个操作需要保证原子性。

    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修改的值。

    3.有序性

    即程序执行的顺序按照代码的先后顺序执行。

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

    JVM执行时,不一定保证语句1在语句2之前执行(指令重排序)。

    指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。(这点在后面详解)

    1.2 Java内存模型

    Java内存模型简称JMM(Java Memory Model),是Java虚拟机所界说的一种抽象规范,用来屏蔽不合硬件和操作系统的内存拜候差别,让java程序在各种平台下都能达到一致的内存拜候效果。

    两个概念:

    1. 主内存(Main Memory)
      可以简单理解为计算机傍边的内存,但又不完全同等。主内存被所有的线程所共享,对一个共享变量(好比静态变量,或是堆内存中的实例)来说,主内存傍边存储了它的“本尊”。
    2. 工作内存(Working Memory)
      工作内存可以简单理解为计算机傍边的CPU高速缓存,但又不完全同等。每一个线程拥有自己的工作内存,对一个共享变量来说,工作内存傍边存储了它的“副本”。
      线程对共享变量的所有操作都必须在工作内存进行,不克不及直接读写主内存中的变量。不合线程之间也无法拜候彼此的工作内存,变量值的传递只能通过主内存来进行。
      之所以所有线程没有直接在主内存上操作,是因为直接操作主内存太慢了,JVM不克不及晦气用性能较高的工作内存。这里可以类比一下CPU、高速缓存、内存直接的关系。

    举例:

    静态变量:

    static int s = 0;

    线程A执行:

    s = 3;

    那么,MM的工作流程如下图所示:

      

     通过一系列内存读写的操作指令(JVM内存模型共界说了8种内存操作指令),线程A把静态变量 s=0 从主内存读到工作内存,再把 s=3 的更新结果同步到主内存傍边。从单线程的角度来看,这个过程没有任何问题。

    这时候我们引入线程B,执行如下代码:

    System.out.println("s=" + s);

    那么,如果线程A先执行,线程B后执行,线程B的输出结果会是什么?实际结果会有两种:

    s=3 或者 s=0

    引入线程B以后,当线程A首先执行,更新的可能是呈现下面情况:

     此时线程B从主内存获得的s值是3,理所固然输出 s=3,这种情况不难理解。可是,有较小的几率呈现另一种情况:

     

     因为工作内存所更新的变量其实不会立即同步到主内存,所以虽然线程A在工作内存傍边已经把变量s的值更新成3,可是线程B从主内存获得的变量s的值仍然是0,从而输出 s=0。

    如何解决这个问题:

    一般的做法是使用Synchronized同步锁,虽然可以保证线程安全,可是Synchronized是重量级锁,对程序的性能影响太大。还有一种轻量级的解决办法,也就是volatile。

    volatile有许多特性,其中最重要的就是保证了用volatile修饰的变量对所有线程的可见性。(当一个线程修改变量值,新的值会立即同步到主内存,其他线程读取变量时会从主内存拉取最新值)

    如果代码为:

    volatile static int s=0;

    线程A执行:s=3;

    线程B执行:System.out.println("s=" + s);

    此时B一定正确输出:s=3

    2.volatile实现原理

    2.1 原理

    synchronized是阻塞式同步,在线程竞争激烈的情况下会升级为重量级锁。而volatile就可以说是java虚拟机提供的最轻量级的同步机制。

    Java内存模型告诉我们,各个线程会将共享变量从主内存中拷贝到工作内存,然后执行引擎会基于工作内存中的数据进行操作处理。线程在工作内存进行操作后何时会写到主内存中?这个时机对普通变量是没有规定的,而针对volatile修饰的变量给java虚拟机特殊的约定,线程对volatile变量的修改会立刻被其他线程所感知即不会出现数据脏读的现象,从而保证数据的“可见性”。
    被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。
    volatile是怎样实现了?比如一个很简单的Java代码:
    instance = new Instancce() //instance是volatile变量

    在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令(具体的大家可以使用一些工具去看一下,这里我就只把结果说出来)。我们想这个Lock指令肯定有神奇的地方,那么Lock前缀的指令在多核处理器下会发现什么事情了?主要有这两个方面的影响:

    1. 将当前处理器缓存行的数据写回系统内存;
    2. 这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效

    为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到系统内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。因此,经过分析我们可以得出如下结论:

    1. Lock前缀的指令会引起处理器缓存写回内存;
    2. 一个处理器的缓存回写到内存会导致其他处理器的缓存失效
    3. 当处理器发现本地缓存失效后,就会从内存中重读该变量数据,即可以获取当前最新值。

    这样针对volatile变量通过这样的机制就使得每个线程都能获得该变量的最新值。

    2.2 volatile与happens-before(先行发生)原则

    在计算机科学中,先行产生原则是两个事件的结果之间的关系,如果一个事件产生在另一个事件之前,结果必须反映,即使这些事件实际上是乱序执行的(通常是优化程序流程)。

    这里所谓的事件,实际上就是各种指令操作,好比读操作、写操作、初始化操作、锁操作等等。


    先行产生原则作用于很多场景下,包含同步锁、线程启动、线程终止、volatile。我们这里只列举出volatile相关的规则:

    对一个volatile变量的写操作先行产生于后面对这个变量的读操作

    volatile static int s = 0;

    线程A:s=3

    线程B:System.out.println("s=" + s);

    当线程A先执行的时候,把s = 3写入主内存的事件一定会先于读取s的事件。所以线程B的输出一定是s = 3。

    总结:线程A将volatile变量的修改线程B就能够迅速感知。

    3.volatile内存语义及实现(内存屏障)

    3.1 volatile示例-先行发生

    public class VolatileExample {
        private int a = 0;
        private volatile boolean flag = false;
        public void writer(){
            a = 1;          //1
            flag = true;   //2
        }
        public void reader(){
            if(flag){      //3
                int i = a; //4
            }
        }
    }

    如上代码,先行发生原则执行图:

    线程A将volatile变量 flag更改为true后线程B就能够迅速感知。

    假设线程A先执行writer方法,线程B随后执行reader方法,初始时线程的本地内存中flag和a都是初始状态,下图是线程A执行volatile写后的状态图。

     当volatile变量写后,线程中本地内存中共享变量就会置为失效的状态,因此线程B再需要读取从主内存中去读取该变量的最新值。下图就展示了线程B读取同一个volatile变量的内存变化示意图。

     3.2 指令重排序

    JVM在编译Java代码时,或者CPU在执行JVM字节码时,对现有的指令顺序进行重新排序。

    指令重排序的目的是为了在不改变程序运行结果的前提下,优化程序执行效率。(指单线程下不影响程序执行结果)

    指令重排序影响多线程情况下的执行结果:

    boolean contextReady = false;
    
    // 线程A执行下面2行
    context = loadContext();
    contextReady = true;
    
    // 线程B执行下面
    while( ! contextReady ){
      sleep(200);
    }
    doAfterContextReady (context);

    以上程序看似没有问题。线程B循环期待上下文context的加载,一旦context加载完成,contextReady == true的时候,才执行doAfterContextReady方法。

    但是如果线程A进行了指令重排序,初始化和contextReady 赋值交换了顺序:

    boolean contextReady = false;
    // 在线程A中执行
    contextReady = true;
    context = loadContext();
    
    // 在线程B中执行
    while( ! contextReady ){
        sleep(200);
    }
    doAfterContextReady (context);

    此时,很可能context对象还没有加载完成,变量contextReady 已经为true,线程B直接跳出了循环期待,开始执行doAfterContextReady 体例,结果自然会出现错误。

    PS:这里指令重排只是简单示意,真正指令重排是在字节码指令层面。

    3.3 内存屏障

    内存屏障是一种屏障指令,它使CPU或编译器对屏障指令之前和之后发出的内存操作执行一个排序约束。屏障前的操作保证在屏障后的操作之前进行操作。

    JMM内存屏障分4类:

     java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:

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

    1. 在每个volatile写操作的前面插入一个StoreStore屏障;
    2. 在每个volatile写操作的后面插入一个StoreLoad屏障;
    3. 在每个volatile读操作的前面插入一个LoadLoad屏障;
    4. 在每个volatile读操作的后面插入一个LoadStore屏障。

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

    StoreStore屏障:禁止上面的普通写和下面的volatile写重排序;

    StoreLoad屏障:防止上面的volatile写与下面可能有的volatile读/写重排序;

    LoadLoad屏障:禁止下面所有的普通读操作和上面的volatile读重排序;

    LoadStore屏障:禁止下面所有的普通写操作和上面的volatile读重排序。

     

    上面代码示例:

    屏障上方的普通写入和屏障下放的volatile写入操作无法交换顺序。

    4.volatile适用场景

    4.1 volatile使用条件

    volatile变量具有 synchronized 的可见性特性,但是不具备原子性。这就是说线程能够自动发现 volatile 变量的最新值

    volatile变量可用于提供线程安全,但是只能应用于非常有限的一组用例:多个变量之间或者某个变量的当前值与修改后值之间没有约束。因此,单独使用 volatile 还不足以实现计数器、互斥锁或任何具有与多个变量相关的不变式(Invariants)的类(例如 “start <=end”)。

    出于简易性或可伸缩性的考虑,您可能倾向于使用 volatile 变量而不是锁。当使用 volatile 变量而非锁时,某些习惯用法(idiom)更加易于编码和阅读。此外,volatile 变量不会像锁那样造成线程阻塞,因此也很少造成可伸缩性问题。在某些情况下,如果读操作远远大于写操作,volatile 变量还可以提供优于锁的性能优势。

    使用条件:

    1. 对变量的写操作不依赖于当前值。
    2. 该变量没有包含在具有其他变量的不变式中。

    这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

    第一个条件的限制使 volatile 变量不能用作线程安全计数器。虽然增量操作(x++)看上去类似一个单独操作,实际上它是一个由(读取-修改-写入)操作序列组成的组合操作,必须以原子方式执行,而 volatile 不能提供必须的原子特性。实现正确的操作需要使x 的值在操作期间保持不变,而 volatile 变量无法实现这。(然而,如果只从单个线程写入,那么可以忽略第一个条件。)

    4.2 反例

    大多数编程情形都会与这两个条件的其中之一冲突,使得 volatile 变量不能像 synchronized 那样普遍适用于实现线程安全。

    【反例:volatile变量不能用于约束条件中】 下面是一个非线程安全的数值范围类。它包含了一个不变式 —— 下界总是小于或等于上界。

    public class NumberRange {  
        private volatile int lower;
        private volatile int upper;  
      
        public int getLower() { return lower; }  
        public int getUpper() { return upper; }  
      
        public void setLower(int value) {   
            if (value > upper)   
                throw new IllegalArgumentException(...);  
            lower = value;  
        }  
      
        public void setUpper(int value) {   
            if (value < lower)   
                throw new IllegalArgumentException(...);  
            upper = value;  
        }  
    }

    将 lower 和 upper 字段定义为 volatile 类型不能够充分实现类的线程安全;而仍然需要使用同步——使 setLower()和 setUpper() 操作原子化。

    否则,如果凑巧两个线程在同一时间使用不一致的值执行 setLower 和 setUpper 的话,则会使范围处于不一致的状态。例如,如果初始状态是(0, 5),同一时间内,线程 A 调用setLower(4) 并且线程 B 调用setUpper(3),显然这两个操作交叉存入的值是不符合条件的,那么两个线程都会通过用于保护不变式的检查,使得最后的范围值是(4, 3) —— 一个无效值。

    4.3 适用场景

    4.3.1 状态标志

    也许实现 volatile 变量的规范使用仅仅是使用一个布尔状态标志,用于指示发生了一个重要的一次性事件,例如完成初始化或请求停机。

    volatile boolean shutdownRequested;  
      
    ...  
      
    public void shutdown() {   
        shutdownRequested = true;   
    }  
      
    public void doWork() {   
        while (!shutdownRequested) {   
            // do stuff  
        }  
    }

    线程1执行doWork()的过程中,可能有另外的线程2调用了shutdown,所以boolean变量必须是volatile。

    而如果使用 synchronized 块编写循环要比使用 volatile 状态标志编写麻烦很多。由于 volatile 简化了编码,并且状态标志并不依赖于程序内任何其他状态,因此此处非常适合使用 volatile。

    这种类型的状态标记的一个公共特性是:通常只有一种状态转换shutdownRequested 标志从false 转换为true,然后程序停止。这种模式可以扩展到来回转换的状态标志,但是只有在转换周期不被察觉的情况下才能扩展(从false 到true,再转换到false)。此外,还需要某些原子状态转换机制,例如原子变量。

    4.3.1 一次性安全发布(双重检查锁定为什么需要volatile)

    在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。

    这就是造成著名的双重检查锁定(double-checked-locking)问题的根源,其中对象引用在没有同步的情况下进行读操作,产生的问题是您可能会看到一个更新的引用,但是仍然会通过该引用看到不完全构造的对象。如下面介绍的单例模式。

    如果不用volatile修饰变量,则在双重检查锁定时,可能发生指令重排,导致错误出现。

    private volatile static Singleton instace;     
        
    public static Singleton getInstance(){     
        //第一次null检查       
        if(instance == null){              
            synchronized(Singleton.class) {    //1       
                //第二次null检查         
                if(instance == null){          //2    
                    instance = new Singleton();//3    
                }    
            }             
        }    
        return instance;   
    }

    从字节码可以看到创建一个对象实例,可以分为三步:

    1. 分配对象内存
    2. 调用构造器方法,执行初始化
    3. 将对象引用赋值给变量。

    虚拟机实际运行时,以上指令可能发生重排序。以上代码 2,3 可能发生重排序,但是并不会重排序 1 的顺序。也就是说 1 这个指令都需要先执行,因为 2,3 指令需要依托 1 指令执行结果。

    虽然重排序并不影响单线程内的执行结果,但是在多线程的环境就带来一些问题。

    image.png

    上面错误双重检查锁定的示例代码中,如果线程 1 获取到锁进入创建对象实例,这个时候发生了指令重排序。当线程1 执行到 t3 时刻,线程 2 刚好进入,由于此时对象已经不为 Null,所以线程 2 可以自由访问该对象。然后该对象还未初始化,所以线程 2 访问时将会发生异常。

    4.3.1 独立观察

    安全使用 volatile 的另一种简单模式是:定期 “发布” 观察结果供程序内部使用。【例如】假设有一种环境传感器能够感觉环境温度。一个后台线程可能会每隔几秒读取一次该传感器,并更新包含当前文档的 volatile 变量。然后,其他线程可以读取这个变量,从而随时能够看到最新的温度值。

    使用该模式的另一种应用程序就是收集程序的统计信息。

    【例】如下代码展示了身份验证机制如何记忆最近一次登录的用户的名字。将反复使用lastUser 引用来发布值,以供程序的其他部分使用。(主要利用了volatile的可见性)

    public class UserManager {  
        public volatile String lastUser; //发布的信息  
      
        public boolean authenticate(String user, String password) {  
            boolean valid = passwordIsValid(user, password);  
            if (valid) {  
                User u = new User();  
                activeUsers.add(u);  
                lastUser = user;  
            }  
            return valid;  
        }  
    }

    4.3.1 “volatile bean” 模式

    volatile bean 模式的基本原理是:很多框架为易变数据的持有者(例如 HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。

    在 volatile bean 模式中,JavaBean 的所有数据成员都是 volatile 类型的,并且 getter 和 setter 方法必须非常普通——即不包含约束!

    public class Person {  
        private volatile String firstName;  
        private volatile String lastName;  
        private volatile int age;  
      
        public String getFirstName() { return firstName; }  
        public String getLastName() { return lastName; }  
        public int getAge() { return age; }  
      
        public void setFirstName(String firstName) {   
            this.firstName = firstName;  
        }  
      
        public void setLastName(String lastName) {   
            this.lastName = lastName;  
        }  
      
        public void setAge(int age) {   
            this.age = age;  
        }  
    }

    4.3.1 开销较低的“读-写锁”策略

    如果读操作远远超过写操作,您可以结合使用内部锁和 volatile 变量来减少公共代码路径的开销。

    如下显示的线程安全的计数器,使用 synchronized 确保增量操作是原子的,并使用 volatile 保证当前结果的可见性。如果更新不频繁的话,该方法可实现更好的性能,因为读路径的开销仅仅涉及 volatile 读操作,这通常要优于一个无竞争的锁获取的开销。

    public class CheesyCounter {  
        // Employs the cheap read-write lock trick  
        // All mutative operations MUST be done with the 'this' lock held  
        @GuardedBy("this") private volatile int value;  
      
        //读操作,没有synchronized,提高性能  
        public int getValue() {   
            return value;   
        }   
      
        //写操作,必须synchronized。因为x++不是原子操作  
        public synchronized int increment() {  
            return value++;  
        }  
    }

    5.volatile特性

    1. 保证变量在线程之间的可见性。(基于CPU内存屏障)
    2. 阻止编译时和运行时的指令重排。编译时JVM编译器遵循内存屏障约束,运行时依靠CPU屏障指令来组织重排。
  • 相关阅读:
    poj- 2528 Mayor's posters
    POJ 2631 Roads in the North (树的直径裸题)
    Quoit Design (白话--分治--平面点对问题)
    洛古 P1020 导弹拦截 (贪心+二分)
    D
    代理模式---动态代理之Cglib
    代理模式---动态代理之JDK
    开闭原则
    迪米特法则
    接口隔离原则
  • 原文地址:https://www.cnblogs.com/limaosheng/p/13669784.html
Copyright © 2011-2022 走看看