JMM(java内存模型)和并发三大特性、as-if-serial、happens-before:

并发三大特性:

并发编程的Bug三大源头:可见性、原子性、有序性

可见性:
当一个线程修改了共享变量的值,其它线程是能够看到修改的值的;通过在其它线程读取变量之前,把修改后的变量同步给主内存来实现可见性的。
如何保证可见性:

  • 通过volatile关键字保证可见性;
  • 通过内存屏障保证可见性;
  • 通过synchronized关键字保证可见性;
  • 通过lock保证可见性;
  • 通过final关键字保证可见性(X86底层也是有Lock前缀指令);
    原子性:
    一个或多个操作,要么全部执行,且在执行的过程中不被打断,要么全部不执行。在java中,对基本变量的读取和赋值都是原子性的(64为操作系统),但是自增操作不是原子性的,因为分为三个步骤:读取、运算、赋值。
    如何保证原子性:
  • 通过synchronized关键字保证保证原子性;
  • 通过Lock保证原子性;
  • 通过CAS保证原子性;
    synchronized和Lock也可以保证可见性,因为它们可以保证任一时刻只有一个
    线程能访问共享资源,并在其释放锁之前将修改的变量刷新到内存中

有序性:
程序执行的顺序按照代码的先后顺序执行。Jvm存在指令重排,所以存在有序性。

如何保证有序性:

  • 通过volatile关键字保证有序性;
  • 通过内存屏障保证有序性;
  • 通过synchronized关键字保证有序性;
  • 通过lock保证有序性;
    synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行 同步代码,自然就保证了有序性

JAVA内存模型(JMM):

Java内存模型(java memory model)指定了jvm如何与计算机内存协同工作的,是一种抽象概念,并不真实存在,它是一组规则或规范,通过这组规则定义了程序中各个变量(包括实例字段、静态字段、构成数组对象的元素)在共享数据区域和私有数据区域的访问方式,定义了线程和主内存之间的抽象关系,JMM是围绕原子性、可见性、有序性展开的。

CUP与内存:
所有的运算都是由CUP的运算器(ALU)寄存器来完成的,CPU的指令执行过程中涉及数据的写入和读取的过程,所有的数据都存在主内存中(RAM),但是CUP的处理速度远远大于主存的访问数度,这样严重影响了CPU的处理效率,为了解决I/O速度和CPU运算速度严重不匹配的这一问题引入了高速缓存;
为什么高速缓存可以解决这一问题:因为cup运算时从高速缓存中读写数据的速度大于直接从主存中读取数据的速度,高速缓存中缓存了常用的数据,其从主存中存取数据或指令时,都趋于一片连续的区域中,这被称为局部性原则:
时间局部性:如果一个数据被访问,那么它近期可能还会被访问,比如递归、循环、方法的反复调用等;
空间局部性:如果一个存储器被引用,那么将来它附近的位置也会被引用;

缓存均衡了与内存的速度差异,现在的缓存数量有三级:最接近CPU的是L1,然后依次是L2、L3,cup读取数据时先从L1开始查找,如果没找到在一次从L2、L3、主存查找;
接下来称高速慌缓存为cache;
将运算所需要的数据复制一份到cache中,这样CUP在进行计算时直接可以对cache进行读取和写入;
当运算结束后,再把最新的数据刷到主存中。
在这里插入图片描述
共享变量存储在主存中,任何线程都可以访问:

主要存储的是java实例对象,所有线程创建的对象都放在主存中,不管实例对象是成员变量还是方法中的局部变量,也包括共享的类信息、常量、静态变量等都是放在主存中的。

每个线程都有自己独有的工作内存,也成为本地内存,并且工作内存只存储共享变量的副本。

主存与工作内存的的数据存储类型:
1.对于一个执行中的方法而言,如果方法中包含的本地变量是基本数据类型,则直接存储在工作内存的栈帧中,如果本地变量是引用类型,那么该变量的引用会存储在工作内存中,而对象实例存储在主内存(堆)中。
2.对于对象的成员变量,不管是基本数据类型、包装类型还是引用类型、static变量、类的本身信息,都会存储在主存中。

在主存中的数据可以被多线程共享的,倘若两个线程同时调用同一对象的同一方法,那么两个线程会把要操作的数据拷贝到自己的工作内存之中,执行完成之后才刷新到主存中。

在这里插入图片描述
在这里插入图片描述
线程对主内存的操作指令:
一个变量如何从主内存复制到工作内存,又如何从工作内存同步到主内存,JMM定义了八个操作步骤来完成:
1.Lock(锁定):作用与主内存变量,把一个变量标识为独占状态;
2.UnLock(解锁):作用于主内存变量,把一个处于锁定状态的变量解锁,解锁后的变量才能被其它线程锁定;
3.Read(读取):作用于主内存变量,把一个变量值从主内存中传输到工作内存中,以便后面的laod动作使用;
4.Load(加载):作用于工作内存变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中;
5.Use(使用):作用于工作内存的变量,把工作内存中的一个变量值传给执行引擎,每当虚拟机遇到一个需要使用变量值的字节码指令时就会执行这个操作;
6.Assign(赋值):作用于工作内存的变量,从执行引擎接受到的值赋值给工作内存中的变量,每当虚拟机遇到给一个变量赋值的字节码指令时就会执行该操作;
7.Store(存储):作用于工作内存的变量,把工作内存中的一个变量值传给主内存中,以便后面的write在操作;
8.Write(写入):作用于主内存变量,它把store操作从工作内存中传递来的一个变量值传送到主内存的变量中;
上面的八个步骤都是原子操作,在使用上是相互依赖的,lock-unlock是对主内存中变量的加锁与解锁,read-load从主内存中复制变量到工作内存中,use-assign执行代码改变变量的值,store-write把工作内存中的变量刷新到主内存中 。

JMM与硬件内存架构的关系:
对于硬件来说,只有寄存器、缓存内存(高速缓存)、主内存(RAM)的概念,并没有工作内存(线程私有数据区域)主内存(堆和原空间)之分,也就是所JMM对内存的划分对硬件内存的划分没有任何影响,因为JMM只是一种抽象概念,是一组规则。
不管是工作内存还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存(RAM)中,当然有可能存储到CPU缓存中和寄存器中,因此总体来说,java内存模型和计算机硬件内存架构是一个相互交叉的关系是一种抽象概念划分与真实物理硬件的交叉。

在这里插入图片描述

volatile的内存语义:

volatile的特性:
1.可见性:一个线程对某个变量的值进行了修改,另一个线程立即能看到;
2.原子性:对变量的读/写具有原子性(32位的long和duble类型不具备原子性),因为对变量的read、load、use操作和assign、store、write操作必须是连续的,即修改后必须同步回主内存,但是类似于volatile++这种符合操作就不具有原子性,所以只能保证get和set操作的原子性,而不能保证getAndOprerate的操作的原子性,基于这一点我可以一般认为volatile修饰的变量不具备原子性;
3.有序性:对volatile修饰的变量读写操作前后加上各种特定的内存屏障来禁止指令重排序来保证有序性。

volatile读写的内存予语义:

  • 当写一个volatile变量时,JMM会把线程对应的工作内存中的共享变量刷新到主内存;
  • 当读一个volatile变量时,JMM会把线程的工作内存的变量置为无效,会从主内存中读取共享变量。

volatile可见性实现的原理:

JMM交互层面实现:
read、load、use操作和assign、store、write操作必须是连续的,即修改后立即写回主内存,使用时必须从主内存读取,由此保证volatile变量的操作对多线程的可见性。
硬件层面实现:
通过lock前缀指令,会锁定变量对应的缓存行区域并写回主内存,这个操作称为缓存锁定。缓存一致性会阻止两个及以上的处理器修改同一缓存区域,一个处理器的缓存回写到主内存会导致其它处理器的缓存区域失效。
缓存锁定:锁定变量对应的缓存行区域并写回主内存,会导致其它处理器的缓存区域失效,这就是缓存锁定

lock前缀指令的作用:

  • 确保后续指令的原子性,使用缓存锁定来保证指令执行的原子性;
  • 有类似内存屏障的作用,禁止该指令前面的和后面的读写指令进行重排序,类似把lock前缀指令前面指令执行完成之后才能执行后面的指令。
  • Lock前缀指令会等待它之前所有的指令都完成,并且所有的写操作写回主内存(也就是store
    buffer中的内容写入内存)之后才会执行,并且根据缓存一致性协议刷新store
    buffer的操作会导致其它cache中的副本变量会失效。

volatile重排序规则:
在这里插入图片描述
volatile禁止重排序的场景:
1、第二个操作时volatile写,不管第一个操作是什么都不会从排序;
2、第一个操作是volatile读,不管第二个操作是什么,都不会重排序;
3、第一个操作时volatile写,第二个操作时volatie也不会发生重排序;

重排序:
计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排;
Java语言规定JVM内部维持顺序化语义,即只要程序的最终结果与它顺序化的执行结果相等,那么指令执行的顺序与代码的顺序可以不一致,此过程就是指令的重排序。

  • 编译器重排:编译器在不改变单线程程序语义的情况下,可以重新安排语句的执行顺序;
  • 处理器重排:
    1.指令并行的重排:现代处理器采用指令并行的技术,将多条指令并行执行,也就是在执行一个指令的等待过程中,可以同时执行另一个不互相依赖的指令;即如果不存在数据依赖性(后一个执行语句不依赖前一个执行语句的执行结果),处理器可以改变语句对应指令的执行顺序;
    2.内存系统的重排序:(这个的细节我不太明白)由于处理器使用缓存和读写缓存冲区,这使得加载(load)和存储(store)操作看上去可能是在乱序执行,因为三级缓存的存在,导致内存与缓存的数据同步存在时间差;

JMM内存屏障插入策略:

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

JVM层面的内存屏障:
在JSR规范中定义了4中内存屏障。
1.LoadLoad屏障(指令load1 LoadLoad load2):在load2及后续读操作要读取的数据被访问前,保证load1要读取的数据被读取完;
2.LoadStore屏障(指令load1 LoadStore Store2):在Store2及后续写入操作被执行前,保证Load1要读取的数据被读取完毕;
3.StoreStore屏障(Store1 StoreStore Store2):在Store2操作及后续写入操作被执行前,保证Store1的写入操作对其它处理器可见;
4.StoreLoad屏障(指令Store1 StoreLoad Load2):在Load2及后续所有读操作执行前,保证Store1的写入对所有处理器可见;这个屏障的开销是最大的,在大多数的处理器中,这个屏障是万能屏障,兼容其它三种屏障;
由于X86系统只有StoreLoad屏障可会重排序,所以StoreLoad屏障对应的是mfence后lock前缀指令,其它屏障对应空操作;

硬件层面内存屏障:
硬件层面提供了一系列的内存屏障memory barrier/memory fence(Inter的提法)来提供一致性的能力;拿X86平台来说,提供了几种主要的内存屏障:

  • Ifence:是一种Load barrier读屏障;
  • Sfence:是一中Store barrier写屏障;
  • Mfence:是一种全能屏障,具备ifence和sfence的能力;
  • Lock前缀指令:lock前缀不是内存屏障,但是它能完成类似内存屏障的功能;Lock前缀指令会对CPU总线和高速缓存,可以理解为CUP级别的锁;

内存屏障有两个能力:
1.阻止内存屏障两边的指令重排序;
2.刷新处理器缓存/冲刷处理器缓存;

对load barrier来说,在读指令前加度屏障,可以让高速缓存中的数据失效,重新从主内存中读取;对于Store barrier来说,在写指令之后插入写屏障,可以让写入缓存中的最新数据写入主内存;
Lock前缀实现了类似的能力,它先对总线和缓存加锁,然后执行后面的命令,最后释放锁后会把高速缓存中的数据刷回主内存;在Lock锁住总线的时候,其他CPU的读写请求会被阻塞,直到释放锁。
不同硬件实现内存屏障的方式不同,java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同平台生成相应的机器码。

JMM中的可见性、有序性、原子性都是由内存屏障来具体实现的;

缓存一致性问题:

现在的计算机都是多核的,每个核心都有自己的高速缓存,上面的硬件架构图中已经画出来了;每个核心对操作的变量都会有一个副本在自己的缓存中,该变量修改之后,这个修改后的变量副本对其它处理器是不可见的,这就造成了缓存不一致。

为了解决缓存不一致的问题,有两种方式:
1.通过总线加锁的方式:
CPU和其它组件的通信都是通过总线(数据总线、控制总线、地址总线)来进行的,如果采用总线加锁的方式,则会阻塞其它CUP对组件的访问,从而使得只有一个CPU可以访问这个变量的内存,这种方式效率低下;
2.通过缓存一致性协议:有多种
可以分为两类:窥探机制 和基于目录的机制,而下面说的MESI就是窥探机制中的一种;这两类各有优缺点:窥探要求的宽带要大一些,是广播的形式,速度快;而基于目录机制的对宽带要求低一些,是点对点的传播,较大的系统可以使用 这种机制。

窥探机制的工作原理:
当特定数据被多个缓存共享时,当某一处理器修改了共享数据的值时,必须通知拥有该变量的缓存中,这种通知可以防止违反缓存一致性。而数据变更的通知可以通过总线窥探来完成,所有的窥探者都在监视总线上的每个事务,如果一个CUP修改了共享变量的事务出现在总线上,所有的窥探者(CPU)都会知道这一动作,然后检查自己的缓存是否拥有相同的变量副本,如果有这个副本变量,则执行相应的操作来确保缓存一致性,这个动作可以是刷新缓存块或使缓存块失效。

窥探协议类型:

  • Write-invalidate:
    当处理器写入一个缓存块时,其它缓存通过总线窥探会把自己的副本失效,要使用时,重新从主内存读取;MESI、MSI、MOSI、MOESI、MESIF协议都属于这种类型;
    Write-update:
    当处理器写入一个缓存块时,其它缓存的所有副本都会通过总线窥探到进行更新,这个方法是把写数据通过总线广播到其它所有的缓存,这种类型会造成更大的总线协议,不常使用,dragon、firefly协议属于此类型。

MESI协议:

CUP Cache:
在这里插入图片描述

Cache Line:
缓存中的数据是以缓存行的形式存储的,目前主流CPU Cahce中的Cache Line的大小通常是64个字节,并且它有效的引用主内存中的一块地址;

MESI是众多缓存一致性协议中的一种,也是Inter系列中广泛使用的一种缓存一致性协议。
缓存行(Cache Line)的状态有Modified、Exclusive、Share、Invalid,而MESI是以这四种状态的首字母来命名的。
该协议在每个缓存行上维护一个状态位,来标识这个数据单位的可能有的唯一状态M、E、S、I,以下是各状态的含义:
在这里插入图片描述
数据被共享,但是缓存数据与主内存不一致的情况,这就是MESI协议要解决的问题。

MESI使得CUP都时刻监听着总线上每个总线事务,不同缓存行状态对应不同的监听任务,监听任务的规则如下:

  • 一个处于M状态的缓存行,必须时刻监听着所有试图读取该缓存行对应主内存地址的操作,如果监听到,则必须在执行前把缓存行的数据写回主内存;
  • 一个处于S状态的缓存行,必须时刻监听使该缓存行无效或者独享缓存行的请求,如果监听到,则必须把缓存行的状态设置为无效I;
  • 一个处于E状态的缓存行,必须时刻监听着其它试图读取该缓存行对应主内存地址的操作,如果监听到,则把该缓存行的状态设置为共享S;

MESI消息:
在这里插入图片描述

MESI协议的处理流程:
以两核处理器为例,CPU-A、CPU-B, 分别对应L1A高速缓存和L1B高速缓存;
数据读取场景:
CPU-A读取数据X;
流程:
CPU-A需要读取数据X,会根据数据的地址在自己的缓存L1A中找对应的缓存行,然后判断缓存行的状态:

1.如果缓存行的状态是M、E、S,说明当前缓存行的数据相对去当前读请求是可用的,直接从缓存行中获取其对应的数据;
2.如果缓存行的状态是I,则说明该缓存行的数据是无效的(和数据不存在自己缓存中是一样的),则CPU-A会向总线发起Read消息,说“我需要X地址的数据,谁可以提供”,其它处理器(CPU-B) 会监听总线上的消息,收到消息后,从消息中解析出需要数据对应的X地址,然后根据X地址在自己的缓存L1B中查找缓存行,这是CPU-B找到缓存行的状态会有以下几种情况:

  • 状态为S、E,CPU-B会构造Read
    Response消息,将相应缓存行中的数据放在消息中,将消息发送到总线,同时更新自己的消息为S,CUP-A收到响应消息后,将消息中的数据放进相应的缓存行,并更新状态为S;
  • 状态为M,会先将自己缓存行中的数据写入主内存,并相应Read Response消息,并同时将缓存行中的状态更新为S;
  • 状态为I或缓存L1B中不存在地址X的数据,那么主内存会构造Read
    Response消息,从主内存中读取地址X对应的地址块数据放入消息中,并将消息发送到总线;
    CUP-A接收到总线消息后,解析出数据保存到对应的缓存行中;

数据写场景
CPU-A需要对地址X的X数据写回缓存行的操作;
流程:
CPU-A会根据内存地址在自己的缓存L1A中找对应的缓存行,判断缓存行的状态,可能有以下几种状态:

  • 状态为E、M,说明CPU-A已经拥有了数据的所有权,此时CPU-A会直接将数据写入缓存行中,并更新缓存行状态为M,不需要向总线发送任何消息;
  • 状态为S时,说明数据被共享了,其它CPU可能存有该数据的副本,则CPU-A向总线发送Invaledate消息,以获取数据的所有权,其它处理器(CPU-B)接受到Invalidate消息后,会将相应缓存行中的状态置为I(无效),并回复Invalidate
    Acknowledge消息,CUP-A接受到所有CPU的相应消息后,修改L1A地址X对应缓存行的状态为E,此时拥有数据的所有权,会将数据写入缓存行中,并更新缓存行的状态为M;
  • 状态为I时,说明处理器不包含数据X的副本,CUP-A会向总线发送Read
    Invalidate消息,表明“我要读取数据X,希望主内存告诉我数据X的值,同时请求其它处理器把包含该数据的缓存行置为状态I(无效)

(以下的3个小步骤是丢弃了值。可能有的CPU会不关心写入时缓存行的状态,而是直接覆盖其它线程的值)
1)其它处理器监听到该消息后,如果相应缓存行的状态不是I,则将其状态置为I,并响应Invalidate Acknowledge消息;
2)主内存监听到Read消息后,会响应Read Response消息,将CPU-A想读取地址的地址块数据放在Read Response中;
3)CPU-A会接收到其它所有CPU的Invalidate Acknowledge消息,和主内存响应的Read Response消息后,会将缓存行的状态为E,此时拥有数据的所有权,会将Read Response消息中包含的数据更新到对应的缓存行中,并更新缓存行的状态为M;

问题:

既然CPU有了MESI协议能够保证Cache的一致性,那么为何还需要volatile关键字来保证可见性(内存屏障)?或者只有加了volatile的变量在多核CPU执行的时候才会触发缓存一致性协议?
答:在多核的情况下,所有的CPU都会有缓存一致性协议,是弱一致性,但是不能保证一个线程改变后其它线程能立马看见,也就是说其它CPU中的副本数据虽然置为无效,可是当前CPU可能将数据修改到高速缓存后又去做其它的事了,没来得及将修改后的变量刷新会主内存,而其余变量使用该变量时,仍然使用主内存的旧值;而volatile关键之能保证可见性的原因是修改操作和写回主内存操作是原子操作,修改后的值立即刷新回主内存。
所以我们认为volatile触发缓存一致性协议也是没有错的。

如果缓存行装不下数据,则会转为总线锁。

as-if-serial

是一种规则,as-if-serial的语义是不管如何排序(编译器和处理器为了提高效率)保证单线程内程序的执行结果不变;
为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,
因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可 能被编译器和处理器重排序

happens-before:
语义是如果第一个操作的执行结果对第二个操作可见,那么第一个操作的一定在第二个操作之前,这是JMM对程序员的承诺;从程序员的角度可以理解为 如果A happens-before B,那么JMM向程序员保证A操作的结果对B可见,且A的执行顺序在B之前;
如果两个操作存在happens-before关系,这并不意味着java平台的具体实现必须要按照happens-before关系指定的顺序来执行,如果重排序的执行结果与按happens-before关系来执行的结果一致,那么这种重排序是允许的,JMM也允许这种重排序。
程序员对内存模型的使用希望是易于理解易于编程,程序员希望以强内存模型来编写模型,但是编译器和处理器希望内存模型对它们的约束越少越好,这样它就可以做尽可能多的优化了提高性能;
但是JMM真实不是按这种规则来执行的,为了提升计算机的性能,对于正确同步的多线程的执行结果不发生改变,则JMM是允许重排序的。
JMM向程序员提供的happens-before规则能够满足程序员的要求,JMM的happens-before规则不但简单易懂,而且向程序员提供了足够强的内存可见性(有些内存可见性保证不一定真是存在的)
一个happens-before规则对应于一个或多个编译器和处理器的排序规则。对于程序员来说,happens-before规则简单易懂,隐藏了JMM具体的执行顺序,它避免了程序员为理解JMM提供的可见性保证而去学习复杂的重排序规则及其实现方法。
如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序
只靠sychronized和volatile关键字来保证原子性、可见性以及有序性,那么编写并发
程序可能会显得十分麻烦,幸运的是,从JDK 5开始,Java使用新的JSR-133内存模型,提供了happens-before 原则来辅助保证程序执行的原子性、可见性以及有序性的问题,它是判断数据是否存在竞争、线程是否安全的依据,happens-before 原则内容如下
1.程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
2.锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
3.volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
4.线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
5.传递性 A先于B ,B先于C 那么A必然先于C
6.线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见。
7.线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断。
8.对象终结规则对象的构造函数执行,结束先于finalize()方法

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值