JUC并发编程中Volatile关键字详解与JMM内存模型

Volatile 是什么

  • Volatile是JVM提供的轻量级的同步机制
  • Volatile是一个Java关键字,用来对变量进行修饰
  • Volatile只保证了JMM三大特性的两点

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

JMM内存模型是什么

JMM内存模型本身并不实际存在,而是一个抽象的概念,
他描述的是一种规则或规范,通过这组规范定义了实例变量的访问方式.

JMM关于同步的规定:

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

Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写会主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成
JUC并发编程中Volatile关键字详解与JMM内存模型_第1张图片

Volatile保证可见性

可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。
不保证可见性的代码演示

public class MyThreadDome {

    static class data{
        public void setNum(int num) {
            this.num = num;
        }

         int num=0;

    }
    public static void main(String[] args) {

        data data = new data();

        new Thread(() -> {
            //线程睡眠
            try{ TimeUnit.SECONDS.sleep( 3 ); } catch(Exception e){}
            data.setNum(10);
            System.out.println("修改为"+data.num);
        }, "A").start();



        
        while(data.num!=10){   //此处主线程已经将data.num读取进工作内存,但此时A线程还未修改
                    //A线程休眠结束后,修改掉data.num,可是这里循环并未退出,原因是不保证可见性主线程不会主动去主内存中重新读取data.num
        }
        System.out.println("over");
    }

}

volatile实现可见性的原理

volatile 使用了内存屏障(Memory Barrier)保证了变量的可见性

  • 内存屏障,又称内存栅栏,是一个 CPU 指令。
  • 在程序运行时,为了提高执行性能,编译器和处理器会对指令进行重排序,JMM 为了保证在不同的编译器和 CPU 上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器重排序和处理器重排序,插入一条内存屏障会告诉编译器和 CPU:不管什么指令都不能和这条 Memory Barrier 指令重排序。

在 volatile 修饰的共享变量进行写操作的时候会多出 lock 前缀的指令

lock 前缀的指令在多核处理器下会引发两件事情:

  • 将当前处理器缓存行的数据写回到系统内存。
  • 写回内存的操作会使在其他 CPU 里缓存了该内存地址的额数据无效。

Volatile不保证原子性

volatile不能保证完全的原子性,只能保证单次的读/写操作具有原子性。
i++为什么不能保证原子性
对于原子性,需要强调一点,也是大家容易误解的一点:对volatile变量的单次读/写操作可以保证原子性的,
如long和double类型变量**(浮点型变量为64为存储,在32位机器上读取分为高32位,低32位操作,但这也属于单次读写操作,volatile可以保证他的原子性)**,
但是并不能保证i++这种操作的原子性,因为本质上i++是读,加,写三次操作。
volatile是无法保证这三个操作是具有原子性的,我们可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。

Volatile保证有序性

volatile保证有序性的主要操作是禁止指令重排
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令重排,一般分为以下三种:

源代码 -> 编译器优化的重排 -> 指令并行的重排 -> 内存系统的重排 -> 最终执行指令

这种指令重排在单线程环境下能够保证最终执行结果和代码顺序结果一致,因为在重排序时,处理器会考虑到数据的依赖性
多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果无法预测。

指令重排

public void mySort() {
	int x = 11;  //1
	int y = 12;  //2
	x = x + 5;   //3
	y = x * x;   //4
}

按照正常单线程环境,执行顺序是 1 2 3 4
但是在多线程环境下,可能出现以下的顺序:

  • 2 1 3 4
  • 1 3 2 4

上述的过程就可以当做是指令的重排,即内部执行顺序,和我们的代码顺序不一样
但是指令重排也是有限制的,即不会出现下面的顺序

  • 4 3 2 1

因为处理器在进行重排时候,必须考虑到指令之间的数据依赖性
因为步骤 4:需要依赖于 y的申明,以及x的申明,故因为存在数据依赖,无法首先执行

例子

int a,b,x,y = 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

这样造成的结果,和最开始的就不一致了,这就是导致重排后,结果和最开始的不一样,因此为了防止这种结果出现,volatile就规定禁止指令重排,为了保证数据的一致性

Volatile针对指令重排的操作

Volatile通过内存屏障实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象

主要操作

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

volatile 写是在前面和后面分别插入内存屏障,而 volatile 读操作是在后面插入两个内存屏障。

内存屏障 说明
StoreStore 屏障 禁止上面的普通写和下面的 volatile 写重排序。
StoreLoad 屏障 防止上面的 volatile 写与下面可能有的 volatile 读/写重排序。
LoadLoad 屏障 禁止下面所有的普通读操作和上面的 volatile 读重排序。
LoadStore 屏障 禁止下面所有的普通写操作和上面的 volatile 读重排序。

volatile写
JUC并发编程中Volatile关键字详解与JMM内存模型_第2张图片

volatile读

JUC并发编程中Volatile关键字详解与JMM内存模型_第3张图片

单例模式下的volatile

在多线程环境下使用单例模式由于线程交替调用getInstance(),无法保证单例
(多个线程通过if判断屏障后被挂起,再次被唤起后会调用多次构造方法)

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) {
        for (int i = 0; i < 10; i++) {
            new Thread(() -> {
                SingletonDemo.getInstance();
            }, String.valueOf(i)).start();
        }
    }
}

4	 我是构造方法SingletonDemo
2	 我是构造方法SingletonDemo
5	 我是构造方法SingletonDemo
6	 我是构造方法SingletonDemo
0	 我是构造方法SingletonDemo
3	 我是构造方法SingletonDemo
1	 我是构造方法SingletonDemo

解决方法一,可以使用sync锁住整个getInstance()方法,但是由于他是重型同步机制,所以使用时需要慎重考虑

public synchronized static SingletonDemo getInstance() {
    if(instance == null) {
        instance = new SingletonDemo();
    }
    return instance;
}

解决方法二
使用双端锁机制(DCL)

public class SingletonDemo{
	private SingletonDemo(){}
    
    private volatile static SingletonDemo instance = null;

    public static SingletonDemo getInstance() {
        if(instance == null) {  //第一个if用以性能保证,不是每一次调用都得加锁
            synchronized(SingletonDemo.class){
                if(instance == null){  //此if为安全保证,
                    					//加锁后判断通过第一个if后是否有其他线程先行创建了实例
                    instance = new SingletonDemo();       
                }
            }
        }
        return instance;
    }
}

此时的单例对象需要volatile关键字修饰
原因:
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实例未必已初始化完成,也就造成了线程安全问题。

你可能感兴趣的:(JUC并发编程,java,后端,并发编程,jvm,juc)