写点什么

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

  • 2021 年 11 月 11 日
  • 本文字数:4280 字

    阅读完需:约 14 分钟

| 阻塞队列之 ArrayBlockingQueue,LinkedBlockingQueue,LinkedBlockingDeque | 登机入口 |


| 阻塞队列之 PriorityBlockingQueue,DelayQueue | 登机入口 |


| 阻塞队列之 SynchronousQueue,LinkedTransferQueue | 登机入口 |


| Java 中 12 个原子(Atomic)操作类实现原理分析 | 登机入口 |


| 线程池(Thread Pool)原理分析 | 登机入口 |


| Future/Callable/FutureTask 原理分析 | 登机入口 |


| Fork/Join 原理分析 | 登机入口 |


初识 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 写入 sto


【一线大厂Java面试题解析+后端开发学习笔记+最新架构讲解视频+实战项目源码讲义】
浏览器打开:qq.cn.hn/FTf 免费领取
复制代码


re 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 中更新过的共享变量刷新到主内存中去。

评论

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