原创

【并发编程系列3】volatile内存屏障及实现原理分析(JMM和MESI)

初识volatile

Java语言规范第3版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。
这个概念听起来有些抽象,我们先看下面一个示例:

package com.zwx.concurrent;

public class VolatileDemo {
    public static boolean finishFlag = false;

    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            int i = 0;
            while (!finishFlag){
                i++;
            }
        },"t1").start();
        Thread.sleep(1000);//确保t1先进入while循环后主线程才修改finishFlag
        finishFlag = true;
    }
}

这里运行之后他t1线程中的while循环是停不下来的,因为我们是在主线程修改了finishFlag的值,而此值对t1线程不可见,如果我们把变量finishFlag加上volatile修饰:

public static volatile boolean finishFlag = false;

这时候再去运行就会发现while循环很快就可以停下来了。
从这个例子中我们可以知道volatile可以解决线程间变量可见性问题。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。

volatile如何保证可见性

利用工具hsdis,打印出汇编指令,可以发现,加了volatile修饰之后打印出来的汇编指令多了下面一行:
在这里插入图片描述
lock是一种控制指令,在多处理器环境下,lock 汇编指令可以基于总线锁或者缓存锁的机制来达到可见性的一个效果。

可见性的本质

硬件层面

线程是CPU调度的最小单元,线程设计的目的最终仍然是更充分的利用计算机处理的效能,但是绝大部分的运算任务不能只依靠处理器“计算”就能完成,处理器还需要与内存交互,比如读取运算数据、存储运算结果,这个 I/O 操作是很难消除的。而由于计算机的存储设备与处理器的运算速度差距非常大,所以现代计算机系统都会增加一层读写速度尽可能接近处理器运算速度的高速缓存来作为内存和处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步到内存之中。
查看我们个人电脑的配置可以看到,CPU有L1,L2,L3三级缓存,大致粗略的结构如下图所示:
在这里插入图片描述
从上图可以知道,L1和L2缓存为各个CPU独有,而有了高速缓存的存在以后,每个 CPU 的处理过程是,先将计算需要用到的数据缓存在 CPU 高速缓存中,在 CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中。在整个运算过程完成后,再把缓存中的数据同步到主内存。
由于在多 CPU 种,每个线程可能会运行在不同的 CPU 内,并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个 CPU 中,如果在不同 CPU 中运行的不同线程看到同一份内存的缓存值不一样就会存在缓存不一致的问题,那么怎么解决缓存一致性问题呢?CPU层面提供了两种解决方法:总线锁缓存锁

总线锁

总线锁,简单来说就是,在多CPU下,当其中一个处理器要对共享内存进行操作的时候,在总线上发出一个 LOCK#信号,这个信号使得其他处理器无法通过总线来访问到共享内存中的数据,总线锁定把 CPU 和内存之间的通信锁住了(CPU和内存之间通过总线进行通讯),这使得锁定期间,其他处理器不能操作其他内存地址的数据。然而这种做法的代价显然太大,那么如何优化呢?优化的办法就是降低锁的粒度,所以CPU就引入了缓存锁。

缓存锁

缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效,IA-32处理器和Intel 64处理器使用MESI实现缓存一致性协议(注意,缓存一致性协议不仅仅是通过MESI实现的,不同处理器实现了不同的缓存一致性协议)

MESI(缓存一致性协议)

MESI是一种比较常用的缓存一致性协议,MESI表示缓存行的四种状态,分别是:
1、M(Modify) 表示共享数据只缓存在当前 CPU 缓存中,并且是被修改状态,也就是缓存的数据和主内存中的数据不一致
2、E(Exclusive) 表示缓存的独占状态,数据只缓存在当前CPU缓存中,并且没有被修改
3、S(Shared) 表示数据可能被多个 CPU 缓存,并且各个缓存中的数据和主内存数据一致
4、I(Invalid) 表示缓存已经失效
在 MESI 协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听(snoop)其它CPU的读写操作。
对于 MESI 协议,从 CPU 读写角度来说会遵循以下原则:
CPU读请求:缓存处于 M、E、S 状态都可以被读取,I 状态CPU 只能从主存中读取数据
CPU写请求:缓存处于 M、E 状态才可以被写。对于S状态的写,需要将其他CPU中缓存行置为无效才行。

CPU工作流程

使用总线锁和缓存锁机制之后,CPU 对于内存的操作大概可以抽象成下面这样的结构。从而达到缓存一致性效果:
在这里插入图片描述

MESI协议带来的问题

MESI协议虽然可以实现缓存的一致性,但是也会存在一些问题:就是各个CPU缓存行的状态是通过消息传递来进行的。如果CPU0要对一个在缓存中共享的变量进行写入,首先需要发送一个失效的消息给到其他缓存了该数据的 CPU。并且要等到他们的确认回执。CPU0在这段时间内都会处于阻塞状态。为了避免阻塞带来的资源浪费。CPU中又引入了store bufferes:
在这里插入图片描述
如上图,CPU0 只需要在写入共享数据时,直接把数据写入到 store bufferes中,同时发送invalidate消息,然后继续去处理其他指令(异步) 当收到其他所有 CPU 发送了invalidate acknowledge消息时,再将store bufferes中的数据数据存储至缓存行中,最后再从缓存行同步到主内存。但是这种优化就会带来了可见性问题,也可以认为是CPU的乱序执行引起的或者说是指令重排序(指令重排序不仅仅在CPU层面存在,编译器层面也存在指令重排序)。
我们通过下面一个简单的示例来看一下指令重排序带来的问题。

package com.zwx.concurrent;

public class ReSortDemo {
    int value;
    boolean isFinish;

    void cpu0(){
        value = 10;//S->I状态,将value写入store bufferes,通知其他CPU当前value的缓存失效
        isFinish=true;//E状态
    }
    void cpu1(){
        if (isFinish){//true
            System.out.println(value == 10);//可能为false
        }
    }
}

这时候理论上当isFinish为true时,value也要等于10,然而由于当value修改为10之后,发送消息通知其他CPU还没有收到响应时,当前CPU0继续执行了isFinish=true,所以就可能存在isFinsh为true时,而value并不等于10的问题。
我们想一想,其实从硬件层面很难去知道软件层面上的这种前后依赖关系,所以没有办法通过某种手段自动去解决,故而CPU层面就提供了内存屏障(Memory Barrier,Intel称之为 Memory Fence),使得软件层面可以决定在适当的地方来插入内存屏障来禁止指令重排序。

CPU层面的内存屏障

CPU内存屏障主要分为以下三类:
写屏障(Store Memory Barrier):告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对写屏障之后的读或者写是可见的。
读屏障(Load Memory Barrier):处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的。
全屏障(Full Memory Barrier):确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作。
这些概念听起来可能有点模糊,我们通过将上面的例子改写一下来说明:

package com.zwx.concurrent;

public class ReSortDemo {
    int value;
    boolean isFinish;

    void cpu0(){
        value = 10;//S->I状态,将value写入store bufferes,通知其他CPU当前value的缓存失效
        storeMemoryBarrier();//伪代码,插入一个写屏障,使得value=10这个值强制写入主内存
        isFinish=true;//E状态
    }
    void cpu1(){
        if (isFinish){//true
            loadMemoryBarrier();//伪代码,插入一个读屏障,强制cpu1从主内存中获取最新数据
            System.out.println(value == 10);//true
        }
    }

    void storeMemoryBarrier(){//写屏障
    }
    void loadMemoryBarrier(){//读屏障
    }
}

通过以上内存屏障,我们就可以防止了指令重排序,得到我们预期的结果。
总的来说,内存屏障的作用可以通过防止 CPU 对内存的乱序访问来保证共享数据在多线程并行执行下的可见性,但是这个屏障怎么来加呢?回到最开始我们讲 volatile关键字的代码,这个关键字会生成一个 lock 的汇编指令,这个就相当于实现了一种内存屏障。接下来我们进入volatile原理分析的正题

JVM层面

在JVM层面,定义了一种抽象的内存模型(JMM)来规范并控制重排序,从而解决可见性问题。

JMM(Java内存模型)

JMM全称是Java Memory Model(Java内存模型),什么是JMM呢?通过前面的分析发现,导致可见性问题的根本原因是缓存以及指令重排序。 而JMM 实际上就是提供了合理的禁用缓存以及禁止重排序的方法。所以JMM最核心的价值在于解决可见性和有序性
JMM属于语言级别的抽象内存模型,可以简单理解为对硬件模型的抽象,它定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性,它解决了CPU 多级缓存、处理器优化、指令重排序导致的内存访问问题,保证了并发场景下的可见性。
需要注意的是,JMM并没有限制执行引擎使用处理器的寄存器或者高速缓存来提升指令执行速度,也没有限制编译器对指令进行重排序,也就是说在JMM中,也会存在缓存一致性问题和指令重排序问题。只是JMM把底层的问题抽象到JVM层面,再基于CPU层面提供的内存屏障指令,以及限制编译器的重排序来解决并发问题。

JMM抽象模型结构

JMM 抽象模型分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成,可以抽象为下图:
在这里插入图片描述

JMM如何解决可见性问题

从JMM的抽象模型结构图来看,如果线程A与线程B之间要通信的话,必须要经历下面2个步骤。
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量。
下面通过示意图来说明这两个步骤:
在这里插入图片描述
结合上图,假设初始时,这3个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存 A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内 存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。 从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

编译器的指令重排序

综合上面从硬件层面和JVM层面的分析,我们知道在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型:
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序,如下图:
在这里插入图片描述
其中2和3属于处理器重排序(前面硬件层面已经分析过了)。而这些重排序都可能会导致可见性问题(编译器和处理器在重排序时会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,编译器会遵守happens-before规则和as-if-serial语义)。
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排 序(不是所有的编译器重排序都要禁止)。对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(Memory Barriers,Intel称之为Memory Fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序。JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。正是因为volatile的这个特性,所以单例模式中可以通过volatile关键字来解决双重检查锁(DCL)写法中所存在的问题

JMM层面的内存屏障

在JMM 中把内存屏障分为四类:
在这里插入图片描述
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多数处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(Buffer Fully Flush)。

happens-before规则

happens-before表示的是前一个操作的结果对于后续操作是可见的,它是一种表达多个线程之间对于内存的可见性。所以我们可以认为在 JMM 中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作必须要存在happens-before关系。这两个操作可以是同一个线程,也可以是不同的线程,如果想详细了解happens-before规则,可以点击这里

总结

并发编程中有三大特性:原子性可见性有序性,volatile通过内存屏障禁止指令重排序,主要遵循以下三个规则:

  1. 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  2. 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  3. 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

最后需要特别提一下原子性,Java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的写操作具有原子性。当JVM在这种处理器上运行时,可能会把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,这两个32位的写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的写操作将不具有原子性。
锁的语义决定了临界区代码的执行具有原子性。但是因为一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入,所以即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读/写就具有原子性。但是多个volatile操作或类似于i++这种复合操作,这些操作整体上不具有原子性。针对于复合操作如i++这种,如果要保证原子性,需要通过synchronized关键字或者加其他锁来处理。
注意:在JSR-133之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。

正文到此结束
本文目录