用volatile的视角,来打开JMM内存模型

【引言】

这一切的一切,还得从一个叫volatile的关键字提及..... java

在这里插入图片描述

【灵魂拷问开始】面试

  1. 面试官:Java并发这块了解的怎么样?说说你对volatile关键字的理解?编程

  2. 面试官:能不能详细说下什么是内存可见性,什么又是指令重排序呢?数组

  3. 面试官:volatile怎么保证可见性的?多个线程之间的可见性,你能讲一下底层原理是怎么实现的吗?缓存

  4. 面试官:volatile关键字是怎么保证有序性的?安全

  5. 面试官:volatile能保证可见性和有序性,可是能保证原子性吗?为何?网络

  6. 面试官:了解过JMM内存模型吗?简单的讲讲数据结构

这些题目,相信你认真看完此文,会有本身的理解和认识。文末见问题回答👇多线程

到这里,个人眼里已经是常含泪水了。不是由于我对代码爱的深沉,而是由于我菜的真诚! 架构

在这里插入图片描述

没事,不就是个破volatile吗?别念了,我学习还不行吗!

PS: 文章的内容是我看视频,博客,查资料的理解。在这一块可能不少人的理解有所不一样,小编我尚无工做经验,只是总结研究来学习,作个面试题的记录。文章内容从理解到查资料学习再到画图写出来,肝了挺长时间的吧。你们当作一篇面筋来看就好,主要是回答面试问题,至于深刻到底层经过字节码汇编等来经过代码说明,俺还在研究中。本文只是比较浅显的发现问题,解决问题的。不作实际的工做开发。若有不正请当即指出。


1. 多核并发缓存架构

缓存Cache设置的目的是为了解决磁盘和CPU速度不匹配的问题。可是,对于CPU来讲,Cache仍是不够快,缓存的概念再次被扩充,不只在内存和磁盘之间也有Cache(磁盘缓存),并且在CPU和主内存之间有Cache(CPU缓存),乃至在硬盘与网络之间也有某种意义上的Cache──称为Internet临时文件夹或网络内容缓存等。凡是位于速度相差较大的两种硬件之间,用于协调二者数据传输速度差别的结构,都可称之为Cache。

CPU缓存

CPU缓存(Cache Memory)是位于CPU与内存之间的临时存储器,它的容量比内存小的多。可是交换速度却比内存要快得多。缓存大小是CPU的重要指标之一,并且缓存的结构和大小对CPU速度的影响很是大,CPU内缓存的运行频率极高,通常是和处理器同频运做,工做效率远远大于系统内存和硬盘。

CPU缓存能够分为三级:

一级缓存L1

一级缓存(Level 1 Cache)简称L1 Cache,位于CPU内核的旁边,是与CPU结合最为紧密的CPU缓存。通常来讲,一级缓存能够分为一级数据缓存(Data Cache,D-Cache)和一级指令缓存(Instruction Cache,I-Cache)

二级缓存L2

L2 Cache(二级缓存)是CPU的第二层高速缓存,份内部和外部两种芯片。内部的芯片二级缓存运行速度与主频相同,而外部的二级缓存则只有主频的一半。L2高速缓存容量也会影响CPU的性能,原则是越大越好。

三级缓存L3

三级缓存是为读取二级缓存后未命中的数据设计的—种缓存,在拥有三级缓存的CPU中,只有约5%的数据须要从内存中调用,这进一步提升了CPU的效率。

任务管理器查看CPU缓存使用状况:

因此说,在咱们的程序执行时,在CPU和Cache之间,是经过CPU缓存来作交互的。CPU从CPU缓冲读取数据,CPU缓存从内存中读取数据;CPU将计算完的数据写回到CPU缓存中,而后CPU缓存再同步回内存中,内存再写回到磁盘中。

JMM内存模型简介

JMM(Java Memory Model), 是Java虚拟机平台对开发者提供的多线程环境下的内存可见性、是否能够重排序等问题的无关具体平台的统一的保证。JMM定义了一个线程与主存之间的抽象关系,它就像咱们的数据结构中的逻辑结构同样,只是概念性的东西,并非真实存在的,可是可以让咱们更好的理解多线程的底层原理。

首先,必定要先明确一个概念:CPU的运算是很是很是快的,和其余硬件不在一个量级上。

Java内存模型类比于上面硬件的内存模型,它是基于CPU缓存模型来构建的。

每个线程在操做共享变量的时候,都将共享变量拷贝一份到本身的工做区间中(由于若是多个线程同时在CPU中操做数据,就像CPU与内存直接交互同样,速度很是慢),等到当前线程的CPU运算完以后,在写回主内存。

若是此时一个共享变量发生了改变,为了保证数据一致性,就必须马上通知其余线程这个共享变量的值发生了改变,让其余线程工做内存中的副本更新,保证拿到的数据是一致的。在这通知之间,线程之间就必然会有联系和沟通

就比如两我的同时拿着同一张银行卡到银行取钱,卡里有100块,一我的取了50,帐户余额当即就变成了50。第二我的是在这50的基础上来取钱的,不可能还在100的基础上取钱。

那么,Java是怎么保证银行卡的余额当即变为50,而且是作了什么操做来保证余额的正确性呢?

<>

2. JMM内存模型验证

volatile验证内存模型

来,整一段代码再唠......

/** * @Author: Mr.Q * @Date: 2020-06-10 09:47 * @Description:JMM内存模型验证--volatile保证可见性测试 */
public class VolatileVisibilityTest {

    //此处是否添加volatile,来验证内存模型
    private static boolean initFlag = false; 

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            System.out.println("等待数据中....");
            while (!initFlag) {

            }
            System.out.println("--------------success-----------");
        }).start();

        Thread.sleep(3000);

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

    public static void prepareData() {
        System.out.println("\n准备数据中....");
        initFlag = true; //此处为第30行代码
        System.out.println("initFlag = " + initFlag);
        System.out.println("数据准备完成!");
    }
}
复制代码

首先,一个线程在等待数据,initFlag初始值为false,!initFlag进入到死循环中卡在此处。

另外一个线程准备数据,将initFlag置为true。因为是静态的成员共享变量,修改以后等待的线程可以感知到,此时跳出死循环,打印信息,程序运行结束。

可是,真的是这样吗?

咱们发现并无,此时程序依然处于死循环中,即initFlag依然为false

咦,这是怎么肥四呢?

在这里插入图片描述

单线程下跑,是没有问题的。可这是在多线程中,问题就来了。

这也就间接验证了JMM的存在,即每一个线程在工做时,都会将共享数据拷贝到本身的工做内存来操做。若是不是的话,此处多线程下执行也不会出现问题。

这时,那个男人,那个叫volatile的蓝人,它基情满满的向咱们走来了!

共享变量不一致是吧?操做没有可见性是吧?来吧,这种小事就交给我吧宝贝,么么哒😘

咱们想要达到这样的效果:

private volatile static boolean initFlag = false;
复制代码

volatile修饰initFlag变量,只要有线程作了修改,其余线程当即能够感知。

正确的运行结果,让打印出成功信息。

问题是解决了。这时,面试官不厚道的笑了🙃,这场战斗,才刚刚开始!

JMM内存模型8大原子操做

8大原子操做你们可能都有了解,可是具体到在底层是怎么交互的?每一个原子操做之间的关系是怎样的?

那么,咱们经过上面的程序来具体作个底层原理的分析,这也是可以讲清楚volatile关键字保证可见性最直观的说明了!

【JMM内存模型8大原子操做】

  • read读取: 从主内存中读取数据

  • load载入: 将主内存读取到的数据写入工做内存

  • use使用: 从工做内存读取出数据来计算

  • assign赋值: 将CPU计算出的值从新赋值到工做内存中

  • store存储: 将工做内存中更改后的值写入到主存

  • write写入: 将store回去的变量赋值给主存中的变量

  • lock锁定: 将主内存变量加锁,标识为线程独占状态

  • unlock解锁: 将主内存变量解锁,解锁后其余线程才能再次锁定该变量

仍是上面程序的代码,针对上述程序出现的问题,咱们来作个深刻的分析了解:有图有真相😒

咱们先来分析【线程1】:

  1. 首先,线程1将主内存中的initFlag = false read出来;

  2. 其次,将initFlag = false 拷贝一份到线程的工做内存中;

  3. 而后,CPU将线程工做内存(CPU缓存)中的数据拿到本身的寄存器中来计算。

此时,!initFlag为真,线程1阻塞在死循环中,等待数据中......

对于【线程2】:

  1. 前三步彻底和线程1的操做同样,每一个线程都是这么干的.

  2. 线程2中调用了prepareData方法使initFlag = true

  3. 而后CPU将改变后的值从新赋值到工做内存中,此时线程2的工做内存中initFlag = true

  4. 线程2的工做内存存储了true,并准备更新回主存中

  5. 线程2执行write操做,将initFlag = true写回到主存中

此时,主存中存放的是initFlag = true。而线程1的工做内存中任然是initFlag = false。就是线程2把initFalg改了,线程1还不知道,仍然拿的是原来的值,致使程序一直处在死循环中。

这就是程序为何卡在了这里的缘由!

那后来加上了volatile关键字,它是怎么保证线程2改完initFlag后,线程1立马就知道了呢?换句话来讲,线程2更改完initFlag后,是怎么让线程1的工做内存中拷贝的副本也当即更新呢?

3. JMM缓存不一致问题

就像上面图解的状况同样,JMM出现了缓存不一致新的问题,即线程2修改完initFlag以后,线程1工做内存中的副本和主存中不一致的问题。

那么,大佬们是如何解决这个问题的呢?

8个原子操做,这不还剩lockunlock么!他俩呀,就干这事的!

总线加锁

起初,是经过对数据在总线上加锁来实现的:

一个线程在修改数据时,会加一把lock锁到总线上。此时,其余线程就不能再去读取数据了,等到线程2将数据修改完写回到主存,而后unlock释放锁,而后其余线程才可以读取。

这样,固然保证了其余线程拿到了最新的数据,数据一致性获得保证了,可是多核并行的操做,在加锁以后变成了单核串型的了,效率低下。就这样的速度,能叫并发吗?这还怎么过双十一呀🤣!

在这里插入图片描述

MESI缓存一致性协议

MESI协议

多个CPU从主内存读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里的数据,该数据会立刻同步回主内存,其它CPU经过总线侦听机制能够感知到数据的变化,从而将本身缓存里的数据失效。

总线侦听:

当几个缓存共享特定数据而且处理器修改共享数据的值时,更改必须传播到全部其余具备数据副本的缓存中。这种变化的传播能够防止系统违反高速缓存一致性。能够经过总线侦听来完成数据更改的通知。全部侦听器都会监视总线上的每笔交易。若是修改共享缓存块的事务出如今总线上,则全部侦听器都会检查其缓存是否具备共享块的相同副本。若是缓存具备共享块的副本,则相应的窥探器将执行操做以确保缓存一致性。该动做能够是刷新无效缓存块。它还依赖于缓存一致性协议来改变缓存块状态。

MESI缓存一致性协议,经过对总线的侦听机制,很好地解决了这个问题。

没错,硬件!就是这么硬核且高效。

【简单总结一下】:

总线上安装了多个监听器,发现有线程修改了内存中的数据,就会使其余线程工做区间不一致的副本当即失效,而后让他们从新并行读取。


4. volatile可见性底层实现原理

上面讲了硬件层面上的实现,那么,软件上是怎么实现的呢?

有了总线监听器,咱们能够检测到线程修改数据的行为。可是,线程2修改了数据,监听器也检测到了,线程1是怎么知道而且修改的呢?

咱们都知道,线程间各自工做都是独立的,线程2修改了数据,并不会告诉线程1我修改了。数据都在内存上,你们共有的,我修改凭什么要告诉你😒?换句话来讲,他们都是经过主存来沟通交互的。

那么,volatile关键字是怎么保证修改的可见性的呢?

volatile的代码是用更加底层的C/C++代码来实现的

底层的实现,主要是经过汇编lock前缀指令,它会锁定内存区域的缓存(缓存行锁定),并写回到主内存中。

保证可见性原理验证

咱们对程序作反汇编,查看汇编代码:

因为汇编代码比较长,虽然俺学了微机原理,但真的是看不懂😭。就挑最重要的一句摘录出来解释

0x0000000002c860bf:lock add dword ptr [rsp], Oh ; *putstatic initFlag 
iqqcode.jmm.VolatileVisibilityTest::prepareData@1 (line 30)
复制代码

对应代码为

initFlag = true;
复制代码

A-32架构软件开发者手册对lock指令的解释:

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

  2. 这个写回内存的操做会引发在其余CPU里缓存了该内存地址的数据无效

就是经过lock指令,让initFlag当即写回内存,且让其余线程中的副本失效。

相比于此前在总线上加的重量级锁,lock指令只是在会写主内存时加了锁,就是从store操做开始才加锁,而此前的总线上加锁是从read就开始了。一旦写回,当即unlock释放锁。因为CPU的读写是很是快的,这个过程是很是很是之短的。因此volatile是轻量级的锁,性能高。

Q:若是不加 lock - unlock 指令会怎样?

线程2在store到write之间,这时initFlag = true被CPU修改了值可是尚未写回主内存,总线监听机制发现了数检测的据被修改,当即使线程1工做内存的副本失效,线程1再次去读取initFlag,但此时因为没有加锁而且还没来得及修改initFlag = false这个脏数据,线程1又将initFlag = false错误的数据拷贝到工做内存中,仍是处于死循环中,依然会存在问题。

因此,必需要在store和write之间加上lockunlock,防止时间差带来的误读。

volatile保证可见性与有序性,可是不保证证原子性,保证原则性须要借助synchronized这样的锁机制


5. volatile不保证原子性

不保证原子性验证

仍是经过代码来讲明问题:

/** * @Author: Mr.Q * @Date: 2020-06-11 11:04 * @Description:volatile不保证原子性测试 */
public class VolatileAtomicityTest {

    public static volatile int num = 0;

    public static void increase() {
        num++;
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 1; j <= 1000; j++) {
                    increase();
                }
            });
            threads[i].start();
        }

        //主线程阻塞,等待线程数组中的10个线程执行完再继续执行
        for (Thread thread : threads) {
            thread.join();
        }

        System.out.println(num); // num <= 1000 * 10
    }
}
复制代码

结果:num <= 10000

在这里插入图片描述
此时此刻,我已对并发编程的代码完全干懵🙂,含着泪,继续往下学习!

按道理来讲结果是10000,可是运行下极可能是个小于10000的值。

咦?volatile不是保证了可见性啊,一个线程对num的修改,另一个线程应该马上看到啊!

但是这里的操做num++是个复合操做,包括读取num的值,对其自增,而后再写回主存。

  • 假设线程1,读取了num的值为0,线程2恰好和线程2是同步操做,也为num=0;

  • 他俩都对num作了+1操做,同时准备write会主内存。

  • 看谁先经过总线(包括同时经过)

  • 假设是线程1先经过。MESI会将线程2工做内存中num = 1的副本马上置位无效,此时线程1已将num = 0 --> 1修改,num = 1

  • 线程2只能再次从新读取num = 1,而后执行加一再回写主内存。num = 2,可是却执行了三次循环,此时i = 3

若是线程1和线程2同时经过,因为他们工做内存中num均为1,因此仍是执行了3次循环而num自增了2次

这就是num < 10000的缘由。若是没有出现上述状况,num = 10000

【问题解决】

1. 同步加锁解决volatile原子性问题

第一种补救措施很简单,就是简单粗暴的的加锁,这样能够保证给num加1这个方法是同步的,这样每一个线程就会井井有理的运行,而保证了最终的num数和预期值一致。

2. CAS解决volatile原子性问题

针对num++这类复合类的操做,可使用JUC并发包中的原子操做类,原子操做类是经过循环CAS的方式来保证其原子性的。

AtomicInteger这是个基于CAS的无锁技术,它的主要原理就是经过比较预期值和实际值,当其没有异常的之后,就进行增值操做。incrementAndGet这个方法实际上每次对num进行+1的过程都进行了比较,存在一个retry的过程。它在多线程处理中能够防止这种屡次递增而引起的线程不安全的问题


6. volatile保证有序性

volatile保证有序性,就是禁止编译器在编译阶段对指令的重排序问题。

volatile禁止指令重排序

public class VolatileSeriaTest {

    private static int a = 0, b = 0; //此处a,b变量是否添加volatile来修饰

    public static void main(String[] args) throws InterruptedException {
        Set<String> set = new HashSet<>();
        Map<String,Integer> map = new HashMap<>();

        for (int i = 0; i < 1000000; i++) {
            a = 0;
            b = 0;
            map.clear();

            Thread one = new Thread(() -> {
                b = 1;
                int x = a;
                map.put("x", x);
            });

            Thread two = new Thread(() -> {
                a = 1;
                int y = b;
                map.put("y", y);
            });

            one.start();
            two.start();

            one.join();
            two.join();

            set.add("x=" + map.get("x") + "," + "y=" + map.get("y"));
            System.out.println(set + " --> i = " + i);
        }
    }
}
复制代码

咱们能够看到,程序一共跑出了四种状况:

这三种状况,咱们很容易想到

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-3rI0io9S-1592044179981)(iqqcode-blog.oss-cn-beijing.aliyuncs.com/img/2020061…)]

可是出现了x=0y=0就不正常了,缘由就是编译器对程序做了指令重排序

当两个线程以

  • x = a;

  • a = 1;

  • y = b;

  • b = 1;

顺序来执行,就会出现x=0y=0这种特殊状况,这是单线程下现象不到的情景。

CPU指令重排序的定义为:CPU容许在某些条件下进行指令重排序,仅需保证重排序后单线程下的语义一致

保证的是单线程下的语义一致,多线程时是不保证的,因此就须要volatile来禁止指令重排序了。

那究竟是怎么禁止的呢?

这里只是简单的说明问题,深刻的源码分析研究,你们看看源码查查资料吧。

这个涉及到内存屏障(Memory Barrier)

内存屏障简介

内存屏障有两个能力:

  1. 就像一套栅栏分割先后的代码,阻止栅栏先后的没有数据依赖性的代码进行指令重排序,保证程序在必定程度上的有序性。

  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效,保证数据的可见性。

首先,指令并非代码行,指令是原子的,经过javap命令能够看到一行代码编译出来的指令,固然,像int i=1;这样的代码行也是原子操做。

在单例模式中,Instance ins = new Instance(); 就不是原子操做,它能够分红三步原子指令:

  1. 分配内存地址;

  2. new一个Instance对象;

  3. 将内存地址赋值给ins;

CPU为了提升执行效率,这三步操做的顺序能够是123,也能够是132。

若是是132顺序的话,当把内存地址赋给inst后,ins指向的内存地址尚未new出来单例对象,这时候,若是拿到ins的话,其实就是空的,会报空指针异常。

这就是为何双重检查单例模式(DCL) 中,单例对象要加上volatile关键字。

内存屏障有三种类型和一种伪类型:

  • lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可让高速缓存中的数据失效,从新从主内存加载数据,以保证读取的是最新的数据。

  • sfence:即写屏障(Store Barrier),在写指令以后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据马上对其余线程可见。

  • mfence,即全能屏障,具有ifence和sfence的能力。

  • Lock前缀:Lock不是一种内存屏障,可是它能完成相似全能型内存屏障的功能。

volatile会给代码添加一个内存屏障,指令重排序的时候不会把后面的指令重排序到屏障的位置以前

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FssR5nnj-1592044179982)(iqqcode-blog.oss-cn-beijing.aliyuncs.com/img/2020061…)]

PS😐:只有一个CPU的时候,这种内存屏障是多余的。只有多个CPUI访问同一块内存的时候,就须要内存屏障了。

JMM的Happens-Before原则

Happens-Before 是java内存模型中的语义规范,来阐述操做之间内存的可见性,能够确保一条语句的全部“写内存”操做对另外一条语句是可见的。

Happens-Before原则以下:

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

  2. 锁定规则:一个unLock操做先行发生于后面对同一个锁额lock操做;

  3. volatile变量规则:对一个变量的写操做先行发生于后面对这个变量的读操做;

  4. 传递规则:若是操做A先行发生于操做B,而操做B又先行发生于操做C,则能够得出操做A先行发生于操做C;

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

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

  7. 线程终结规则:线程中全部的操做都先行发生于线程的终止检测,咱们能够经过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;

  8. 对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;

以上的happens-before原则为volatile关键字的可见性提供了强制保证。

并发编程三大特性:

  1. 可见性

  2. 原子性

  3. 有序性

并发三特性总结

特性 volatile synchronized Lock Atomic
原子性 没法保障 能够保障 能够保障 能够保障
可见性 能够保障 能够保障 能够保障 能够保障
有序性 能够保障 能够保障 能够保障 没法保障

为了文章的可读性,开篇面试题目的相关回答放到了这篇文章来解答.

戳👉知道这些,面试时volatile就稳了


【文章参考】

  1. CPU缓存 - 搜狗百科

  2. 缓存

  3. 面试官最爱的volatile关键字,你答对了吗?

  4. Java指令重排序与volatile关键字

  5. Java Volatile关键字【公众号:并发编程网】

  6. volatile的原理分析

相关文章
相关标签/搜索