Volatile详解

什么是Volatile

volatile是java虚拟机提供的轻量级的同步机制,volatile三个特性。

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

了解Volatile需要先知道JMM(内存模型)

什么是JMM

JMM(java内存模型java Memory Model,简称JMM)本身是一种抽象的概念不真实存在,他描述的是一组规则或规范,通过这组规范定义了程序各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

JMM三大特性

  1. 可见性
  2. 原子性
  3. 有序性
可见性

首先我们来理解什么是主内存主内存 (java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问) 实际就是硬件的内存条(有8G内存,16G 内存等),我们new student(); 这么个实例对象就在主内存里。当我们实例对象student 有属性age = 18,假设,有3个线程来修改这个年龄,线程为T1,T2,T3,每个线程都会(变量副本拷贝)从主内存拷贝一份到自己的工作内存中,也就是线程T1工作内存有18,线程T2,T3工作内存中也有18,这个时候线程T1修改了age=15,同时把15写回给主内存,此时T2,T3是不知道线程T1已经修改了主内存age=15的。那么种情况我们就需要一种机制,当一个线程修改了主内存要通知其他线程。
这种及时通知状况就是JMM内存模型中的第一个特性可见性
图1如下:

图1
代码演示

package com.test;
import java.util.concurrent.TimeUnit;

class MyData{
    int number = 0;
    public void addTo50(){
        this.number = 50;
    }
}

/**
 * 1.验证volatile的可见性
 * 1.1 假如 int number = 0; number变量之前没有添加volatile关键字修饰
 */
public class VolatileDemo {
    public static void main(String[] args){
        MyData myData = new MyData();//new 资源类

        //线程A
        new Thread(() ->{
            System.out.println(Thread.currentThread().getName() + "\t come in");
            //模拟运算,暂停3秒
            try {
                TimeUnit.SECONDS.sleep(3);
                myData.addTo50();
                System.out.println(Thread.currentThread().getName() + "\t update number value:"+myData.number);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        },"A线程").start();

        //线程B main线程
        while (myData.number == 0){
            //main线程就一直在这里等待循环,直到number值不在等于0.
        }
        System.out.println(Thread.currentThread().getName() + "\t over");
    }
}

运行结果如图:
线程B main线程 是处在死循环中,因为没人通知现在线程B number已经修改了写回主内存了。
运行图1
下面我们修改下代码,加上volatile关键字

class MyData{
    volatile int number = 0;
    public void addTo50(){
        this.number = 50;
    }
}
/....../
//线程B main线程
while (myData.number == 0){
    //main线程就一直在这里等待循环,直到number值不在等于0.
}
System.out.println(Thread.currentThread().getName() + "\t over,main get number value:"+myData.number);

运行结果如图:
当我们加上了volatile 关键字,实现了可见性,一个线程修改及时通知另外的线程。
在这里插入图片描述

原子性
  1. 原子性指的是什么意思?
    保证数据完整一致性,即不可分割,完整性,某个线程正在做某个具体业务时,中间不可以被加塞或者分割,需要整体完整要么成功,要么同时失败。

volatile是不保证原子性的,我们来用代码演示下

import java.util.concurrent.TimeUnit;

class MyData {
    volatile int number = 0;
    //volatile不保证原子性
    public void addPP() {
        number++;
    }
}

/**
 * 1.验证volatile不保证原子性
 */
public class VolatileDemo {
    public static void main(String[] args) {
        MyData myData = new MyData();//new 资源类
        //20个线程 每个线程执行1000次 20 * 1000 = 20000
        for (int i = 1; i <= 20; i++) {
            new Thread(()->{
                for (int j = 1; j <=1000 ; j++) {
                    myData.addPP();
                }
            },String.valueOf(i)).start();
        }

        //需要等待上面20个线程执行计算完,在用main线程取得最终结果值是多少,也就是 原子性
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName()+"\t finally number value:"+myData.number);
    }
}

运行代码如图:
验证volatile运行图
为什么volatile不能保证原子性,下面我们来研究下,为什么数值每次都小于20000,假设我们有3个线程t1,t2,t3,我们知道数值存在主内存中,当调用number++时,每个线程都会变量拷贝到自己的工作内存中,当t1线程拷贝执行加1后写入主内存时被其他线程抢到,t1线程挂起,t2线程加1写入了主内存,那么这时候volatile及时通知了其他线程,但是t1活了速度太快还没来得急得到通知就把自身的值写入主内存。那么这时候t1就覆盖了t2计算的值,主内存就是覆盖值,然后在通知其他线程,其他线程得到是覆盖的值,所以如此循环最终值会小于20000,当然也存在点好,正好20000的情况。

如何解决原子性问题

  1. 第一种 加synchronized 不推荐,属于杀鸡用牛刀,synchronized性能也不是很好。
    代码如下:
/......../
//volatile不保证原子性
public synchronized void addPP() {
    number++;
}
  1. 第二种 推荐使用java工具类atomic
    代码如下:
package com.test;

import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

class MyData {
   volatile int number = 0;
   
   //volatile不保证原子性
   public void addPP() {
       number++;
   }
   //带原子性的number++
   AtomicInteger atomicInteger = new AtomicInteger();
   public void addAtomic(){
       atomicInteger.getAndIncrement();
   }
}

/**
* 1.验证volatile不保证原子性
* 2.是用AtomicInteger 保证原子性
*/
public class VolatileDemo {
   public static void main(String[] args) {
       MyData myData = new MyData();//new 资源类
       //20个线程 每个线程执行1000次 20 * 1000 = 20000
       for (int i = 1; i <= 20; i++) {
           new Thread(()->{
               for (int j = 1; j <=1000 ; j++) {
                   myData.addPP();//不带原子性
                   myData.addAtomic();//带原子性
               }
           },String.valueOf(i)).start();
       }

       //需要等待上面20个线程执行计算完,在用main线程取得最终结果值是多少,也就是 原子性
       while (Thread.activeCount() > 2){
           Thread.yield();
       }
       System.out.println(Thread.currentThread().getName()+"\t int type finally number value:"+myData.number);
       System.out.println(Thread.currentThread().getName()+"\t AtomicInteger type finally number value:"+myData.atomicInteger);
   }
}

运行结果如图:
原子性对比图
如果想了解为什么加Atomic就可以保证原子性,请看我专门描述的文章,这里不做讲解。

禁止指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。
指令重排图
单线程环境里面确保程序最终执行结果和代码顺序执行的结果一致。
处理器在进行重排序时必须考虑指令之间的数据依赖性(先有父母才有子)。
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果是无法预测的。

指令重排通俗讲,比如考试,考试时候卷子的顺序,和我们实际做的题的顺序是不一样的,我们实际做题是先把会做的做了,然后在做不会的,这就是重排。

在多线程下如果不加volatile会出现指令重排,也就是结果会有多个。
下面我们做个例子
编译器 指令重排
定义 int a,b,x, = 0;

线程1线程2
x=a;y=b;
b=1;a=2;
结果可能 x=0,y=0
如果编译器对这段代码执行重排优化后,肯能出现下列状况
线程1线程2
b=1;a=2;
x=a;y=b;
结果可能 x=2,y=1

代码演示
这个结果有两种,第一种是a=6,第二种是a=5,多线程环境中线程交替执行,出现指令重排,两个线程中使用的变量能否保正一致性是无法确定的,语句1和语句2交替,语句2先执行,语句1后执行,那么另外线程太快抢到了method02,flag=true,进来时a=1这个语句还没执行,就变成a = 0+5;所以出现结果无法预测。

public class SortDemo {
    int a = 0;
    boolean flag = false;

    public void method01() {
        a = 1;//语句1
        flag = true;//语句2
    }

    public void method02() {
        if (flag) {
            a = a + 5;//语句3
            System.out.println("value:" + a);
        }
    }
}
什么时候使用volatile

下面我们来做个验证,首先我们创建一个单线程执行单例模式代码如下:

public class SingletonDemo {
    private static SingletonDemo instance = null;
    private SingletonDemo(){
        System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingletonDemo()");
    }

    public static SingletonDemo getInstance(){
        if(instance == null){
            instance = new SingletonDemo();
        }
        return instance;
    }
    public static void main(String[] args){
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
        System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
    }
}

运行结果如图:
单例单线程
根据运行结果,单线程下这个单例模式没毛病,那么我们修改下在多线程下执行看看代码如下:

/...../
 public static void main(String[] args){
        //多线程调用
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                SingletonDemo.getInstance();
            },String.valueOf(i)).start();
        }
    }

运行结果如图:
单例模式在多线程下图
这时我们看到多线程下这种单例写法就不能满足我们要求了,下面我们解决多线程下单例模式调用方案,也就是DCL (Double Check Lock双端检锁机制) 代码如下:

public class SingletonDemo {
    private static SingletonDemo instance = null;
    private SingletonDemo(){
        System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingletonDemo()");
    }

    //如上厕所,我先判断厕所有人吗?没人我进去了,然后锁上门,我不放心在推推这个门,推不动了,我在开始干活。DCL机制就是加锁前和后都进行一次判断
    //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){
        for (int i = 0; i < 10; i++) {
            new Thread(()->{
                SingletonDemo.getInstance();
            },String.valueOf(i)).start();
        }
    }
}

synchronized 不能直接加在方法上,那样性能是相当差的,实际开发中很少这么用。下面我来看运行结果:
多线程下单例模式图
看上去,我们解决了这个出现多实例的问题,但是在高并发下,这样的方式是线程安全的吗? 答案肯定不是,为什么呢? 原因就是计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排。,没错关键点指令重排,一旦指令重排上面代码会出现一个现象,某一个线程执行到第一次检查,读取instance不为null时,instance的引用对象可能没有完成初始化
实例化代码 instance = new SingletonDemo(); 可以分为3个步骤完成实例化。
memory = allocate(); //1.分配对象内存空间
instance(memory); //2.初始化对象
instance = memory; //3.设置instance指向刚分配的内存地址,此时instance != null;
因为步骤2和步骤3不存在数据依赖关系 ,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。
memory = allocate(); //1.分配对象内存空间
instance = memory; //3.设置instance指向刚分配的内存地址,此时instance != null;但是对象还没有初始化完成!
instance(memory); //2.初始化对象
指令重排只会保证串行语义的执行一致性(单线程),但并不会关心多线程间的语义一致性。
所以当一条线程访问instance不为null时,由于instance实例未必已初始化完成,也就造成了线程不安全问题
通俗讲正常情况下比如,公司有新同事儿要来,那么会第一步分配座位,第二步配置电脑网线,第三步人来工作,也就是我们看到的真人。
指令重排,第一步分配座位,但是这时候我们就直接看新来同事儿的座位这,那么这时候我们是看不到真人的,但是我们的目光已经看新同事座位这了,也就是(instance指向刚分配的内存地址),对象没有完成初始化,可能这时候这位新同事儿在路上还没到呢,但是我们已经关注新同事儿座位这了。这时就成了有名无实,当我们来找这位新同事儿聊天是不存在的,但是我们某个同事儿还是来找了(某个线程取值)这时候就没找到真人,也就是取值取到了null.这就造成了线程安全问题。
那我们怎么来解决这个问题呢?这时候就用到了volatile了,volatile禁止指令重排特性,下面修改代码如下:

private static volatile SingletonDemo instance = null;

这样我们就保证了线程安全。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值