volatile关键字

阅读:233

目录介绍

  • 00.看一个简单案例
  • 01.volatile轻量级锁
  • 02.volatile测试案例
  • 03.volatile实现原理
    • 3.1 先看一个简单案例
    • 3.2 生成汇编代码
    • 3.3 多线程下如何获取最新值
  • 04.volatile的happens-before关系
    • 4.1 并发切入点
    • 4.2 volatile的happens-before
  • 05.volatile的内存语义
    • 5.1 执行代码状态图
    • 5.2 volatile的内存语义实现
  • 06.看一个案例分析
  • 07.volatile的应用场景
  • 08.volatile深入解读
    • 8.1 volatile保证可见性
    • 8.2 volatile不能确保原子性
    • 8.3 volatile保证有序性

00.看一个简单案例

  • 举个简单的例子:在java中,执行下面这个语句:
    i  = 10;
    

    • 执行线程必须先在自己的工作线程中对变量i所在的缓存行进行赋值操作,然后再写入主存当中。而不是直接将数值10写入主存当中。
    • 比如同时有2个线程执行这段代码,假如初始时i的值为10,那么我们希望两个线程执行完之后i的值变为12。但是事实会是这样吗?
    • 可能存在下面一种情况:初始时,两个线程分别读取i的值存入各自所在的工作内存当中,然后线程1进行加1操作,然后把i的最新值11写入到内存。此时线程2的工作内存当中i的值还是10,进行加1操作之后,i的值为11,然后线程2把i的值写入内存。
    • 最终结果i的值是11,而不是12。这就是著名的缓存一致性问题。通常称这种被多个线程访问的变量为共享变量。

01.volatile轻量级锁

1.1 轻量级锁

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

1.2 volatile的用途

  • 但它同时不容易被正确理解,也至于在并发编程中很多程序员遇到线程安全的问题就会使用synchronized。Java内存模型告诉我们,各个线程会将共享变量从主内存中拷贝到工作内存,然后执行引擎会基于工作内存中的数据进行操作处理。
  • 线程在工作内存进行操作后何时会写到主内存中?这个时机对普通变量是没有规定的,而针对volatile修饰的变量给java虚拟机特殊的约定,线程对volatile变量的修改会立刻被其他线程所感知,即不会出现数据脏读的现象,从而保证数据的“可见性”。
  • 被volatile修饰的变量能够保证每个线程能够获取该变量的最新值,从而避免出现数据脏读的现象。

03.volatile实现原理

3.1 先看一个简单案例

  • volatile是怎样实现了?比如一个很简单的Java代码:
    public class Main {
    
        private volatile Instance instance;
    
        public static void main(String args[]) {
            //instance是volatile变量
            instance = new Instance();
        }
        
        class Instance{
            public Instance(){}
        }
    }
    

3.2 生成汇编代码

  • 在生成汇编代码时会在volatile修饰的共享变量进行写操作的时候会多出Lock前缀的指令
  • 这个Lock指令肯定有神奇的地方,那么Lock前缀的指令在多核处理器下会发现什么事情了?主要有这两个方面的影响:
    • 1.将当前处理器缓存行的数据写回系统内存;
    • 2.这个写回内存的操作会使得其他CPU里缓存了该内存地址的数据无效

3.3 多线程下如何获取最新值

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

04.volatile的happens-before关系

4.1 并发切入点

  • 经过上面的分析,已经知道了volatile变量可以通过缓存一致性协议保证每个线程都能获得最新值,即满足数据的“可见性”。
  • 并发分析的切入点分为两个核心,三大性质。两大核心:JMM内存模型(主内存和工作内存)以及happens-before;三条性质:原子性,可见性,有序性。

4.2 volatile的happens-before

  • 先来看两个核心之一:volatile的happens-before关系。
    • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。下面我们结合具体的代码,我们利用这条规则推导下:
    private void test3() {
        Thread thread1 = new Thread(new Runnable() {
            @Override
            public void run() {
                new VolatileExample().writer();
            }
        });
        Thread thread2 = new Thread(new Runnable() {
            @Override
            public void run() {
                new VolatileExample().reader();
            }
        });
        thread1.start();
        thread2.start();
    }
    
    
    public class VolatileExample {
        private int a = 0;
        private volatile boolean flag = false;
        public void writer(){
            a = 1;          //1
            LogUtils.e("测试volatile数据1--"+a);
            flag = true;   //2
            LogUtils.e("测试volatile数据2--"+flag);
        }
        public void reader(){
            LogUtils.e("测试volatile数据3--"+flag);
            if(flag){      //3
                int i = a; //4
                LogUtils.e("测试volatile数据4--"+i);
            }
        }
    }
    

  • 打印日志如下所示
    //第一种情况
    2019-03-07 17:17:30.294 25764-25882/com.ycbjie.other E/TestFirstActivity: │ 测试volatile数据3--false
    2019-03-07 17:17:30.294 25764-25881/com.ycbjie.other E/TestFirstActivity: │ 测试volatile数据1--1
    2019-03-07 17:17:30.295 25764-25881/com.ycbjie.other E/TestFirstActivity: │ 测试volatile数据2--true
    
    //第二种情况
    2019-03-07 17:18:01.965 25764-25901/com.ycbjie.other E/TestFirstActivity: │ 测试volatile数据1--1
    2019-03-07 17:18:01.965 25764-25902/com.ycbjie.other E/TestFirstActivity: │ 测试volatile数据3--false
    2019-03-07 17:18:01.966 25764-25901/com.ycbjie.other E/TestFirstActivity: │ 测试volatile数据2--true
    

  • 上面的实例代码对应的happens-before关系如下图所示:
    • 1.png
  • 分析上面代码执行过程
    • 加锁线程A先执行writer方法,然后线程B执行reader方法图中每一个箭头两个节点就代码一个happens-before关系,黑色的代表根据程序顺序规则推导出来,红色的是根据volatile变量的写happens-before 于任意后续对volatile变量的读,而蓝色的就是根据传递性规则推导出来的。
    • 这里的2 happen-before 3,同样根据happens-before规则定义:如果A happens-before B,则A的执行结果对B可见,并且A的执行顺序先于B的执行顺序,我们可以知道操作2执行结果对操作3来说是可见的,也就是说当线程A将volatile变量 flag更改为true后线程B就能够迅速感知。

05.volatile的内存语义

5.1 执行代码状态图

  • 还是按照两个核心的分析方式,分析完happens-before关系后我们现在就来进一步分析volatile的内存语义。还是以上面的代码为例,假设线程A先执行writer方法,线程B随后执行reader方法,初始时线程的本地内存中flag和a都是初始状态,下图是线程A执行volatile写后的状态图。
    • 线程A执行volatile写后的内存状态图
    • 1.png
  • 当volatile变量写后,线程中本地内存中共享变量就会置为失效的状态,因此线程B再需要读取从主内存中去读取该变量的最新值。下图就展示了线程B读取同一个volatile变量的内存变化示意图。
    • 线程B读volatile后的内存状态图
    • 1.png
  • 结果分析
    • 从横向来看,线程A和线程B之间进行了一次通信,线程A在写volatile变量时,实际上就像是给B发送了一个消息告诉线程B你现在的值都是旧的了,然后线程B读这个volatile变量时就像是接收了线程A刚刚发送的消息。既然是旧的了,那线程B该怎么办了?自然而然就只能去主内存去取啦。

5.2 volatile的内存语义实现

  • 为了性能优化,JMM在不改变正确语义的前提下,会允许编译器和处理器对指令序列进行重排序,那如果想阻止重排序要怎么办了?答案是可以添加内存屏障。

内存屏障

  • JMM内存屏障分为四类见下图,

  • java编译器会在生成指令系列时在适当的位置会插入内存屏障指令来禁止特定类型的处理器重排序。为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:
    • 1.png
  • "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读重排序
  • 下面以两个示意图进行理解,图片摘自相当好的一本书《java并发编程的艺术》。
    • volatile写插入内存屏障示意图
    • 1.png
    • volatile读插入内存屏障示意图
    • 1.png

06.看一个案例分析

  • 代码如下所示
    • 注意不同点,现在已经将isOver设置成了volatile变量,这样在main线程中将isOver改为了true后,thread的工作内存该变量值就会失效,从而需要再次从主内存中读取该值,现在能够读出isOver最新值为true从而能够结束在thread里的死循环,从而能够顺利停止掉thread线程。
    private static volatile boolean isOver = false;
    private void test4(){
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!isOver) {
                    LogUtils.e("测试volatile数据"+isOver);
                }
            }
        });
        thread.start();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        isOver = true;
    }
    

07.volatile的应用场景

  • 使用volatile需要具备哪些条件
    • synchronized关键字是防止多个线程同时执行一段代码,那么就会很影响程序执行效率,而volatile关键字在某些情况下性能要优于synchronized,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字无法保证操作的原子性。通常来说,使用volatile必须具备以下2个条件:
      • 1)对变量的写操作不依赖于当前值
      • 2)该变量没有包含在具有其他变量的不变式中
  • 下面列举几个Java中使用volatile的几个场景。
    • ①.状态标记量
      • 根据状态标记,终止线程。
    volatile boolean flag = false;
     //线程1
    while(!flag){
        doSomething();
    }
      //线程2
    public void setFlag() {
        flag = true;
    }
    

    • ②.单例模式中的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,然后使用,然后顺理成章地报错。

08.volatile深入解读

8.1 volatile保证可见性

  • 一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
    • 1)保证了不同线程对这个变量进行操作时的可见性,即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的。
    • 2)禁止进行指令重排序。
  • 先看一段代码,假如线程1先执行,线程2后执行:
    //线程1
    boolean stop = false;
    while(!stop){
        doSomething();
    }
     
    //线程2
    stop = true;
    

  • 思考上面代码完整吗?
    • 这段代码是很典型的一段代码,很多人在中断线程时可能都会采用这种标记办法。但是事实上,这段代码会完全运行正确么?即一定会将线程中断么?不一定,也许在大多数时候,这个代码能够把线程中断,但是也有可能会导致无法中断线程(虽然这个可能性很小,但是只要一旦发生这种情况就会造成死循环了)。
  • 上面代码为何有可能导致无法中断线程?
    • 下面解释一下这段代码为何有可能导致无法中断线程。在前面已经解释过,每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。
    • 那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,因此还会一直循环下去。
  • 但是用volatile修饰之后就变得不一样:
    • 第一:使用volatile关键字会强制将修改的值立即写入主存;
    • 第二:使用volatile关键字的话,当线程2进行修改时,会导致线程1的工作内存中缓存变量stop的缓存行无效(反映到硬件层的话,就是CPU的L1或者L2缓存中对应的缓存行无效);
    • 第三:由于线程1的工作内存中缓存变量stop的缓存行无效,所以线程1再次读取变量stop的值时会去主存读取。
    • 那么在线程2修改stop值时(当然这里包括2个操作,修改线程2工作内存中的值,然后将修改后的值写入内存),会使得线程1的工作内存中缓存变量stop的缓存行无效,然后线程1读取时,发现自己的缓存行无效,它会等待缓存行对应的主存地址被更新之后,然后去对应的主存读取最新的值。那么线程1读取到的就是最新的正确的值。

8.2 volatile不能确保原子性

  • 先来看一下下面的代码
    public class Nothing {
    
        private volatile int inc = 0;
        private volatile static int count = 10;
    
        private void increase() {
            ++inc;
        }
    
        public static void main(String[] args) {
            int loop = 10;
            Nothing nothing = new Nothing();
            while (loop-- > 0) {
                nothing.operation();
            }
        }
    
        private void operation() {
            final Nothing test = new Nothing();
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    for (int j = 0; j < 1000000; j++) {
                        test.increase();
                    }
                    --count;
                }).start();
            }
    
            // 保证前面的线程都执行完
            while (count > 0) {
    
            }
            System.out.println("最后的数据为:" + test.inc);
        }
    
    }
    

    • 运行结果为:
    最后的数据为:5919956
    最后的数据为:3637231
    最后的数据为:2144549
    最后的数据为:2403538
    最后的数据为:1762639
    最后的数据为:2878721
    最后的数据为:2658645
    最后的数据为:2534078
    最后的数据为:2031751
    最后的数据为:2924506
    

  • 大家想一下这段程序的输出结果是多少?
    • 也许有些朋友认为是1000000。但是事实上运行它会发现每次运行结果都不一致,都是一个小于1000,0000的数字。可能有的朋友就会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000000次操作,那么最终inc的值应该是1000000*10=10000000。
  • 这里面就有一个误区
    • **volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。**可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。
    • 在前面已经提到过,自增操作是不具备原子性的,它包括读取变量的原始值、进行加1操作、写入工作内存。那么就是说自增操作的三个子操作可能会分割开执行,就有可能导致下面这种情况出现:
    • 假如某个时刻变量inc的值为10,线程1对变量进行自增操作,线程1先读取了变量inc的原始值,然后线程1被阻塞了;然后线程2对变量进行自增操作,线程2也去读取变量inc的原始值,**由于线程1只是对变量inc进行读取操作,而没有对变量进行修改操作,所以不会导致线程2的工作内存中缓存变量inc的缓存行无效,也不会导致主存中的值刷新,**所以线程2会直接去主存读取inc的值,发现inc的值时10,然后进行加1操作,并把11写入工作内存,最后写入主存。
    • 然后线程1接着进行加1操作,由于已经读取了inc的值,注意此时在线程1的工作内存中inc的值仍然为10,所以线程1对inc进行加1操作后inc的值为11,然后将11写入工作内存,最后写入主存。
    • 那么两个线程分别进行了一次自增操作后,inc只增加了1。根源就在这里,自增操作不是原子性操作,而且volatile也无法保证对变量的任何操作都是原子性的。解决方案:可以通过synchronized或lock,进行加锁,来保证操作的原子性。也可以通过AtomicInteger。
  • 在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类
    • 即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

8.3 volatile保证有序性

  • 在前面提到volatile关键字能禁止指令重排序,所以volatile能在一定程度上保证有序性。
    • volatile关键字禁止指令重排序有两层意思:
    • 1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;
    • 2)在进行指令优化时,不能将在对volatile变量的读操作或者写操作的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。
  • 可能上面说的比较绕,举个简单的例子:
    // x、y为非volatile变量
    // flag为volatile变量
    
    x = 2;        //语句1
    y = 0;        //语句2
    flag = true;  //语句3
    x = 4;        //语句4
    y = -1;       //语句5


    • 由于flag变量为volatile变量,那么在进行指令重排序的过程的时候,不会将语句3放到语句1、语句2前面,也不会讲语句3放到语句4、语句5后面。但是要注意语句1和语句2的顺序、语句4和语句5的顺序是不作任何保证的。
    • 并且volatile关键字能保证,执行到语句3时,语句1和语句2必定是执行完毕了的,且语句1和语句2的执行结果对语句3、语句4、语句5是可见的。

赞赏支持


精彩留言

发表评论