Volatile解析

↘锁芯ラ 提交于 2019-12-18 02:27:22

Volatile解析

Volatile是jvm提供的轻量级的同步机制,具有三大特性:

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

1.JMM及可见性

想要了解volatile的三大特性,首先需要了解JMM(Java Memory Model,Java内存模型)。JMM本身是一种抽象的存在,它描述了一种规范,规定了程序中各种变量的访问方式。JMM关于同步的规定包括下面三部分:

  1. 线程解锁前,必须把共享变量的值刷新回主内存
  2. 线程加锁前,必须读取主内存中最新值到自己的工作内存
  3. 加锁解锁是同一把锁

何为主内存、工作内存?
在硬件层面,计算机的存储主要分为以下几:硬盘、内存、缓存(cache),其中缓存是用于内存与cpu交流的桥梁,因为cpu的运算速度远高于内存的读写,cpu不能总是等待数据从内存中读取,所以引入缓存,将cpu需要的数据先从内存读取到缓存中,cpu使用时直接从缓存中获取,数据处理完之后放回到缓存中,再将缓存中最新的数据同步到内存中。
主内存和工作内存的概念跟内存和缓存的概念十分相似,主内存就相当于内存,多线程共用一份,工作内存就相当于缓存,数据是从主内存中拷贝的副本。java程序的运行是承载在线程上的,每个线程都有自己的一份私有数据空间,也就是工作内存,而JMM规定所有变量的值都存储在主内存中,供所有线程共享使用,但是线程对变量的操作必须在自己的工作内存中进行,因此线程在操作变量前,必须将变量的值从主内存拷贝到自己的工作内存,然后对变量进行操作,操作完成后再写回主内存。线程不能直接从操作主内存中的变量,各个线程工作内存中存储着主内存中变量的副本,各线程之间也无法访问对方的工作内存,数据同步必须通过主内存进行。具体情况可参考下面的例子。

  1. 线程1/2/3/都需要操作主内存的student对象的age属性,因此各拷贝一份age的值到自己的工作内存
  2. 此时线程1获得CPU时间,将age的值修改为20
  3. 修改结束,线程1将工作内存中age修改之后的值同步回主内存空间
    在这里插入图片描述
    此时线程2/3并不知道主内存中age的值已修改为了20,因此需要有一种机制,有一个线程修改完自己工作内存中变量的值并同步回主内存后,就需要通知其他线程主内存中的值已被修改,这种机制就是JMM中的可见性
    下面通过代码证明volatile能够保证被修饰变量的可见性
/**
 *  此类用于提供一个不被volatile的变量及修改这个变量的方法
 */
public class MyDemo {
    int num = 0;

    public void addToOne(){
        this.num=1;
    }
}
/**
 *  此类用于启动线程修改变量的值,并在main方法线程中查看该变量是否被修改
 */
public class VolatileDemo {
    public static void main(String[] args) {
        MyDemo myDemo = new MyDemo();

        new Thread(()->{
            System.out.println(Thread.currentThread().getName() + ":" + myDemo.num);
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            myDemo.addToOne();
            System.out.println(Thread.currentThread().getName() + ":" + myDemo.num);
        }, "Thread 1").start();

        while(myDemo.num==0){
        }
        System.out.println(Thread.currentThread().getName() + ":" + myDemo.num);
    }
}

运行主函数出现以下结果:
在这里插入图片描述
main方法线程的打印方法并未执行,但是num的值确实被Thread1线程给修改了,说明num修改后,同步到主内存后,并未告知其他线程num值已被修改,因此没有保证可见性,此时给num变量前面加上volatile关键字后再来执行主函数。

/**
 *  此类用于提供一个被volatile的变量及修改这个变量的方法
 */
public class MyDemo {
    volatile int num = 0;

    public void addToOne(){
        this.num=1;
    }
}

在这里插入图片描述
此时main方法线程可以获取到Thread1线程修改后的num值,并进行打印,从而证明volatile能够保证可见性

2.原子性

原子性是指某个线程在处理数据是,中间不可以被打断,保证最终一致性。下面通过代码验证volatile不保证原子性。

public class MyDemo {
    volatile int num = 0;
    
    public void addadd(){
        this.num++;
    }
}
public class VolatileDemo {
    public static void main(String[] args) {
        MyDemo myDemo = new MyDemo();

        for (int i = 1; i <= 20; i++){
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    myDemo.addadd();
                }
            }, "Thread " + i).start();
        }
		//判断后台运行线程数是不是大于2,若大于2则表示没有计算完成,后台默认会有两个线程运行:main线程和GC线程
        while (Thread.activeCount() > 2){
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + ":" + myDemo.num);
    }
}

运行结果如下:
在这里插入图片描述
无论执行多少次,都没办法加到20000,但是也有几率加到20000
为什么会不保证原子性?
addadd()方法经过编译后,会变成以下字节码

  public void addadd();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2      // Field num:I
       5: iconst_1
       6: iadd
       7: putfield      #2      // Field num:I
      10: return

其中num++会被分解为如下4步:

       2: getfield      #2      // Field num:I     获取对象字段的值
       5: iconst_1                               //1(int)值入栈。
       6: iadd                                   //将栈顶两int类型数相加,结果入栈
       7: putfield      #2      // Field num:I     给对象字段赋值,既写回主内存

在这里插入图片描述
程序开始执行时,线程1/2/3会分别读取到主内存中num的值为0(对应字节码getfield),然后分别在自己的工作内存中进行自增(对应iconst_1、iadd),在线程1即将向主内存中写入修改后num值为1时(对应putfield),线程2获得CPU时间向主内存中写入修改后num值为1(对应putfield),此时还没来的及告知线程1,线程2已经将num的值修改为1,而线程1再次获得CPU时间,向主内存中写入修改后num值为1,导致了num的值被覆盖为1,从而数据丢失。因此上面的代码中,出现了最终值不是20000的情况。
如何解决无法保证原子性问题?
方法1:使用synchronized,这种加锁机制虽然效果很好但是代价太大,消耗性能
方法2:使用JUC中的AtomicInteger

public class MyDemo {
    volatile int num = 0;

    public void addToOne(){
        this.num=1;
    }

    public void addadd(){
        this.num++;
    }

    AtomicInteger atomicInteger = new AtomicInteger();

    public void atomicadd(){
        atomicInteger.getAndIncrement();
    }
}
public class VolatileDemo {
    public static void main(String[] args) {
        MyDemo myDemo = new MyDemo();

        for (int i = 1; i <= 20; i++){
            new Thread(()->{
                for (int j = 1; j <= 1000 ; j++) {
                    myDemo.addadd();
                    myDemo.atomicadd();
                }
            }, "Thread " + i).start();
        }

        while (Thread.activeCount() > 2 ){//判断后台运行线程数是不是大于2,若大于2则表示没有计算完成,后台默认会有两个线程运行:main线程和GC线程
            Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "num :" + myDemo.num);
        System.out.println(Thread.currentThread().getName() + "atomicInteger :" + myDemo.atomicInteger);

    }
}

在这里插入图片描述

3.指令重排序

在计算机执行程序过程中,为了提高性能,编译器和处理器会对指令进行重新排序,一般分为三种:编译器优化重排,指令并行重排,系统内存重排。单线程中指令重排序能够保证重排后的执行结果与顺序执行的结果保持一致,在多线程中线程交替执行,指令重排序无法保证重排后的执行结果与顺序执行的结果保持一致,结果无法预测。

public void test(){
	int x = 1;//1
	int y = 2;//2
	x = x + 2;//3
	y = x + y;//4
}

上述代码的顺序执行顺序为1 2 3 4,但在指令重排后可能会变成2 1 3 4或1 3 2 4等一些其他情况,但是4和3没办法放在1和2的前面,因为处理器进行重排序是必须考虑指令之间的数据依赖性。
下面是一段事例代码,按理来说会出现指令重排,但是我运行好多次,就是没出现。。。求大神指导一下。

public class MyDemo {
    int c = 0;
    boolean flag = false;
    public void m1(){
        this.c = 1;
        this.flag = true;
        System.out.println("m1:" + this.c);
    }

    public void m2(){
        if(this.flag){
            this.c = this.c + 5;
            System.out.println("m2:" + this.c);
        }
    }
}
public class VolatileDemo {
    public static void main(String[] args) {
        MyDemo myDemo = new MyDemo();
                new Thread(()->{
            myDemo.m1();
        }, "Thread 1").start();
        new Thread(()->{
            myDemo.m2();
        }, "Thread 2").start();
        while (Thread.activeCount() > 2 ){//判断后台运行线程数是不是大于2,若大于2则表示没有计算完成,后台默认会有两个线程运行:main线程和GC线程
            Thread.yield();
        }
        System.out.println(myDemo.c);
    }
}
m1()方法中,如果
this.c = 1;
this.flag = true;
重排为
this.flag = true;
this.c = 1;
当先执行this.flag = true;后直接执行m2()方法
则在m2()方法中,if判断为true,先进行了
this.c = this.c + 5;
然后再输出c为5
但是我执行了好多次都是6..
这块还需要在研究一下

volatile是如何禁止指令重排序
待补充
DCL(Double Check Lock 双端检锁机制)的弊端
在单例模式中,有一种俗称懒汉式的实现方式

public class MySingleton {
    private static MySingleton mySingleton = null;

    private MySingleton(){
        System.out.println(Thread.currentThread().getName() + "产生一个实例");
    }
    public static MySingleton getInstance(){
        if(mySingleton == null){
            mySingleton = new MySingleton();
        }

        return mySingleton;
    }

    public static void main(String[] args) {
        for (int i = 1; i <= 10; i++) {
            new Thread(() -> {
                MySingleton.getInstance();
            }, "Thread " + i).start();
        }
    }
}

这种实现方式是非线程安全的,执行结果如下:
在这里插入图片描述
会在多个线程中进行实例的初始化,因此需要给 getInstance()方法加锁,但如果给整个getInstance()加锁,代价太大,会导致并发量下降,所以引入DCL,既在加锁前和加锁后分别进行判断

    public static MySingleton getInstance(){
        if(mySingleton == null){
            synchronized (MySingleton.class){
                if(mySingleton == null){
                    mySingleton = new MySingleton();
                }
            }
        }

        return mySingleton;
    }

此时可以保证在99.99%的情况下都是现成安全的,但是剩下的0.01%是会由于指令重排序,导致线程不安全。

mySingleton = new MySingleton();
初始化过程可分为如下三步
1.为新对象变量申请内存空间
2.初始化对象(调用构造方法)
3.mySingleton指向初始化完成的对象
此时23并不存在数据依赖关系,因此可能进行指令重排
1.为新对象变量申请内存空间
3.mySingleton指向初始化完成的对象
2.初始化对象(调用构造方法)
当线程1,因指令重排,走到3.mySingleton指向初始化完成的对象时,线程2获得CPU时间,判断mySingleton不为NULL,
然后直接返回,此时再调用mySingleton中的方法就会产生问题
因此就需要给mySingleton增加volatile修饰符
private static volatile MySingleton mySingleton = null;


易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!