lsp都喜欢看的内存模型

 2023-01-14
原文作者:执墨 原文地址:https://juejin.cn/post/6889736962987524103

兄弟们好,给大家带来一篇内存模型的水文(手动滑稽)。Begin

先来看大纲

202301011702003191.png

1.JMM规范

先来说JMM是什么?

JMM(Java Memory Model):全称Java内存模型。它定义了**Java虚拟机在计算机内存中的工作方式**。它是 一套规范,并不真实存在 。它包括三个点: 原子性,可见性,有序性

首先我们来看一下它的工作原理。 线程操作数据的时候需要从主内存中读取,线程操作完数据以后进行写回主内存。

202301011702008212.png

可能有的兄弟要说了,为什么要搞这么麻烦呢?我直接操作主内存中的数据不就得了,干嘛非要复制一份再用。

我们通过一个场景来说明这个问题。

假设现在不存在JMM规范,我们所有的操作都是直接在主内存中完成。

**单线程:**我们定义了一个资源,然后在这个线程中使用这个资源,对于这个资源的修改等操作直接在主内存中完成。没有任何问题。

**多线程:**还是一样,我们定义了一个资源,然后再多个线程中使用了这个资源,不同线程中对资源的修改全部直接操作主内存,这个时候不同线程之间的操作可能被相互覆盖。

也就是在并发操作下会出现资源覆盖的情况,于是引入了Java内存模型的概念

聊完了为什么要用这个东东,我们再来聊一下它的三个特点:

1.1 原子性

原子这个名词最开始接触应该是我们在高中时期的化学吧。当时最直接的解释就是元素最小的构成单位。当然最后又出现了夸克这样的东东。

在其他领域中逐渐把原子作为了一个最小的单位,例如计算机,一个原子操作代表了这个操作不可以被打断即最小不可被分割。

原子性的含义就是这个: 它表示这个操作不可以被中断

再聊原子性,在计算机领域中接触到的应该就是数据库的事务了吧。四大特性: 原子性,一致性,隔离性,持久性 。其中的原子性含义和上述相同,代表了这个事务中的操作不可以被中断,要么成功要么失败。

1.2 可见性

不知道兄弟们看不看小说,好多玄幻小说的主角,拿到一本远古秘籍就自己修炼,不给其他人看(想不通,为什么不给其他人看),此时这本秘籍相对于其他人来说就是不可见的。而在现代图书馆,所有的书你都能看到,这个时候这些书籍就是可见的。

在计算机领域中,可见性的表现和上述的故事类似,只不过表现形式为线程之间的数据是否可见。比如:线程A和线程B同时从主内存中读取了一个资源,然后分别做了修改,不过线程A的操作更快一些,在线程B写回之前就将该资源写回了主内存( JMM规范 ),但是这个时候线程B不知道做了修改,此时它进行了写回,这个时候我们称这个资源对于不同线程是不可见的。 一句话说明白:不同线程之间不能共享资源状态的称为该资源不具有可见性

那如果这个资源存在可见性,那么当线程A将资源重新写回主内存的时候,就会触发一个机制,使其他线程重新从工作内存中读取最新的资源,然后进行操作,这个时候就代表该资源具备可见性。 一句话说明白:不同线程之间可以共享资源状态称为该资源具有可见性

画个图玩一下。

202301011702013093.png

1.3 有序性

这个很好理解,我们直接上解释。开发者编写代码的时候都是按照一定的顺序进行编写,而在具体执行的时候不一定会按照我们自己写的顺序执行,JVM会进行一定的优化,对代码进行重排,提高代码的执行速率。

重排序类型:

  • 编译器优化重排序。在 不改变单线程执行语义 的情况下,编译器重新梳理代码的执行顺序
  • 指令级并行重排序。现代处理器采用了指令级并行技术,将多条指令重叠执行,如果 不存在数据依赖性 ,处理器可以适当改变语句对应机器指令的执行顺序。
  • 内存系统重排序,由于处理器使用缓存和读/写缓存区,这使得加载和存储操作看上去是乱序执行。

搞明白这些执行重排序的东东以后,我们再看一下重排序出现的时机。

单线程执行语义: as if serial 表示在单线程状态执行的情况下,重排序以后的代码和没有进行重排序的代码执行结果相同,即重排序不会影响代码的正确性。

**数据依赖性:**表示两条指令之间不存在数据依赖,主要表现形式为以下的几种情况

202301011702020034.png

只要保证了单线程执行语义和不出现上图所述的几种数据依赖关系就会出现指令重排序。

2. JMM内存交互

在通过JMM规范进行内存交互,依赖于下面八大内存交互操作

  • lock作用于主内存变量 ,把一个变量标识为线程独占。其他线程不能进行操作
  • unlock作用于主内存变量 ,把一个变量从线程独占状态,变为公有状态
  • read作用于主内存变量 ,将主内存中的变量传输到工作内存中
  • load作用于工作内存变量 ,将read传输到工作内存中的变量加载到工作内存中
  • store作用于主内存变量 ,将工作内存中的变量加载到主内存中
  • wirte作用于工作内存变量 ,将store操作中的变量写入主内存中
  • use作用于工作内存变量 ,当使用这个变量的时候,会通过这个指令来完成
  • assign作用于工作内存变量 ,当为这个变量进行赋值的时候,会通过这个指令完成

JMM规范定义了以下的指令出现操作

  • readload操作必须同时出现;storewrite必须同时出现
  • 不允许线程丢弃其assign操作,如果线程对资源进行修改则必须通知主内存
  • 不允许线程将一个没有进行assign的资源直接同步到主内存中
  • 不允许线程直接创建一个资源,所有的资源必须通过主内存常见,读取,才能操作。也就是说在进行assignstore之前必须先进行loaduse。( 待会详细解释
  • 一个变量在同一个时刻只能被一个线程执行一次lock操作;但可以被同一个线程执行多次(可重入锁),但是lock的次数和unlock的次数应该保证相同
  • 对一个变量执行了lock操作,在unlock的时候所有的线程必须重新读取主内存中该变量的值。(synchronized的可见性实现原理)
  • 如果一个变量没有被lock则不能对其进行unlock操作
  • 对一个变量执行unlock操作的时候,必须将该变量同步到主内存中。

详细和兄弟们聊聊第四条,不知道兄弟们还记不记得一个对象被存放的位置

202301011702024955.png

看完上图,是不是对上面JMM定义的内存交互规则里的第四条,有了些许疑问。我们再来看一遍第四条

不允许线程直接创建一个资源,所有的资源必须通过主内存常见,读取,才能操作。也就是说在进行assignstore之前必须先进行loaduse

按照上面说的含义,所有的资源都是在主内存中创建,工作内存只能通过readload,读取加载以后才能use,而我们上面的一个对象的创建过程似乎违背了这个原则。(可以认为,工作内存对应了栈,主内存对应了堆)

这个操作其实是由于JVM对常见对象的过程做的一个优化,节省由于共享内存而造成的一系列开销。

有兄弟要说了,你给我讲这些,我在实际中怎么进行分析呢???

没错,这玩意在实际中的确蛮难分析的,所以呢我们的大JAVA提供了一个叫Happen Before的原则用来分析操作的安全性

2.1 Happen-Before

全称:先行发生原则。

**大白话:**操作A先于操作B发生,则在执行操作B的时候操作A的所有修改,都可以被操作A观察到。

3. Synchronized实现JMM规范

3.1 原子性

兄弟们应该还记得上面我们在介绍JMM规范的时候对于原子性的相关解释。

synchronized代码块可以保证在该代码块中仅仅存在一个线程正在执行,所以保证了这个代码块中的原子性,不能被其他线程所中断

3.2 可见性

兄弟们还记不记得我们在上面聊内存交互的时候提到了八个指令其中存在一个lockunlock,而且在JMM对这八个指令定义规则时候其中有一条: unlock以后其他工作内存需要重新从主内存读取这个变量的最新值

synchronized的底层实现就是lockunlock原子指令(可以看JVM源码)。

synchronized代码块执行完成以后,会触发工作内存变量的刷新机制,保证变量的可见性。

画张图看看

202301011702030966.png

3.3 有序性

我们来看一段代码

    	private int num = 0 ;
        private boolean flag = false ;
    
        public int test01(){
            synchronized (this){
                if(!flag){
                    num = 2 ;
                }
            }
            return num ;
        }
    
        public void test02(){
            synchronized (this){
                num = 4 ;
                flag = true ;
            }
        }

可以发现synchronized没有从根源禁止指令重排序,实际上指令重排序还是发生了,只不过由于加锁了,导致其他线程无法进入加锁的代码块,所以即使发生了指令重排序也不会对程序造成任何影响。

4. Volatile实现JMM规范

兄弟们应该都听过这样的面试题, 聊聊你对Volatile的理解

而我们常用的回答, Volatile保证可见性,有序性,但是不保证原子性

下面我们来聊聊它是如何保证可见性和有序性。

Volatile实现可见性和有序性都是基于 内存屏障 实现的。下面我们仔细聊一下内存屏障是什么

4.1 内存屏障

我们来看一组代码

    x = x + y ;
    z = 3 ;

这两行代码的执行顺序不是我们开发者可以控制的,计算机内部会进行编译优化或者运行优化,也就是说,第二行的代码可能优于第一行代码执行。而我们想要保证代码的执行顺序,往往需要采取一系列措施,如硬件措施或者软件措施等。

而内存屏障就是在硬件之上,操作系统和JVM之下的对并发做的最后的一层封装。

我们先来聊一下CPU层面的并发处理方案。

兄弟们应该还记得我们之前将Synchronized的时候涉及到的CPU的架构

202301011702036777.png

上图就是CPU的架构图,一个CPU两个核心,单独的一级和二级缓存,三级缓存公用。而在并发操作的时候就会出现数据冲突的问题,也就是缓存一致性的问题。而解决这种问题,CPU厂商提供了一个解决方法:MESI协议。

MESI代表了四种状态,下面是对这四种状态的解释

  • M(修改,Modified):本地处理器已经修改缓存行,即是脏行,它的内容与内存中的内容不一样,并且此cache只有本地一个拷贝
  • E(专有,Exclusive):缓存行内容和内存中的一样,而且其他处理器都没有这行数据。
  • S(共享,Shared):缓存行规内容和内存中的一样,有可能其他处理器也存在该缓存行的拷贝
  • I(无效,Invalid):缓存行失效,不能使用。

我们简单的聊一下这个协议在实际中的应用

  • Core1修改值以后,会发送一个信号给其他正在对该值进行操作的核,改变其他核中值得状态
  • Core1修改多少次值,就会发送多少次信号给其他正在进行操作的核。( 发信号的时候会锁总线
  • 其他核在使用该值的时候发现该值已经失效了,会从内存中重新读取该值

MESI协议保证了在CPU中缓存的可见性。但在内存中却无法保证其可见性等。所以这个时候就需要 内存屏障 来解决这个事情了。

其实我们可以自己思考一下,内存屏障如何做: 无非就是在需要保证JMM规范的语句中加入一个块,让CPU或者编译器不对该块内容进行重排序,所以呢,组成这个块的就是两个指令

内存屏障的两大指令

  • load将内存中的数据拷贝到处理器中
  • store将处理器中缓存的数据刷回内存中

我们看一个工作图

202301011702042718.png

这两个指令组合起来就形成了四种屏障类型

屏障类型 指令说明 说明
LoadLoadBarriers Load1;LoadLoad;Load2 确保Load1数据的装载优先于Load2
StoreStoreBarriers Store1;StoreStorestore2 确保Store1刷新数据到内存( 此时数据对其他处理器可见 )的操作先于store2的刷新数据到内存中
LoadStoreBarriers Load1;LoadStore;Store2 确保Load1的数据状态先于Store2的数据刷回内存种
StoreLoadBarries Store1;StoreLoad;Load2 确保store1的数据刷回内存的操作先于Load2的数据装载

其中的StoreLoad Barriers屏障同时具备其他三个屏障的效果,因此被成为全能屏障,但是其开销比较昂贵

4.2 可见性和有序性

Volatile是如何保证可见性和有序性的?

我们以X86系统架构来说明,

对于X86系统而言,它仅仅实现了三种内存屏障

Store Barrier

sfence指令实现了Store barrier,相当于我们上面提到的StoreStore Barriers。它强制把sfence指令之前的store指令全部在其之前执行。即 禁止sfence前后的store指令跨越sfence执行,并且所有在sfence之前的内存更新都是可见的

Load Barrier

lfence指令实现Load Barrier,相当于我们前面提到的loadLoad Barriers它强制所有在lfence指令之后的load全部在lfence之后执行。配合StoreBarrier使用,使得sfence之前的内存更新对与lfencec之后的Load操作都是可见的

Full Barrier

mfence指令实现了Full Barrier相当于StoreLoad Barriers

它强制所有的mfencec指令之前的store/load指令都在该指令执行之前执行,保证了mfence前后的可见性和有序性

JVMVolatile变量的处理。

  • 在写volatile变量之后插入一个sfence,保证sfence之前的写操作不会被重排序到sfence之后,同时保证其变量的可见性。
  • 在读volatile变量之前,插入一个lfence,这样保证了lfence之后的读操作不会跑到lfence之前。

5. Final实现JMM规范

一个字段被声明为finalJVM会在初始化final变量后插入一个sfence,而类的final字段在clinit方法中初始化,由类加载过程保证其可见性,而你内存屏障保证了重排序,所以其实现了可见性和从有序性。