JMM内存模型

一:在并发编程中我们需要知道两个关键问题:线程之间的通信及线程之间如何同步?在java中线程之间以何种机制来交换信息?

线程之间的通信机制有两种,共享内存和消息传递:

在共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态来隐式来进行通讯 :如:Volatile

在消息传递的并发模型里,线程之间的没有公共状态,线程之间必须通过明确的发送消息来显示通信。wait,notfy,notifyall

线程之间的同步,是指程序控制不同线程间的操作发生相对顺序的机制:

在共享内存的并发模型里,同步是显示的,必须制定某个方法或某段代码在线程之间互斥执行;如synchnronized

在消息传递的并发模型里,由于消息发送在必须在消息的接受之前,因此同步是隐士进行的。

java的并发模型模型采用的是共享内存模型,java之间线程之间的通信总是隐式,整个过程是完全透明,

2:定位内存可见性问题

什么对象是内存共享,什么不是?

Volatile可见性  synchronized 同步1. 对于声明了Volatile 的变量进行写操作的时候,jvm会向处理器发送一条lock的前缀指令,会把这个变量所在的缓存行的数据写回到系统内存当中。

2.在多处理器的情况下,保证各个处理器缓存一致性特点,就会实现缓存一致性协议(MESI协议)。(失效,然后在从系统当中获取最新值)

synchronized 可见性,原子性,可重入性

volatile 可以做到原子性,可见性,不能做到复合操作的原子性。

内存交互操作

由上面的交互关系可知,关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

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

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述两个操作必须按顺序执行,而没有保证必须是连续执行。也就是read和load之间,store和write之间是可以插入其他指令的,如对主内存中的变量a、b进行访问时,可能的顺序是read a,read b,load b, load a。Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

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

原子性、可见性、有序性

原子性

JMM保证的原子性变量操作包括read、load、assign、use、store、write,而long、double非原子协定导致的非 原子性操作基本可以忽略。如果需要对更大范围的代码实行原子性操作,则需要JMM提供的lock、unlock、synchronized等来保证。

可见性

前面分析volatile语义时已经提到,可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。JMM在变量修改后将新值同步 回主内存,依赖主内存作为媒介,在变量被线程读取前从内存刷新变量新值,保证变量的可见性。普通变量和volatile变量都是如此,只不过 volatile的特殊规则保证了这种可见性是立即得知的,而普通变量并不具备这种严格的可见性。除了volatile外,synchronized和 final也能保证可见性。

有序性

JMM的有序性表现为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句指“线程内表现为串行的语义”(as-if-serial),后半句值“指令重排序”和普通变量的”工作内存与主内存同步延迟“的现象。

重排序

在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。从硬件架构上来说,指令重排序是指CPU采用了允许将多条指令不按照程序规定的顺序,分开发送给各个相应电路单元处理,而不是指令任意重排。重排序分成三种类型:

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

volatile变量规则

关键字volatile是JVM中最轻量的同步机制。volatile变量具有2种特性:

  • 保证变量的可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入,这个新值对于其他线程来说是立即可见的。
  • 屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,下文有详细的分析。

volatile语义并不能保证变量的原子性。对任意单个volatile变量的读/写具有原子性,但类似于i++、i–这种复合操作不具有原子性,因为自增运算包括读取i的值、i值增加1、重新赋值3步操作,并不具备原子性。

由于volatile只能保证变量的可见性和屏蔽指令重排序,只有满足下面2条规则时,才能使用volatile来保证并发安全,否则就需要加锁 (使用synchronized、lock或者java.util.concurrent中的Atomic原子类)来保证并发中的原子性。

  • 运算结果不存在数据依赖(重排序的数据依赖性),或者只有单一的线程修改变量的值(重排序的as-if-serial语义)
  • 变量不需要与其他的状态变量共同参与不变约束

因为需要在本地代码中插入许多内存屏蔽指令在屏蔽特定条件下的重排序,volatile变量的写操作与读操作相比慢一些,但是其性能开销比锁低很多。

数据依赖性规则

as-if-serial规则

happens-before原则

如果代码中存在控制依赖的时候,会影响指令序列执行的并行度(因为高效)。也是为此,编译器和处理器会采用猜测(Speculation)执行来克 服控制的相关性。所以重排序破坏了程序顺序规则(该规则是说指令执行顺序与实际代码的执行顺序是一致的,但是处理器和编译器会进行重排序,只要最后的结果 不会改变,该重排序就是合理的)。

在单线程程序中,由于as-ifserial语义的存在,对存在控制依赖的操作重排序,不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

JMM的重排序屏障

从Java源代码到最终实际执行的指令序 列,会经过三种重排序。但是,为了保证内存的可见性,Java编译器在生成指令序列的适当位置会插入内存屏 障指令来禁止特定类型的处理器重排序。对于编译器的重排序,JMM会根据重排序规则禁止特定类型的编译器重排序;对于处理器重排序,JMM会插入特定类型 的内存屏障,通过内存的屏障指令禁止特定类型的处理器重排序。这里讨论JMM对处理器的重排序,为了更深理解JMM对处理器重排序的处理,先来认识一下常 见处理器的重排序规则:

其中的N标识处理器不允许两个操作进行重排序,Y表示允许。其中Load-Load表示读-读操作、Load- Store表示读-写操作、 Store-Store表示写-写操作、Store-Load表示写-读操作。可以看出:常见处理器对写-读操作都是允许重排序的,并且常见的处理器都不 允许对存在数据依赖的操作进行重排序(对应上面数据转换那一列,都是N,所以处理器不允许这种重排序)。

那么这个结论对我们有什么作用 呢?比如第一点:处理器允许写-读操作两者之间的重排序,那么在并发编程中读线程读到可能是一个未被初始化或者是一个 NULL等,出现不可预知的错误,基于这点,JMM会在适当的位置插入内存屏障指令来禁止特定类型的处理器的重排序。内存屏障指令一共有4类:

  • LoadLoad Barriers:确保Load1数据的装载先于Load2以及所有后续装载指令
  • StoreStore Barriers:确保Store1的数据对其他处理器可见(会使缓存行无效,并刷新到内存中)先于Store2及所有后续存储指令的装载
  • LoadStore Barriers:确保Load1数据装载先于Store2及所有后续存储指令刷新到内存
  • StoreLoad Barriers:确保Store1数据对其他处理器可见(刷新到内存,并且其他处理器的缓存行无效)先于Load2及所有后续装载指令的装载。该指令会使得该屏障之前的所有内存访问指令完成之后,才能执行该屏障之后的内存访问指令。

猜你喜欢

转载自www.cnblogs.com/Cjbolgs/p/9266668.html