从内存角度了解volatile的使用

一、前言

        相信大家在项目中或多或少都使用过volatile关键字,那么今天这篇文章就会带你了解volatile的原理和如何使用volatile。

        首先,我们要清楚一个前提条件,那就是volatile发生的场景一定是多线程!!而多线程又分为多线程并发多线程并行,你清楚这两个的区别嘛?volatile是发生在哪个场景?

二、多线程并发和并行区别

所谓的并发和并行的解释如下:

  • 并发:同一个时间段内多个任务同时都在执行
  • 并行:同一个时间段内多个任务同时在执行

区别:并发多了一个字,开个玩笑。下面举个例子:

例如:一台四核的CPU电脑,我们可以想向成一个四条车道的高速公路。一核对应一条公路。

此时CPU有八个任务要执行,可以想象成八辆车子要上路。那他们对应的并行和并发关系如下:

并行:

例如下面,最多同时可以有四个任务执行。

并发:

多个任务依次执行(假设没有CPU时间片轮转的情况)

再极端的说,如果你的电脑是单核CPU(这配置你应该没有吧),你的多线程肯定是并发执行的,因为单核cpu一次只能处理一个任务,其他任务需要挂起排队等待CPU的时间片轮转。

所以说,随着时代的进步,多核CPU早已代替单核CPU的存在。也就是说,我们如今面对的多线程,线程(任务)的个数往往多于CPU的可以同时执行的个数,所以一般都称多线程为并发编程,而不是并行编程。

到这里,上面的问题👉volatile是发生在哪个场景?

那肯定是多线程并发环境中。

如果需要了解一下上面经常提到的CPU时间片轮转,可以往下看。了解伙伴的可以直接看第四节 volatile发生场景

三、CPU时间片轮转

        先解释下什么是CPU时间片轮转(也可以叫做上下文切换),所谓的CPU时间片轮转,其实就是CPU专门为每个线程分配了一个时间片,也就是这个线程可以运行的时间。一旦时间到了,就需要切换到另外一个线程。

        简单的说,就是CPU为每个线程分配了一个时间,A线程执行时间到,换B线程执行,B线程执行时间到,换A线程执行,一直反复进行。(假设AB线程的任务都没有执行完)

到了这里,你会不会有个问题,

那为什么需要时间片轮转?我一个一个任务的执行,他不香嘛(就像我们上面八辆汽车同时依次通过)?

        这是因为CPU的设计者为了让用户感觉多个线程是在同时执行的,再简单的说,就是为了让你可以同时打游戏,同时看电影。(这就是我们常说的多线程工作,一边划水,一边敲代码🤣)

那如果我任务没有执行完?我的时间片到了,下次执行CPU怎么知道我之前运行到哪里?

        所以再切换线程时,CPU会保存当前线程的执行现场,以保证再次执行时可以快速的恢复现场。

线程上下文切换的时机有:当前线程的CPU时间片使用结束完处于就绪状态时或者当前线程被其他线程中断时

        在这一小节中,我们需要知道的是,线程可能存在上下文切换,而切换会带来一定的资源开销。

四、volatile发生场景

        我们先看一段代码

public class JMMTest {

    private static boolean initFlag = false;

    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println("begin...........");
            while (!initFlag) {
            }
            System.out.println("ending.............");
        }).start();

        try {
            Thread.sleep(10);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(() -> {
            prepare();
        }).start();
    }

    public static void prepare() {
        System.out.println("prepare assign  begin.......");
        initFlag = true;
        System.out.println("prepare assign  end.......");
    }
}

可以先在脑海中想一下运行的结果是什么?是依次输出四条println语句嘛?还是其他?

代码执行结果如下:

 可以很明显的看到,就算initFlag == true,第一个线程的while循环依旧没有结束?

解决的方法很简单,给initFlag 加一个volatile关键字即可。

但是,身为一名程序员,我们必须弄清楚是什么原因导致的?

        其实这是由两个线程之间的对数据的操作导致数据不一致的问题,而关于数据不一致的问题可能你需要先了解Java的内存模型对变量操作所存放的位置差异。

而这个其实就是我们经常说的volatile解决的问题之一,可见性问题

提示:虽然上面也可以通过加synchronized来解决,但是不推荐。

五、Java内存模型

        Java内存模型(Java Memory Model,简称JMM),是用来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

        需要记住几个点,如下(很重要,记笔记):

  1. Java内存模型规定所有的变量都存储在主内存
  2. 每一条线程有自己的工作内存,工作内存中保存了该线程使用的变量的内存地址
  3. 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,不能直接读写主内存中的数据
  4. 不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递需要通过主内存来完成

而这些主内存,工作内存是什么?

主内存:直接对应与物理硬件的内存(也就是内存条)

工作内存:为了获取更好的运行速度,虚拟机可能会让工作内存优先存储与CPU的寄存器或者高速缓存中,因此程序执行时主要访问的是工作内存

主内存和工作内存的关系,如下图:

高速缓存的作用:是为了解决CPU运算速度与内存读写速度不匹配的矛盾。一般情况下高速缓存的访问速度是主内存的 10~100 倍。

 最后,贴一张完整的JMM模型如下:

         在这里,我们要注意一个点,虚拟机可能会让工作内存优先存储与CPU的寄存器或者高速缓存中。

        到这里,我们需要了解一个问题,上面提到的,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而什么样的数据会使得工作内存变成高速缓存,也就是代码放到高速缓存中运行呢?

而这个问题的答案设计到了CPU的局部性原理,局部性的原理解释如下:

局部性原理是指CPU访问存储器时,无论是存取指令还是存取数据,所访问的存储单元都趋于聚集在一个较小的连续区域中。

而局部性原理又分为时间局部性和空间局部性(还有一个顺序局部性),先简单介绍下什么是时间局部性和空间局部性

  • 时间局部性:如果一个信息项正在被访问,那么在近期它很可能还会被再次访问。比如循环、递归等。
  • 空间局部性:在最近的将来将用到的信息很可能与正在使用的信息在空间地址上是临近的。例如对数组值(array[ 0 ][ 0 ] -》 array[ 0 ][ 1 ] -》省略)获取的值都是在连续的空间上的,而不是跳跃式获取(array[ 0 ][ 0 ] -》 array[ 1 ][ 100 ] -》array[ 0 ][ 99 ]),很明显前者的获取速度是肯定优于后者的。

也就是说,当你的代码处在上面两个条件中时,代码一般会在高速缓存中。这也很好理解,可以加快程序的运行速度。如果每次都从主内存取的话,那他的效率肯定时不如高速缓存的。

通过代码来主要理解时间局部性

还是上面那个volatile的代码,稍微改动一下,如下:

就是一个对sum进行+1,20ms后对sum进行赋值的操作的代码,代码如下:

public class CpuLocalityTest {
    static int sum = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            System.out.println("begin..........sum:" + sum);
            for (int i = 0; i < 900000000; i++) {
                sum ++;
            }
            System.out.println("ending.............sum:" + sum);
        }).start();

        try {
            Thread.sleep(20);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(() -> {
            prepare();
        }).start();
    }

    public static void prepare() {
        System.out.println("prepare assign  begin.....sum:" + sum);
        sum = 10000;
        System.out.println("prepare assign  end......sum:" + sum);
    }
}

输出结果如下:

         按照我们正常的理解,代码无论怎么执行,都不会结果是sum == 900000000。那我们给sum加一个volatile看下结果。

sum变量加了volatile输出结果如下:

         如果你对比了sum变量加volatile的前后,你可以清晰的发现。加了volatile之后,代码的执行时间大幅度的增加了。这也不难理解,volatile关键字既然可以解决多线程间的可见性问题,那肯定是会有额外我们看不见操作,事实也是如此。

        在代码中,当我们为一个变量加上volatile关键字时,那么JVM会在代码转成汇编时,给调用到有volatile关键字代码块加上一个lock的汇编指令

      可以打印下看下代码具体的汇编指令

例如下面这句就是prepare方法中的调用sum操作(加了volatile

 prepare方法中的调用sum操作(没加了volatile

 可以很清晰的看到,volatile的作用其实就是给调用的代码块多了一个lock指令操作。(lock指令的作用后面会讲到)

这一章节我们需要记住的是:不同的线程对变量的操作是放在不同的内存块的,它们彼此不能直接通信,而这内存块有可能是高速缓存。

六、可见性 

        知道了Java内存模型后,我们先看看为什么多线程会引起数据间不同步的问题,其实也就是CPU中缓存一致性的问题,接下来我们看看什么是缓存一致性。

6.1 缓存一致性

        前提是在并发环境中,由于每个线程都是独自占有CPU的核心,也就是独自占有一个高速缓存,此时就会带来一个数据的不一致问题。

我们通过一个例子来详细说明下:

        例如:还是上面 四、volatile发生场景 的代码

线程1一直对initFlag监听,值为true时就退出。

线程2对initFlag赋值为ture。

我们画一张图来理解一下他们在内存中的关系。如下:

假设线程1和线程2分别在CPU的第一个核心和第二个核心上,initFlag在主内存中,如图所示:

 当他们对initFlag值进行操作时,步骤如下:

1.线程1获取initFlag变量将其放到自己的工作内存(高速缓存)中,进行循环操作

2.线程2获取initFlag变量将其放到自己的工作内存(高速缓存)中,将initFlag设置为true,然后写回内存,此时内存的值initFlag为true。

3.由于线程间不能直接通信,所以此时线程1无法知道initFlag已经修改过。(上文的Java内存模型已经提到)

所以此时需要一个可以进行线程间通信的中介,而在Java中,volatile关键字就可以解决这个问题。而通过前面的内容,我们知道volatile就是一个lock指令,那么这个lock指令有什么用呢?

作用如下:

在Intel CPU下,加了lock指令执行的代码会有如下效果:

1.会将当前处理器缓存行的数据立即写回到系统内存

2.这个写回的内存操作会引起其他CPU(也就是线程)缓存了该地址的数据无效(MESI 缓存协议一致性)

3.提供内存屏障的功能,使得lock指令不会进行重排。

4.各个厂商对指令的效果可能不一样,例如安卓的ARM架构指令,或者AMD架构指令。

所以说,这才是为什么加了volatile可以解决多线程间的可见性问题,和后面会讲到的有序性问题。至于具体是什么操作的。。这就不是我们该关心的吧😀毕竟我们又不是开发芯片的,还是要着重于代码本身。

这里还可以了解一下,数据写回内存的操作会引起其他CPU,其实就是总线嗅探机制。

总线嗅探机制

        总线嗅探机制的功能:当某个CPU核心更新了高速缓存中的数据时,要通过总线把该事件通知给其他CPU核心。

        但是总线嗅探机制也会有个问题,他只能保证某个CPU核心的高速缓存数据更新能被其他CPU核心知道,但是并不能保证事物串形化。

什么是事物串形化?

事物串行化就是保证多个事务的并发执行是正确的,举个例子说明一下:

例如现在有ABCD四个CPU核心,同时对变量 i 进行操作。

同一时间里,A核心把变量 i 赋值为100, B核心把变量 i 赋值为200

此时,通过总线嗅探机制,这两个修改会被通知给C和D核心。

那么就会出现一个问题,假设C号核心先收到A核心的数据,在收到B核心的数据,而D号核心相反。

那如何保证C和D核心看到的并发操作是相同的。

于是就有一个协议基于总线嗅探机制实现了事务串形化,这个协议就是缓存一致性协议。

缓存一致性协议 MESI ( Modified Exclusive Shared Invalid ) :

MESI协议其实就是4个状态单词的开头字母缩写,如下:

  • Modified,已修改状态,需要在合适的时机写回内存
  • Exclusive,独占状态,此时该数据只存在某个核中,其他核没有该数据
  • Shared,共享状态,是从独占状态转移过来的。意指多个核中都有该数据,这个时候就会存在缓存一致性的问题;
  • Invalid,失效状态

其实就是通过这个四个状态对高速缓存里的数据进行不同的标记。例如,还是上面的例子:

1.当CPU A核心从主内存中读取变量 i 的值时,此时如果没有其他CPU 核心的高速缓存中缓存了该值,那么就会把 i 数据标记为独占状态。

2.接下来CPU B核心也从主内存中读取变量 i 的值时,此时会发消息给其他CPU核心,由于CPU A核心已经缓存了该数据,所以会把A核心的数据返回给B核心。此时该数据(所有缓存了该数据的核心)被标记为共享状态。

3.当CPU A核心要修改变量 i 的值时,发现数据的状态是共享状态,则要向所有缓存了该数据的CPU核心发送一个请求,要求先把自己缓存中对应的数据标记为无效状态(也就是说此时其他核心中对数据做的任何修改都不会被写回内存中,保证了不可能会有两个核心同时修改变量然后CPU A核心才可以对高速缓存中的数据修改,同时标记数据为已修改状态,CPU A核心将数据写回到主内存。

最后,如果其他核心想要操作数据 i ,发现数据状态为无效状态,就会重新去主内存中读取变量 i ,此时数据 i 的状态又变回共享状态

通过上面的四个状态,我们就能保证C和D核心看到的并发操作是相同的,因为A核心要修改变量 i 的值时,需要通知其他核心,当B核心修改数据时,发现数据状态是已修改状态,

        讲完volatile关键字解决了多线程间可见性的问题外,那就肯定要提起它解决的另外一个问题,也就是有序性问题,接着往下看。

七、有序性

        所谓的有序性其实就是解决指令重排序的问题。

为什么需要指令重排?

        因为计算机在执行程序时候,为了提高代码、指令的执行效率,编译器和处理器会对指令进行重新排序。而这些优化如果是在单线程下是没有问题,但是如果在多线程下就会出现一些意想不到的事情。

        例如在阿里巴巴Java开发手册中,建议双重检查时(DCL)在并发环境下,要将instance声明为volatile,接下来我们通过代码来理解,如下:

public class SingleDemo {

    private static SingleDemo mInstance;

    private SingleDemo(){

    }

    public static SingleDemo getInstance(){
        if(mInstance == null){
            synchronized (SingleDemo.class){
                if(mInstance == null){
                    mInstance = new SingleDemo();
                }
            }
        }
        return mInstance;
    }
}

而上面这个代码,如果mInstance变量在不加volatile的并发情况下,获取到的mInstance可能是未经初始化的值。(虽然概率很小)

接下来,我们分析一下上面的代码为什么会出问题?

首先查看一下代码对应的JVM指令,如下:

 假设,上面图片箭头所指向的两句指令发生指令重排,如下:

 PUTSTATIC jmm/SingleDemo.mInstance : Ljmm/SingleDemo; 
 INVOKESPECIAL jmm/SingleDemo.<init> ()V

 我们知道,一个类的真正初始化是调用了<init>方法后,如果此时先调用PUTSTATIC指令,此时的mInstance就有值了(但是这个值并不是一个真正的对象),然后如果此时别的线程进来访问,返回一个未经初始化的对象回去。

        为什么这两句会发生重排?

        因为如果在单线程环境下,这两句指令的无论怎么排序都不会影响最终结果,一句是对mInstance赋值,一句是对SingleDemo类进行初始化。

        那为什么加了volatile就可以解决代码重排序问题?答案就如同上文提到的,lock指令提供内存屏障的功能而这个内存屏障的作用其实指令在优化时,遇到lock指令就不进行优化。

八、先行发生原则

        前面介绍完有序性后,那么你会不会有一个疑问,那Java中所有的有序性都得加volatile关键字来保证(当然还有synchronized),那岂不是所有的代码都要加volatile或者synchronized,并非如此,在Java当中,有一个先行发生原则。这个原则的作用,如果你的代码符合下面的规则,那么他们的代码顺序是有保障的,如果不符合,那么虚拟机就会对代码进行重排序。

规则如下:

程序次序规则:在一个线程内一段代码的执行结果是有序的。就是还会指令重排,但是随便它怎么排,结果是按照我们代码的顺序生成的不会变。(其实这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性

管程锁定规则:就是无论是在单线程环境还是多线程环境,对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁都能看到前一个线程的操作结果!(管程是一种通用的同步操作,synchronized就是管程的实现,但是其保证的是顺序性串行化的结果同步块里的语句是会发生指令从排)

volatile变量规则:就是如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见。

线程启动规则:在主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见。(也就是在线程A修改的值,肯定先于子线程B。

线程终止规则:在主线程A执行过程中,子线程B终止,那么线程B在终止之前对共享变量的修改结果在线程A中可见。也称线程join()规则。

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程代码检测到中断事件的发生,可以通过Thread.interrupted()检测到是否发生中断。

传递性规则:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。(例如 int a = 2; int b = a; 变量间具有传递依赖关系)

对象终结规则:就是一个对象的初始化的完成,也就是构造函数执行结束一定 先于它的finalize()方法。(也就是对象创建一定先于他的销毁

九、总结

        volatile解决了多线程间可见性和有序性的问题。相比与synchronized,volatile是一种更加轻量级的解决方案。虽然synchronized也能解决可见性和有序性的问题,但是会比volatile的资源开销更加大。

        所以平时在遇到能使用的volatile的情况下,就不要使用synchronized,同时也要注意,volatile不能保证代码的原子性(例如上面的时间局部性的例子代码,代码不是很严谨🤭,代码只是为了更好的表达时间局部性原理)。而关于原子性,会留到下一章对synchronized关键字进行分析时描述。

        简单的来说原子性就是多个线程间执行的结果不一样的问题。所以,volatile的使用情况只能对变量直接进行赋值操作(i = 100,flag = true),而不能使用在先获取值再赋值,例如(++i,i++)。i = 100,++i,i++对应的指令如下(具体原因留到下一章):

    
    //对应i =100指令
    BIPUSH 100
    PUTSTATIC jmm/JMMTest.i : I
   
     //对应++i,i++指令
    GETSTATIC jmm/JMMTest.i : I
    ICONST_1
    IADD
    PUTSTATIC jmm/JMMTest.i : I

    

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值