全网最稀缺讲解,基于底层硬件级别带你从0到精通Volatile原理

什么是Volatile

但凡是学过四个月java培训班的同学都知道synchronized和volatile,有些同学,甚至于网上有些博客,
会把volatile当成是轻量级锁,有些同学可能会说volatile是可以保证原子性、可见性、有序性,
但是再往下问下去就不清楚了,上网搜一些volatile的博客,那些人写的天花烂坠,描述的一点都不清楚,
所以本文会全程用大白话一步一图从底层硬件级别带着大家从0开始了解volatile。
复制代码

Diss一下网上乱写的博客

带着关键字搜一下“Volatile是轻量级锁”,就可以搜索到大量的乱写的文章,
我这里随便截了两个,不知道这种结论是谁流传出来的,可以说是误导了大量的工程师,一巴掌拍死。
复制代码

WX20220320-175521@2x.png

WX20220320-175602@2x.png

用一段小程序来演示一下

首先先让我们看一段小程序,大家也可以复制到自己的编译器中运行一下
(如果你这段代码看不懂的话,可以先去百度快速入门一下多线程的基础知识)。
复制代码
public class VolatileDemo {
   static int flag = 0;
   
   public synchronized static void main(String[] args) {
      new Thread(() -> {
         int localFlag = flag; 
         while(true) {
            if(localFlag != flag) {
               System.out.println("读取到了修改后的标志位:" + flag);  
               localFlag = flag;
            }
         }  
      }).start();
      
      new Thread(() -> {
         int localFlag = flag;  
         while(true) { 
            System.out.println("标志位被修改为了:" + ++localFlag); 
            flag = localFlag;
            try {
               TimeUnit.SECONDS.sleep(1);
            } catch (Exception e) {
               e.printStackTrace(); 
            }
         }
      }).start();
   }
   
}
复制代码
我们可以看到,有一个线程每隔一秒钟就去修改一个内存中的flag值,
另一个线程去读取内存中的flag来做对比,如果不一致的话,就打印出修改后的flag的值,
此时让我们运行main方法,可以看到下图结果
复制代码

WX20220315-221408@2x.png

这时候,我们把flag加上volatile,然后再跑一下代码
复制代码
static volatile int flag = 0;
复制代码

WX20220315-221739@2x.png

现代计算机操作系统的CPU多级缓存模型

如果多个线程共用一个共享变量,有人写,有人读,那么其实是有问题的,
有可能会导致有的线程没法及时读到别人修改的变量的值,一直读到的就是老的值,
为什么会出现上面这两种情况呢,接下来我带着大家来分析一下。
首先,我们要知道,如果cpu需要频繁的去读写主内存,那么它的效率必然是很低的,就会导致性能下降,
不适合现代计算机的发展。
所以现代计算机的cpu是有高速缓存,可以直接读写高速缓存,而不是读写主内存,这样相对来说效率就高很多了。
如下图所示,cpu会有几层缓存,数据会先从主内存读取到高速缓存中,然后频繁的读写都是基于高速缓存来执行的。
复制代码

CPU缓存模型.jpg

缓存不一致的并发问题

有一点开发经验的朋友,看到了缓存,就会想到了什么?
没错,就是数据不一致。很经典的一个,redis中的数据库缓存双写不一致问题,
这个我后面也会用大白话加一步一图的方式来讲解一下。
同样的,cpu使用了高速缓存,虽然大大提高了读写的效率,但是就会造成一个缓存与主内存数据不一致的情况。
如下图所示,我两个cpu都挂了缓存,并且都有两个线程在,结合上面的代码来看,我线程0把主内存中的flag读取到了缓存里,
然后线程0再从缓存中获取flag,线程1也是同理,然后线程1获取到了flag以后,会对这个flag进行++操作,
注意此时这个flag是线程1的cpu中高速缓存里的flag,但是此时我线程0能感知到这个flag数据变了么?
当然是感知不到了,因为它感知的flag一直都是它自己缓存里的那个flag,
所以这就很明显了,cpu的本地缓存和主内存数据不一致。
复制代码

CPU缓存模型下的并发问题.jpg

如何解决缓存不一致?

那么这个问题要怎么解决呢?
复制代码

总线加锁机制

其实最早期的时候,有一个总线加锁机制,这个东西其实没什么人用了,
它的意思就是,某个cpu要读取某个数据,就会通过总线,给这个数据加一个锁,这样其他的cpu就没法读和写这个数据了,
只有这个cpu修改完了以后,别人才能读到最新的数据,
但是这个就会导致,多线程并发访问一个共享变量的时候,直接就串行化了,这样性能就会很差了。
复制代码

MESI协议以及CPU嗅探机制

总线加锁机制这个东西已经没有什么人用了,所以大概了解一下就行了,现在比较流行的就是一个MESI协议,也就是缓存一致性协议。
如果说我们使用了这个MESI协议之后,我线程1,修改了flag,
JVM会发送一条lock前缀指令给CPU,那么就会把它强制性的刷回主内存,然后再类似发个消息通知一下,
然后此时还会有一个cpu嗅探机制,我其他cpu会去不断的嗅探,
一旦感知到flag被修改了,那么就会强制的让自己本地缓存里面的flag过期,
然后下次再需要读取flag的时候,就会再去从主内存中读取flag,这个就比总线加锁机制好太多了。
复制代码

Java内存模型以及多线程并发问题的发生

Java内存模型是基于CPU的缓存模型来建立的,只不过Java内存模型是标准化的,他可以屏蔽掉底层不同的计算机的区别。
Java内存模型分为两个概念,一个是线程的工作内存,另一个是主内存,
工作内存我们可以理解为之前CPU的高速缓存。
Java内存模型定义了一些操作,分别是read,load,use,assign,store,write。
下面我们结合图形,来分析一下这几个操作,以及多线程并发问题的发生。
复制代码

Java内存模型.jpg

首先,我们先假设,线程0就是我们代码里那个写操作的线程,我们的线程会有一块属于自己的工作内存,
然后flag先是存在于主内存中的,然后会先执行第一步,read,会从主内存中,读取flag变量,
此时flag=0,然后就是load,这时候会把flag=0加载到自己的工作内存中,然后就是该去使用这个变量进行计算了,这时候就是use,
计算完成后,就会将变量重新写回工作内存中(assign),然后就是store,将工作内存中的变量重新写入主内存,
最后就是write,将flag=1赋值给主内存中的变量flag。
我们可以看到,线程用于计算的变量值,是他自己工作内存中的变量,
所以此时如果有另一个线程来从主内存中读取变量进行计算,这时候,就会出现多线程并发问题。
复制代码

可见性、原子性、有序性

这三个也就是并发编程中会出现的三类问题。 
复制代码

可见性

其实之前代码演示和画图演示的,全都是可见性问题,
一个线程在那里夸夸夸修改自己工作内存和主内存中的值,但是另一个线程却看不到,
他只能看到自己工作内存中的值,这个就是不具备可见性。    
复制代码

Volatile是如何保证可见性

这里可以基于之前Java内存模型的那张图来讲解,
加了Volatile以后,线程0 assign了以后,工作内存中的flag变成了1,这时候就立马执行store和write,马上把主存中的flag也变成1,
然后这时候,会去过期线程1中的工作内存里的flag,当线程1要去use的时候,感知到它已经过期了,
就会从主存中重新read load进工作内存。
通过volatile关键字,可以实现的一个效果就是说,
有一个线程修改了值,其他线程可以立马感知到这个值    
复制代码

原子性

这里我以i++来演示。
刚开始主存内的i是0,然后线程0和线程1都从主存中读取变量i刷进自己工作内存中,
然后都用这个i进行i++计算,然后分别都得出新的i=1,
然后赋值给自己工作内存中,
然后再刷回主内存,都把主存里面的i,由0变成了1,
用屁股想想也知道这个不对了,此时的i应该是2才对,怎么会是1呢,所以这个就是原子性。
复制代码

原子性.jpg

几乎没人能解释的清楚,volatile为什么无法保证原子性

如果去网上搜的话,所有人都会说volatile是无法保证原子性的,但是几乎没有人能够解释的清楚,
这里我结合上面java内存模型的那张图,讲解一下,为什么volatle是无法保证原子性的。
假设现在有一个变量
volatile i = 0;
然后有两个线程去执行i++,这时候数据还是可能会出错的。为什么呢?其实很简单的,接下来结合图形,看我分析。
复制代码

为什么volatile无法保证原子性.jpg

线程0和线程1都是read load use了主存中的i,然后并且这个i是加了volatile修饰的,
然后这个时候,线程0计算完成了i++,进行了assign,然后由于加了volatile,所以会立刻进行store和write,
并且使线程1工作内存中的i过期,
但是有影响么?没有的,
如果线程1这时候是要use工作内存的i的时候,那就会有影响,发现它过期了,会重新从主存中读取并load,
不过它这时候是assign,assign完了马上也就store和write,
把这个i = 1重新刷回主存,这波懂我意思么。
所以说,volatile是轻量级锁,简直就是误导。
复制代码

有序性

有时候,为了提高程序的执行效率,编译器和指令器,会将指令进行重新排序,这个就是指令重排,
它会造成一个什么样的后果呢?我们看下面的代码。
复制代码
// 变量flag
flag = false;

// 线程1:

// 准备资源
prepare();
// 资源准备完毕,将flag改成true
flag = true;         

 

// 线程2:
// 一直在监听flag
while(!flag){

  Thread.sleep(1000);

}
// 基于准备好的资源执行操作
execute(); 
复制代码
如果指令重排的时候,把flag = true;和prepare();
顺序反一下,那么线程2就会直接开始execute(),但是这时候,我们的资源其实还并没有准备好,所以这个就会出现问题了。
复制代码

基于happens-before原则来看volatile如何保证有序性

前面已经讲到过了,编译器和指令器会对代码进行重排序,乱排序,
但是只要是符合happens-before原则,那么它就不会进行胡乱重排,如果不符合的话,那就可以进行重新排序了。
那么什么是happens-before原则呢?其实就是8条规则,这里大家大概了解一下就可以了,没必要去背它,
只要记住符合happens-before原则的话,不能干一些很荒谬的事,就不会进行指令重排。

程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作

锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作

volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作,必须保证是先写,再读

传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C

线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生

对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始
复制代码

内存屏障

之前讲过java内存模型的几种操作,比如load store这种,这些操作就和内存屏障有关系了。无论是对volatile修饰的变量进行读或者写,都会加内存屏障,可以保证前后的一些代码不会被指令重排,内存屏障一共有4种,接下来画个图解释一下。

LoadLoad屏障:load1指令的数据装载一定优先于load2指令的数据装载。

StoreStore屏障:store1指令对应的数据一定会先于store2指令的数据刷回主存,对其他cpu可见。

LoadStore屏障:load1指令的数据装载一定优先于store2指令以及其后续指令。

StoreLoad屏障:store1的指令对应的数据一定先刷回主存,对其他cpu可见,然后再执行load2指令装载数据。

volatile写操作前面,会加一层StoreStore屏障,防止前面的写操作和volatile操作进行指令重排,
volatile写操作后面,会加一层StoreLoad屏障,防止后面的volatile读/写操作跟它指令重排了。
volatile读操作后面,会加一层LoadLoad屏障,禁止后面的普通读跟它指令重排,
还会再加一层LoadStore屏障,禁止后面的普通写操作和它指令重排。
复制代码

内存屏障.jpg

但是其实java虚拟机它在运行的时候,会有很多复杂的细节和优化,细节不一定按照这个来,
但是大体意思就是这样,避免指令重排。只要知道大致的原理就ok了,没必要在这里死磕。
复制代码

volatile简单使用之单例模式优化 double check

单例模式,不用我多说,哪怕是四个月java培训班里出来的都知道。
下面看一段单例模式的双重检测的代码,以及分析一下为什么要加volatile。
复制代码
public class DoubleCheckSingleton {
    private static volatile DoubleCheckSingleton instance;

    public static DoubleCheckSingleton getInstance() {
        // 多个线程会卡在这里
        if (instance == null) {
            synchronized (DoubleCheckSingleton.class) {
                // 有一个线程先进来
                // 第二个线程进来了,此时如果没有这个double check判断的话
                // 就会导致它再次创建了一遍实例
                if (instance == null) {
                    // 创建一个单例
                    DoubleCheckSingleton.instance = new DoubleCheckSingleton();
                }
            }
        }
        return instance;
    }
}
复制代码
结合前面的java内存模型的图,就可以理解这里为什么要加volatile了。
线程1创建了一个instance,出去了,这时候线程2进来了,但是这时候,它工作内存里面的instance可能还是null,就会导致它再次创建了一遍实例。
如果我们加了volatile以后,
线程1 assign了以后,立马store write,
然后过期线程2工作内存里面的instance,它这时候就只能再去从主存中读取,但是这时候主存里instance已经不是null了。
复制代码

Guess you like

Origin juejin.im/post/7077571754205380645