Java内存模型&&MESI协议

参考链接
也许,这是东半球最叼的Java内存模型
CPU缓存一致性协议MESI

目录

  • 多线程并发编程的三个特性实现
  • 缓存的出现
  • 缓存不一致
  • MESI协议
    • MESI优化和他们引入的问题
    • 硬件内存模型
  • volatile
  • Java内存模型

多线程并发编程的三个特性实现

多线程并发编程中主要围绕着三个特性实现。 可见性

  • 可见性
    是指当多个线程访问同一个共享变量时,一个线程修改了这个变量的值,其他线程能够立即看到修改后的值。

  • 原子性
    原子性指的一个操作或一组操作要么全部执行,要么全部不执行

  • 有序性
    有序性是指程序执行的顺序按照代码的先后顺序执行。

缓存的出现

  • 安琪拉:CPU 运算器的运算速度远比内存读写速度快,所以CPU 大部分时间都在等数据从内存读取,运算完数据写回内存。

  • 面试官: 那怎么解决?

  • 安琪拉:因为CPU 运行速度实在太快,主存(就是内存)的数据读取速度和CPU 运算速度差了有几个数量级,因此现代计算机系统通过在CPU
    和主存之前加了一层读写速度尽可能接近CPU 运行速度的高速缓存来做数据缓冲
    ,这样缓存提前从主存获取数据,CPU 不再从主存取数据,而是从缓存取数据。这样就缓解由于主存速度太慢导致的CPU 饥饿的问题。同时CPU内还有寄存器,一些计算的中间结果临时放在寄存器内。

  • 面试官: 既然你提到缓存,那我问你一个问题,CPU 从缓存读取数据和从内存读取数据除了读取速度的差异?有什么本质的区别吗?不都是读数据写数据,而且加缓存会让整个体系结构变得更加复杂。

  • 安琪拉:缓存和主存不仅仅是读取写入数据速度上的差异,还有另外更大的区别:研究人员发现了程序80%的时间在运行20% 的代码,所以缓存本质上只要把20%的常用数据和指令放进来就可以了(是不是和Redis 存放热点数据很像),另外CPU访问主存数据时存在二个局部性现象:

  • 时间局部性现象
    如果一个主存数据正在被访问,那么在近期它被再次访问的概率非常大。想想你程序大部分时间是不是在运行主流程20%的代码。
  • 空间局部性现象
    CPU使用到某块内存区域数据,这块内存区域后面临近的数据很大概率立即会被使用到。这个很好解释,我们程序经常用的数组、集合(本质也是数组)经常会顺序访问(内存地址连续或邻近)。

因为这二个局部性现象的存在使得缓存的存在可以很大程度上缓解CPU 饥饿的问题。

Java内存模型&&MESI协议_第1张图片

多核心CPU架构

目前流行的多级缓存结构
由于CPU的运算速度超越了1级缓存的数据I\O能力,CPU厂商又引入了多级的缓存结构。
Java内存模型&&MESI协议_第2张图片

缓存不一致

  • 面试官: 那你能跟我简单讲讲程序运行时,数据是怎么在主存、缓存、CPU寄存器之间流转的吗?

  • 安琪拉:可以。比如以 i = i + 2; 为例, 当线程执行到这条语句时,会先从主存中读取i 的值,然后复制一份到缓存中,CPU读取缓存数据(取数指令),进行 i + 2操作(中间数据放寄存器),然后把结果写入缓存,最后将缓存中i最新的值刷新到主存当中(写回主存时间不确定)。

  • 面试官: 这个数据操作逻辑在单线程环境和多线程环境下有什么区别?

  • 安琪拉:比如i 如果是共享变量(例如类的成员变量),单线程运行没有任何问题,但是多线程中运行就有可能出问题。
    例如:有A、B二个线程,在二个不同的CPU 上运行,因为每个线程运行的CPU 都有自己的缓存,i是共享变量,初始值是0,A 线程从内存读取i
    的值存入缓存,B 线程此时也读取i 的值存入自己CPU的缓存,A 线程对i 进行+1操作,i变成了1,B线程缓存中的变量 i还是0,B线程也对i 进行+1操作,最后A、B线程先后将缓存数据写回内存共享区,预期的结果应该是2,因为发生了二次+1操作,但是实际是1。
    Java内存模型&&MESI协议_第3张图片
    这个就是非常著名的缓存一致性问题,注意这里还只是多CPU的缓存一致性问题,和我们常说的多线程共享变量安全问题还不相同。
    说明:单核CPU 的多线程也会出现上面的线程不安全的问题,只是产生原因不是多核CPU缓存不一致的问题导致,而是CPU调度线程切换,多线程局部变量不同步引起的。

MESI协议

研究人员就搞出了一套协议:缓存一致性协议。协议的类型很多(MSI、MESI、MOSI、Synapse、Firefly),最常见的就是Intel (英特尔)的MESI 协议。缓存一致性协议主要规范了CPU 读写主存、管理缓存数据的一系列规范,如下图所示。
Java内存模型&&MESI协议_第4张图片

  • 安琪拉: 缓存一致性协议(MESI协议)的核心思想: 定义了缓存中的数据状态只有四种,MESI 是四种状态的首字母。
  • 当CPU写数据时,如果写的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态;
  • 当CPU读取共享变量时,发现自己缓存的该变量的缓存行是无效的,那么它就会从内存中重新读取

缓存中数据都是以缓存行(Cache Line)为单位存储;MESI 是指4中状态的首字母。每个Cache line有4个状态,可用2个bit表示,它们分别是:

状态 描述 监听任务
M 修改 (Modified) 该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。 缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive) 该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。 缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared) 该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。 缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid) 该Cache line无效。

注意:
对于M和E状态而言总是精确的,他们在和该缓存行的真正状态是一致的,而S状态可能是非一致的。如果一个缓存将处于S状态的缓存行作废了,而另一个缓存实际上可能已经独享了该缓存行,但是该缓存却不会将该缓存行升迁为E状态,这是因为其它缓存不会广播他们作废掉该缓存行的通知同样由于缓存并没有保存该缓存行的copy的数量,因此(即使有这种通知)也没有办法确定自己是否已经独享了该缓存行。

从上面的意义看来E状态是一种投机性的优化:如果一个CPU想修改一个处于S状态的缓存行,总线事务需要将所有该缓存行的copy变成invalid状态,而修改E状态的缓存不需要使用总线事务

MESI优化和他们引入的问题

缓存的一致性消息传递是要时间的,这就使其切换时会产生延迟。当一个缓存被切换状态时其他缓存收到消息完成各自的切换并且发出回应消息这么一长串的时间中CPU都会等待所有缓存响应完成。可能出现的阻塞都会导致各种各样的性能问题和稳定性问题。

CPU切换状态阻塞解决-存储缓存(Store Bufferes) 。比如你需要修改本地缓存中的一条信息,那么你必须将 I(无效)状态通知到其他拥有该缓存数据的CPU缓存中,并且等待确认。等待确认的过程会阻塞处理器,这会降低处理器的性能。应为这个等待远远比一个指令的执行时间长的多。

Store Bufferes 为了避免这种CPU运算能力的浪费,Store Bufferes 被引入使用。处理器把它想要写入到主存的值写到缓存,然后继续去处理其他事情。当所有失效确认(Invalidate Acknowledge)都接收到时,数据才会最终被提交
但这么做有两个风险。

  1. Store Bufferes的风险 第一:就是处理器会尝试从存储缓存(Store buffer)中读取值,但它还没有进行提交。这个的解决方案称为 Store Forwarding,它使得加载的时候,如果存储缓存中存在,则进行返回
  2. 第二:保存什么时候会完成,这个并没有任何保证。

硬件内存模型

执行失效也不是一个简单的操作,它需要处理器去处理。另外,存储缓存(Store Buffers)并不是无穷大的,所以处理器有时需要等待失效确认的返回。这两个操作都会使得性能大幅降低。为了应付这种情况,引入了失效队列(invalid queue)。它们的约定如下:

  • 对于所有的收到的Invalidate请求,Invalidate Acknowlege消息必须立刻发送
  • Invalidate并不真正执行,而是被放在一个特殊的队列中,在方便的时候才会去执行。
  • 处理器不会发送任何消息给所处理的缓存条目,直到它处理Invalidate。

即便是这样处理器已然不知道什么时候优化是允许的,而什么时候并不允许。

干脆处理器将这个任务丢给了写代码的人。这就是内存屏障(Memory Barriers)。

写屏障 Store Memory Barrier(a.k.a. ST, SMB, smp_wmb)是一条告诉处理器在执行这之后的指令之前,应用所有已经在存储缓存(store buffer)中的保存的指令。
读屏障Load Memory Barrier (a.k.a. LD, RMB, smp_rmb)是一条告诉处理器在执行任何的加载前,先应用所有已经在失效队列中的失效操作的指令。
————————————————
版权声明:本文为CSDN博主「一角钱技术」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/org_hjh/article/details/109626607

volatile

  • 面试官: MESI 协议解决了什么问题?

  • 安琪拉: 解决了多核CPU 缓存不一致的问题。

  • 面试官: 那我有个疑问了,既然有MESI 的存在,解决多核CPU的缓存一致性,为什么还需要Java用volatile 这种关键字? 因为我们知道volatile 也是保证共享变量的可见性。

  • 安琪拉: volatile是Java语言层面来定义的Java语言实现volatile 的内存可见性需要借助MESI,但是有的CPU只有单核、或者不支持MESI、那怎么实现内存可见呢?可以是通过锁总线的方式,volatile屏蔽了硬件的差异,说直接点:使用volatile 修饰的变量是有内存可见性的,这是Java 语法定的,Java 不关心你底层操作系统、硬件CPU是如何实现内存可见的,我的语法规定就是volatile 修饰的变量必须是具有可见性的。

虚拟机实现volatile的方式是写入了一条lock 前缀的汇编指令,lock前缀的汇编指令会强制变量写入主存,也可避免前后指令的CPU重排序,并及时让其他核中的相应缓存行失效,volatile是利用MESI达到符合预期的效果

Java内存模型

  • 面试官: 你故事讲完了吗?可以说说为什么需要Java内存模型了吧?

  • 安琪拉: CPU 有X86(复杂指令集)、ARM(精简指令集)等体系架构,版本类型也有很多种,CPU 可能通过锁总线、MESI协议实现多核心缓存的一致性。因为有硬件的差异以及编译器和处理器的指令重排优化的存在,所以Java 需要一种协议来规避硬件平台的差异,保障同一段代码在所有平台运行效果一致,这个协议叫做Java 内存模型(Java Memory Model)。

  • 面试官: 详细说说。

  • 安琪拉:Java内存模型( Java Memory Model),简称JMM, 是 Java 中非常重要的一个概念,是Java并发编程的核心。JMM 是Java 定义的一套协议,用来屏蔽各种硬件和操作系统的内存访问差异,让Java程序在各种平台都能有一致的运行效果。

面试官:你说Java 定义的一套协议,那既然是协议,肯定是约定了一些内容,这套协议规定了什么内容?

安琪拉:是的,协议这个词很熟悉,HTTP 协议、TCP 协议等。Java内存模型(JMM) 协议定了一套规范:

所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保存了该线程使用到的变量(主内存的拷贝),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量线程间变量值的传递均需要在主内存来完成,如下图所示,线程的所有操作都是把主内存的数据放在自己的工作内存进行。
Java内存模型&&MESI协议_第5张图片

  • 面试官:你刚才说了一大堆概念,能详细讲讲吗?比如你刚才讲的所有变量都在主内存中,每个线程有自己的工作内存,能好好讲讲什么是主内存和工作内存吗?
  • 安琪拉:很多人在这里会有一个误区,认为主内存、工作内存是物理的内存条中的内存,实际上工作内存、主内存都是Java内存模型中的概念模型
    JMM 是内存模型,是抽象的协议。首先真实的内存架构是没有区分堆和栈的,这个Java 的JVM 来做的划分,另外线程私有的本地内存线程栈可能包括CPU 寄存器、缓存和主存。堆亦是如此
  • 面试官: 能具体讲讲JMM 内存模型规范吗?
  • 安琪拉: 可以。前面已经讲了线程本地内存和物理真实内存之间的关系,说的详细些: 初始变量首先存储在主内存中; 线程操作变量需要从主内存拷贝到线程本地内存中; 线程的本地工作内存是一个抽象概念,包括了缓存、寄存器、store buffer(CPU内的缓存区域)等。
    Java内存模型&&MESI协议_第6张图片
    一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作(单一操作都是原子的)来完成:

lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量解除锁定,解除锁定后的变量才可以被其他线程锁定。
read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
store(有的指令是save/存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

如果要把一个变量从主内存中复制到工作内存,需要顺序执行read 和load 操作, 如果把变量从工作内存中同步回主内存中,就要按顺序地执行store 和write 操作。但Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行,也就是操作不是原子的,一组操作可以中断。
不允许read和load、store和write操作之一单独出现,必须成对出现。
不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。
不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。
一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了assign和load操作。
一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行load或assign操作初始化变量的值
如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。
对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)。

你可能感兴趣的:(java,并发编程)