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

文章目录

    • 【引言】
    • 1. 多核并发缓存架构
      • CPU缓存
      • JMM内存模型简介
    • 2. JMM内存模型验证
      • volatile验证内存模型
      • JMM内存模型8大原子操作
    • 3. JMM缓存不一致问题
      • 总线加锁
      • MESI缓存一致性协议
    • 4. volatile可见性底层实现原理
      • 保证可见性原理验证
    • 5. volatile不保证原子性
      • 不保证原子性验证
      • 【问题解决】
    • 6. volatile保证有序性
      • volatile禁止指令重排序
      • 内存屏障简介
      • JMM的Happens-Before原则

【引言】

这一切的一切,还得从一个叫volatile的关键字说起…
用volatile的视角,来打开JMM内存模型_第1张图片

【灵魂拷问开始】

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

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

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

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

  5. 面试官:volatile能保证可见性和有序性,但是能保证原子性吗?为什么?

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

这些题目,相信你认真看完此文,会有自己的理解和认识。文末见问题回答

到这里,我的眼里已是常含泪水了。不是因为我对代码爱的深沉,而是因为我菜的真诚!
用volatile的视角,来打开JMM内存模型_第2张图片

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

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


1. 多核并发缓存架构

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

CPU缓存

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

用volatile的视角,来打开JMM内存模型_第3张图片

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缓存使用情况:

用volatile的视角,来打开JMM内存模型_第4张图片

用volatile的视角,来打开JMM内存模型_第5张图片

所以说,在我们的程序执行时,在CPU和Cache之间,是通过CPU缓存来做交互的。CPU从CPU缓冲读取数据,CPU缓存从内存中读取数据;CPU将计算完的数据写回到CPU缓存中,然后CPU缓存再同步回内存中,内存再写回到磁盘中。

JMM内存模型简介

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

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

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

每一个线程在操作共享变量的时候,都将共享变量拷贝一份到自己的工作区间中(因为如果多个线程同时在CPU中操作数据,就像CPU与内存直接交互一样,速度非常慢),等到当前线程的CPU运算完之后,在写回主内存。

用volatile的视角,来打开JMM内存模型_第6张图片

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

用volatile的视角,来打开JMM内存模型_第7张图片

就好比两个人同时拿着同一张银行卡到银行取钱,卡里有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。由于是静态的成员共享变量,修改之后等待的线程能够感知到,此时跳出死循环,打印信息,程序运行结束。

但是,真的是这样吗?

用volatile的视角,来打开JMM内存模型_第8张图片

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

咦,这是怎么肥四呢?
用volatile的视角,来打开JMM内存模型_第9张图片

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

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

这时,那个男人,那个叫volatile的蓝人,它基情满满的向我们走来了!
用volatile的视角,来打开JMM内存模型_第10张图片

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

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

private volatile static boolean initFlag = false;

volatile修饰initFlag变量,只要有线程做了修改,其他线程立即可以感知。

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

用volatile的视角,来打开JMM内存模型_第11张图片

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

JMM内存模型8大原子操作

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

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

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

用volatile的视角,来打开JMM内存模型_第12张图片

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

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

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

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

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

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

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

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

用volatile的视角,来打开JMM内存模型_第13张图片

还是上面程序的代码,针对上述程序出现的问题,我们来做个深入的分析了解:有图有真相

用volatile的视角,来打开JMM内存模型_第14张图片

我们先来分析【线程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么!他俩呀,就干这事的!

总线加锁

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

用volatile的视角,来打开JMM内存模型_第15张图片

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

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

用volatile的视角,来打开JMM内存模型_第16张图片

MESI缓存一致性协议

MESI协议

多个CPU从主内存读取同一个数据到各自的高速缓存,当其中某个CPU修改了缓存里的数据,该数据会马上同步回主内存,其它CPU通过总线侦听机制可以感知到数据的变化,从而将自己缓存里的数据失效。

总线侦听:

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

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

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

【简单总结一下】:

用volatile的视角,来打开JMM内存模型_第17张图片

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


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立即写回内存,且让其他线程中的副本失效。

用volatile的视角,来打开JMM内存模型_第18张图片

相比于此前在总线上加的重量级锁,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的视角,来打开JMM内存模型_第19张图片

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

用volatile的视角,来打开JMM内存模型_第20张图片
此时此刻,我已对并发编程的代码彻底干懵,含着泪,继续往下学习!

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

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

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

用volatile的视角,来打开JMM内存模型_第21张图片

  • 假设线程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数和预期值一致。

用volatile的视角,来打开JMM内存模型_第22张图片

2. CAS解决volatile原子性问题

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

用volatile的视角,来打开JMM内存模型_第23张图片

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);
        }
    }
}

我们可以看到,程序一共跑出了四种情况:

用volatile的视角,来打开JMM内存模型_第24张图片

这三种情况,我们很容易想到

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

但是出现了x=0y=0就不正常了,原因就是编译器对程序作了指令重排序

当两个线程以

  • x = a;

  • a = 1;

  • y = b;

  • b = 1;

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

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

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

那到底是怎么禁止的呢?

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

  • 附上一篇参考文章,文章中做了x = 0,y = 0这种情况的讲解
  • 《指令重排序与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)(https://iqqcode-blog.oss-cn-beijing.aliyuncs.com/img/20200613084031.png)]

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的原理分析

你可能感兴趣的:(#,JavaSE)