zoukankan      html  css  js  c++  java
  • 【JAVA基础】volatile关键字解析

    一、volatile是什么?

    volatile是一种轻量级的同步机制

    二、volatile的三种特性?

    1.保证可见性
    2.不保证原子性
    3.禁止指令重排
    

    三、JMM(内存模型)的概念

    JMM简单介绍

    在说volatile之前,我们需要知道JMM。JMM是什么呢,JMM表示JAVA内存模型,他是一种抽象的概念,它表示一种约定,规范,实际上并不存在。
    java在执行指令的时候,会涉及到对数据的读写,我们知道,数据是放在主存中的,当程序在运行的过程中,会将运行所需要的数据从主存复制一份到CPU的高速缓存当中,
    这样CPU进行就可以直接从它的高速缓存读取数据并且向其中写入数据,当计算运行结束之后,将高速缓存的数据同步到主存中,
    比如对于

    i = i + 1;
    

    对于这个例子来说,当线程执行到这儿的时候,先从主存中获取i的值,然后拷贝一份数据扔到高速缓存中,然后CPU将对变量i进行加1的操作,将结果写入高速缓存,最后,高速缓存将最终的结果同步到主存,对于单线程来说,这样是不存在问题的,取数、复制、计算、同步。
    但是对于。多线程的情况下,比如两个线程,期望这两个线程执行完的结果使得i变成2,但是事实可能并没有我们想到这么顺利。每个CPU有自己的高速缓存,如果这两个线程被不同的CPU执行,此时可能会出现这样一种情况,线程都从主存1、2取出原始数据0,经过一番操作,线程1会将i变成1,然后同步到主存,线程2也是这样,这个时候结果就是1,而不是2了。
    也就是说,如果一个变量在多个CPU中都存在缓存,那么就可能存在缓存不一致的问题。

    为了解决这个问题,java使用锁机制来处理,那么对于并发编程来说,一般会遇见原子性问题,可见性问题,有序性问题三个问题。JMM对这种问题处理如下。

    JMM关于同步的规定

    1、线程解锁前,必须把共享变量的值刷新主内存
    2、线程加锁前,必须读取主内存的最新值到自己的工作内存
    3、加锁解锁是同一把锁
    为了继续说明,和volatile穿插。先介绍一下原子性问题,可见性问题,有序性问题这三个概念。
    先有一个概念:volatile不保证原子性,synchronized都保证。 可见性就是一种多线程信息的同步机制。

    可见性

    t1、t2、t3线程都是从主内存拿出变量,在自己的工作内存中做一个变量副本,然后操作副本的值,修改之后,在同步到主内存,但是 t1修改之后同步到主内存后,如何保证t2和t3和主内存保持一致呢,这个机制就是可见性。
    举个例子
    现在要对Data里面的a元素加一,期望主线程和其他一个线程都获取到初始值之后,由其他线程改变a的之后,然后主线程里面获取到,期望的场景就是这样

    public class VolatileDemo {
        public static void main(String[] args) {
            Data data = new Data();
            new Thread(() -> {
                System.out.println(Thread.currentThread().getName() + " is coming");
                try {
                    Thread.sleep(3000);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                data.addOne();
                System.out.println(Thread.currentThread().getName() + " has updated...");
            },"thread1").start();
            
            while (data.a == 0) {
                // looping
            }
            System.out.println(Thread.currentThread().getName() + " job is done...");
        }
    
    }
    
    class Data{
        int a = 0;
        void  addOne(){
            this.a += 1;
        }
    }
    

    上面的这个代码就是验证了可见性,对于新的线程thread1和主线程来说,都是把data的值拷贝到自己线程的工作区间去操作的,但是thread1线程操作后,已经把值更新成1,并且写回主内存了,但是没有保证可见性,那么main线程的值一直就是0,所以这个程序会一直走while去判断0。所以就是不可见。那么加上了volatile之后,就可以验证可见性。volatile的就可以解决可见性的问题。

    volatile int a = 0;
    

    现在就是只要有一个线程修改了值马上刷新同步回主内存,同时对其他线程可见。

    原子性

    原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉。在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
    volatile已经说了是不支持原子操作的。
    为了检验volatile不能保证原子性,举一个例子,就是20个线程做i++操作,循环1000次,最后的i结果应该是20000。 下面这个结果有可能是20000。但是实际上很难,以此来验证volatile不能保证原子性。

    class Data {
        volatile int num = 0;
        public void addSelf(){
            num++;
        }
    }
    
    public class VolatileDemo {
        public static void main(String[] args) {
            atomicByVolatile();//验证volatile不保证原子性
        }
    
        public static void atomicByVolatile() {
            Data myData = new Data();
            for (int i = 1; i <= 20; i++) {
                new Thread(() -> {
                    for (int j = 1; j <= 1000; j++) {
                        myData.addSelf();
                    }
                }, "Thread " + i).start();
            }
            //等待上面的线程都计算完成后,再用main线程取得最终结果值
            //设置为2的原因是,默认有两个线程,一个是主线程,另外一个是gc线程
            while (Thread.activeCount() > 2) {
                Thread.yield();
            }
            System.out.println(Thread.currentThread().getName() + "	 finally num value is " + myData.num);
        }
    }
    

    打印输出

    main	 finally num value is 18622
    

    对于这20个线程来说,线程t1从主内存拿到i=0的值,改为t1的时候,正常情况下t2或者t3都应该立即同步主内存的值,若此时t2或者t3发生了阻塞加塞,那么t2或者t3就会把自己的值去同步到主内存,从而发生写覆盖。
    那volatile不保证原子性怎么解决呢,一般有两种方法
    第一种:方法加synchronized
    第二种:加Atomic

    用第二种方法来尝试解决这个问题

    class Data {
        volatile int num = 0;
        public void addSelf(){
            num++;
        }
        AtomicInteger atomicInteger = new AtomicInteger();
        public void atomicAddSelf(){
            atomicInteger.getAndIncrement();
        }
    }
    
    public class VolatileDemo {
        public static void main(String[] args) {
            atomicByVolatile();//验证volatile不保证原子性
        }
        /**
         * volatile不保证原子性
         * 以及使用Atomic保证原子性
         */
        public static void atomicByVolatile() {
            Data myData = new Data();
            for (int i = 1; i <= 20; i++) {
                new Thread(() -> {
                    for (int j = 1; j <= 1000; j++) {
                        myData.addSelf();
                        myData.atomicAddSelf();
                    }
                }, "Thread " + i).start();
            }
    
            //等待上面的线程都计算完成后,再用main线程取得最终结果值
            //设置为2的原因是,默认有两个线程,一个是主线程,另外一个是gc线程
            while (Thread.activeCount() > 2) {
                Thread.yield();
            }
    
            System.out.println(Thread.currentThread().getName() + "	 finally num value is " + myData.num);
            System.out.println(Thread.currentThread().getName()+"	 finally atomicnum value is "+myData.atomicInteger);
        }
    }
    

    输出结果

    main	 finally num value is 19882
    main	 finally atomicnum value is 20000
    

    AtomicInteger表示带有原子性的int类型,调用getAndIncrement()表示++。

    所以,多线程环境下不要++。用getAndIncrement()

    那么Atomic为什么能保证原子性呢?底层是什么呢?这个就涉及到CAS了。具体看CAS这部分CAS浅析

    有序性

    volatile禁止指令重排,有序性就是指令重排

    
    public class ReSortDemo {
        int a = 0;
        boolean flag = false;
    
        public void method1(){
            a = 1;  //语句一
            flag = true; //语句二
        }
    
        //多线程环境中线程交替执行,由于编译器优化重排的存在
        //两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测
        public void method2(){
            if(flag){
                a = a + 5;
                System.out.println("****reValue:" + a);
            }
        }
    }
    

    在单线程环境下先走method1再走method2,结果是6,但是在多线程环境下,语句一和语句二在个线程的执行顺序是被优化的,若先走语句一,再走语句二,是6,但是先走语句二,还没有走到语句一之后,先走了method2,那么结果就是5。volatile就可以防止这种情况,也就是禁止指令重排。

    volatile 实现禁止指令重排序的优化,从而避免了多线程环境下程序出现乱序的现象

    volatile使用的例子

    那么volatile在多线程使用使用的例子呢,那就是单例模式
    那么多线程条件下如何构建单例模式呢? 单机版情况下,多线程单例模式,下面这个代码是有问题的

    public class SingletonDemo {
        private static  SingletonDemo instance = null;
    
        private SingletonDemo() {
            System.out.println(Thread.currentThread().getName() + "	 构造方法SingletonDemo()");
        }
    
        public static SingletonDemo getInstance() {
    
            if (instance == null) {
                instance = new SingletonDemo();
            }
            return instance;
        }
    
    
        public static void main(String[] args) {
            //构造方法只会被执行一次
    //        System.out.println(getInstance() == getInstance());
    //        System.out.println(getInstance() == getInstance());
    //        System.out.println(getInstance() == getInstance());
    
            //构造方法会在一些情况下执行多次
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    SingletonDemo.getInstance();
                }, "Thread " + i).start();
            }
        }
    }
    

    上面这个代码每次创建的单例模式是有问题的,可能创建出来多个,我们可以对方法getInstance()加synchronized,但是这样的话,会对整个方法都锁住,并不是很理想。真正需要控制的就是里面new这一行,所以有一个DCL模式的单例模式,也就是双重检验锁的单例模式。也就是前后都判断一次。

    // DCL Double check lock 双重检验锁,加锁前后都进行判断
        public static SingletonDemo getInstance() {
    
            if (instance == null) {
                synchronized (SingletonDemo.class) {
                    if (instance == null) {
                        instance = new SingletonDemo();
                    }
                }
            }
            return instance;
        }
    

    但是,DCL不一定保证线程安全,因为多线程存在指令重排,指令重排是什么呢,先看一个概念,内存屏障。
    内存屏障(Memory Barrier)又称内存栅栏,是一个 CPU 指令,他的作用有两个:

    • 保证特定操作的执行顺序
    • 保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)

    由于编译器处理器都能执行指令重排序优化,如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU,不管什么指令都不能个这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后执行重排序优化。内存屏障另一个作用是强制刷出各种 CPU 缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。
    下面是保守策略下,volatile写插入内存屏障后生成的指令序列示意图:

    对于这个单例模式的例子来说:指令重排只会保证串行语义保证一致性(单线程),并不会关心多线程条件下的语义一致性
    所以当一条线程访问instance不加null时候,由于instance实例未必已经初始化完成,所以也就造成了线程安全性问题。
    所以需要加volatile关键字去禁止指令重排。

    public class SingletonDemo {
        private static volatile SingletonDemo instance = null;
    
        private SingletonDemo() {
            System.out.println(Thread.currentThread().getName() + "	 构造方法SingletonDemo()");
        }
    
        // DCL Double check lock 双重检验锁,加锁前后都进行判断
        public static SingletonDemo getInstance() {
    
            if (instance == null) {
                synchronized (SingletonDemo.class) {
                    if (instance == null) {
                        instance = new SingletonDemo();
                    }
                }
            }
            return instance;
        }
    
    
        public static void main(String[] args) {
            //构造方法只会被执行一次
    //        System.out.println(getInstance() == getInstance());
    //        System.out.println(getInstance() == getInstance());
    //        System.out.println(getInstance() == getInstance());
    
            //构造方法会在一些情况下执行多次
            for (int i = 0; i < 10; i++) {
                new Thread(() -> {
                    SingletonDemo.getInstance();
                }, "Thread " + i).start();
            }
        }
    }
    

    线程的安全性保证

    工作内存和主内存之间的同步延迟现象导致的可见性问题,可以通过volatile和synchronized关键字来解决,他们都可以使得一个线程修改变量的值后立即对其他的值可见。 关于指令重排导致的可见性和有序性问题,可以用volatile关键字解决,因为volatile的另一个作用就是禁止指令重排。

    你知道的越多,你不知道的越多。
  • 相关阅读:
    多线程原理——随机打印结果
    微信小程序自定义组件传递参数
    微信小程序添加自定义组件
    mysql 多表查询内连接
    mysql 创建增删改查
    Python爬虫入门七之正则表达式
    Python爬虫入门六之Cookie的使用
    Python爬虫入门五之URLError异常处理
    Python爬虫入门四之Urllib库的高级用法
    Python爬虫入门二之爬虫基础了解
  • 原文地址:https://www.cnblogs.com/zhangxinying/p/12443650.html
Copyright © 2011-2022 走看看