JMM基础-计算机原理


Java内存模型即Java Memory Model,简称JMM。

JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。Java1.5版本对其进行了重构,现在的Java仍沿用了Java1.5的版本。Jmm遇到的问题与现代计算机中遇到的问题是差不多的。

物理计算机中的并发问题,物理机遇到的并发问题与虚拟机中的情况有不少相似之处,物理机对并发的处理方案对于虚拟机的实现也有相当大的参考意义。
根据《Jeff Dean在Google全体工程大会的报告》我们可以看到
06.Java内存模型 - 图1
计算机在做一些我们平时的基本操作时,需要的响应时间是不一样的。
(以下案例仅做说明,并不代表真实情况。)

如果从内存中读取1M的int型数据由CPU进行累加,耗时要多久?

做个简单的计算,1M的数据,Java里int型为32位,4个字节,共有10241024/4 = 262144个整数 ,则CPU 计算耗时:262144 0.6 = 157 286 纳秒,而我们知道从内存读取1M数据需要250000纳秒,两者虽然有差距(当然这个差距并不小,十万纳秒的时间足够CPU执行将近二十万条指令了),但是还在一个数量级上。但是,没有任何缓存机制的情况下,意味着每个数都需要从内存中读取,这样加上CPU读取一次内存需要100纳秒,262144个整数从内存读取到CPU加上计算时间一共需要262144*100+250000 = 26 464 400 纳秒,这就存在着数量级上的差异了。

而且现实情况中绝大多数的运算任务都不可能只靠处理器“计算”就能完成,处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作是基本上是无法消除的(无法仅靠寄存器来完成所有运算任务)。早期计算机中cpu和内存的速度是差不多的,但在现代计算机中,cpu的指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

cpu在执行过程中,它的执行效率要比内存的运算速度要高的高的多.所以为了解决CPU里面的运算效率和内存运算效率之间的差异,在我们的内存和cpu之间会存在一个cpu高速缓存.

06.Java内存模型 - 图2
在计算机系统中,寄存器划是L0级缓存,接着依次是L1,L2,L3(接下来是内存,本地磁盘,远程存储)。越往上的缓存存储空间越小,速度越快,成本也更高;越往下的存储空间越大,速度更慢,成本也更低。从上至下,每一层都可以看做是更下一层的缓存,即:L0寄存器是L1一级缓存的缓存,L1是L2的缓存,依次类推;每一层的数据都是来至它的下一层,所以每一层的数据是下一层的数据的子集。


在现代CPU上,一般来说L0, L1,L2,L3都集成在CPU内部,而L1还分为一级数据缓存(Data Cache,D-Cache,L1d)和一级指令缓存(Instruction Cache,I-Cache,L1i),分别用于存放数据和执行数据的指令解码。每个核心拥有独立的运算处理单元、控制器、寄存器、L1、L2缓存,然后一个CPU的多个核心共享最后一层CPU缓存L3

06.Java内存模型 - 图3

性能测试图(L1 L2 L3图)
06.Java内存模型 - 图4

主内存和工作内存

在JMM中主内存属于共享数据区域,从某个程度上讲应该包括了堆和方法区,而工作内存数据线程私有数据区域,从某个程度上讲则应该包括程序计数器、虚拟机栈以及本地方法栈。或许在某些地方,我们可能会看见主内存被描述为堆内存,工作内存被称为线程栈,实际上他们表达的都是同一个含义。关于JMM中的主内存和工作内存说明如下

  • 主内存
    主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会发现线程安全问题。
  • 工作内存
    主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。

弄清楚主内存和工作内存后,接了解一下主内存与工作内存的数据存储类型以及操作方式,根据虚拟机规范,对于一个实例对象中的成员方法而言,如果方法中包含本地变量是基本数据类型(boolean,byte,short,char,int,long,float,double),将直接存储在工作内存的帧栈结构中,但倘若本地变量是引用类型,那么该变量的引用会存储在功能内存的帧栈中,而对象实例将存储在主内存(共享数据区域,堆)中。但对于实例对象的成员变量,不管它是基本数据类型或者包装类型(Integer、Double等)还是引用类型,都会被存储到堆区。至于static变量以及类本身相关信息将会存储在主内存中。需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存,简单示意图如下所示:
06.Java内存模型 - 图5

硬件内存架构

06.Java内存模型 - 图6

正如上图所示,经过简化CPU与内存操作的简易图,实际上没有这么简单,这里为了理解方便,我们省去了南北桥并将三级缓存统一为CPU缓存(有些CPU只有二级缓存,有些CPU有三级缓存)。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。

Java线程与硬件处理器

了解完硬件的内存架构后,接着了解JVM中线程的实现原理,理解线程的实现原理,有助于我们了解Java内存模型与硬件内存架构的关系,在Window系统和Linux系统上,Java线程的实现是基于一对一的线程模型,所谓的一对一模型,实际上就是通过语言级别层面程序去间接调用系统内核的线程模型,即我们在使用Java线程时,Java虚拟机内部是转而调用当前操作系统的内核线程来完成当前任务。这里需要了解一个术语,内核线程(Kernel-Level Thread,KLT),它是由操作系统内核(Kernel)支持的线程,这种线程是由操作系统内核来完成线程切换,内核通过操作调度器进而对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也就是操作系统可以同时处理多任务的原因。由于我们编写的多线程程序属于语言层面的,程序一般不会直接去调用内核线程,取而代之的是一种轻量级的进程(Light Weight Process),也是通常意义上的线程,由于每个轻量级进程都会映射到一个内核线程,因此我们可以通过轻量级进程调用内核线程,进而由操作系统内核将任务映射到各个处理器,这种轻量级进程与内核线程间1对1的关系就称为一对一的线程模型。如下图

06.Java内存模型 - 图7

如图所示,每个线程最终都会映射到CPU中进行处理,如果CPU存在多核,那么一个CPU将可以并行执行多个线程任务。

物理内存模型带来的问题


基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(Cache Coherence)。在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。

现代的处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。

06.Java内存模型 - 图8
06.Java内存模型 - 图9
处理器A和处理器B按程序的顺序并行执行内存访问,最终可能得到x=y=0的结果。

处理器A和处理器B可以同时把共享变量写入自己的写缓冲区(步骤A1,B1),然后从内存中读取另一个共享变量(步骤A2,B2),最后才把自己写缓存区中保存的脏数据刷新到内存中(步骤A3,B3)。当以这种时序执行时,程序就可以得到x=y=0的结果。

从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1→A2,但内存操作实际发生的顺序却是A2→A1。

如果真的发生这种情况,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

06.Java内存模型 - 图10


最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。

具体参考百度百科

https://baike.baidu.com/item/%E7%BC%93%E5%AD%98%E4%B8%80%E8%87%B4%E6%80%A7/15814005?fr=aladdin


1.伪共享

演示代码:zjj_parent_b69dd541-bce8-5f8b-85db-03f7d1cc1c7a


前面我们已经知道,CPU中有好几级高速缓存。但是CPU缓存系统中是以缓存行(cache line)为单位存储的。目前主流的CPU Cache的Cache Line(缓存行)大小都是64Bytes。

Cache Line可以简单的理解为CPU Cache中的最小缓存单位,今天的CPU不再是按字节访问内存,而是以64字节为单位的块(chunk)拿取,称为一个缓存行(cache line)。当你读一个特定的内存地址,整个缓存行将从主存换入缓存。

什么是伪共享

一个缓存行可以存储多个变量(存满当前缓存行的字节数);而CPU对缓存的修改又是以缓存行为最小单位的,在多线程情况下,如果需要修改“共享同一个缓存行的变量”,就会无意中影响彼此的性能,这就是伪共享(False Sharing)。


06.Java内存模型 - 图11
x和y毫无关系,但是放在同一个缓存行里面了,这就产生了竞争情况,对性能会产生影响

假如一个CPU核心的线程在对x进行修改,另一个CPU核心的线程却对y进行读取,当前者修改了x时,会把x和y同时加载到前者的核心的缓存行中,更新完x后其它所有包含x的缓存行都将失效,因为其它缓存中的x已经不是最新的值了
而当后者读取y的时候,发现缓存行已经失效了,需要重写从主内存中重写加载.

我们的缓存都是以缓存行作为一个单位来处理的,所以失效x的缓存的同时,也会把y失效,反之亦然.这就出现了一个问题,y和x完全不相干,每次却因为x的更新需要重新从主内存读取.它被缓存未命中给拖慢了,这就是伪共享.


解决伪共享

为了避免伪共享,我们可以使用数据填充的方式来避免,即单个数据填充满一个CacheLine。这本质是一种空间换时间的做法。但是这种方式在Java7以后可能失效。

Java8中已经提供了官方的解决方案,Java8中新增了一个注解@sun.misc.Contended。

比如JDK的ConcurrentHashMap中就有使用
06.Java内存模型 - 图12
加上这个注解的类会自动补齐缓存行,需要注意的是此注解默认是无效的,需要在jvm启动时设置-XX:-RestrictContended才会生效。

一个类中,只有一个long类型的变量:
06.Java内存模型 - 图13
定义一个VolatileLong类型的数组,然后让多个线程同时并发访问这个数组,这时可以想到,在多个线程同时处理数据时,数组中的多个VolatileLong对象可能存在同一个缓存行中。
06.Java内存模型 - 图14
运行后,可以得到运行时间
06.Java内存模型 - 图15
花费了39秒多。
我们改用进行了缓存行填充的变量
06.Java内存模型 - 图16
花费了8.1秒,如果任意注释上下填充行的任何一行,时间表现不稳定,从8秒到20秒都有,但是还是比不填充要快。具体原因目前未知。
再次改用注解标识的变量,同时加入参数-XX:-RestrictContended
06.Java内存模型 - 图17
06.Java内存模型 - 图18
花费了7.7秒。
由上述的实验结果表明,伪共享确实会影响应用的性能。

Java内存模型带来的问题

1.Java内存模型概述

从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),我们称之为工作内存,本地内存(工作内存)中存储了该线程以读/写共享变量的副本。

每个线程在对某个值进行操作的时候,它不是直接读写主内存里面的这个值,而是把主内存的这个值拿出来读取到工作内存里面,每个线程的内部的工作内存就有了这个值副本,这个工作内存是每个线程自己看到,它们是线程隔离的,也就是说线程A的工作内存不能访问线程B的工作内存.

本地内存(工作内存)是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。


06.Java内存模型 - 图19
06.Java内存模型 - 图20


2.可见性问题

总结:

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值,这就是可见性.

若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。


06.Java内存模型 - 图21
线程2在对count进行操作了.在自己的工作内存给count改成了2, 此时线程1看不到count改成了2(线程1看不到线程2的工作内存的内容),只要线程2没有把内容(count = 2)更新到主内存里面,此时线程1读取的永远是旧的值(就是count为1),这就是所谓的可见性问题.


在多线程的环境下,假如两个线程都是一次运行,此时工作内存里面是没有count变量的,如果某个线程首次读取共享变量(count变量),则首先到主内存中获取该变量,然后存入自己的工作内存中,以后只需要在工作内存中读取该变量即可,在后面的运算过程中,它发现它的工作内存中已经有了这个count共享变量的时候,它就很有可能不会在主内存中读取这个变量了.

同样如果对该变量执行了修改的操作,则先将新值写入自己的工作内存中,然后再刷新至主内存中。但是工作内存什么时候最新的值会被刷新至主内存中是不太确定,一般来说会很快,但具体时间不知。

要解决共享对象可见性这个问题,我们可以使用volatile关键字或者是加锁,CAS操作也行。

3.原子性问题(竞争问题)


原子性就是线程1对主内存里面的count值进行操作的时候,线程2是不能进行操作的,只能等到线程1操作完了,线程2才能进行操作.
如果没有原子性的话,就是两个线程同时操作主内存里面的count值,这样就会存在问题了.




线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。

06.Java内存模型 - 图22

如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。

然而图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次,结果是count=2,尽管一共有两次加1操作,但是有的一个操作丢失了,这就是所谓的竞争问题。 要解决上面的问题我们可以使用java synchronized代码块(逼迫两个并行的操作变成串行操作).

原子性其实就是保证数据一致、线程安全一部分.

保证原子性: Synchronized , AtomicInteger , Lock

4.有序性问题


一般来说处理器为了提高程序运行效率,提高性能,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。如下:

int a = 10; //语句1
int r = 2; //语句2
a = a + 3; //语句3
r = a*a; //语句4

则因为重排序,他还可能执行顺序为 2-1-3-4,1-3-2-4
但绝不可能 2-1-4-3,因为这打破了依赖关系。 显然重排序对单线程运行是不会有任何问题,而多线程就不一定了,所以我们在多线程编程时就得考虑这个问题了。

我们写的代码,编译器会把语句执行顺序进行调整, cpu不是按顺序执行的,会对指令进行重排序,会把多个指令重叠执行,甚至会提前执行,这就是重排序.

多线程重排序问题:

文档:[和笔记对接]深入理解Java内存模型—重…
链接:http://note.youdao.com/noteshare?id=7ffec6df578f5ad10e23138be32eaf47&sub=2D856F77F2924A0496B1961211A6C2BE


重排序类型


除了共享内存和工作内存带来的问题,还存在重排序的问题:在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。

3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

数据依赖性


如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列3种类型,上面3种情况,只要重排序两个操作的执行顺序,程序的执行结果就会被改变。
06.Java内存模型 - 图23
例如:
06.Java内存模型 - 图24
很明显,A和C存在数据依赖,B和C也存在数据依赖,而A和B之间不存在数据依赖,如果重排序了A和C或者B和C的执行顺序,程序的执行结果就会被改变。
很明显,不管如何重排序,都必须保证代码在单线程下的运行正确,连单线程下都无法正确,更不用讨论多线程并发的情况,所以就提出了一个as-if-serial的概念。

as-if-serial

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。(强调一下,这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。)但是,如果操作之间不存在数据依赖关系,这些操作依然可能被编译器和处理器重排序。

A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。

06.Java内存模型 - 图25 06.Java内存模型 - 图26

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、runtime和处理器可以让我们感觉到:单线程程序看起来是按程序的顺序来执行的。asif-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。

控制依赖性

06.Java内存模型 - 图27

上述代码中,flag变量是个标记,用来标识变量a是否已被写入,在use方法中变量i的赋值依赖if (flag)的判断,这里就叫控制依赖,如果发生了重排序,结果就不对了。
考察代码,我们可以看见,

操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。操作3和操作4则存在所谓控制依赖关系。

在程序中,当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(Reorder Buffer,ROB)的硬件缓存中。当操作3的条件判断为真时,就把该计算结果写入变量i中。猜测执行实质上对操作3和4做了重排序,问题在于这时候,a的值还没被线程A赋值。

在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因)。

但是对多线程来说就完全不同了:这里假设有两个线程A和B,A首先执行init ()方法,随后B线程接着执行use ()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入呢?答案是:不一定能看到。

让我们先来看看,当操作1和操作2重排序,操作3和操作4重排序时,可能会产生什么效果?操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还没有被线程A写入,这时就会发生错误!
所以在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

5.内存屏障


Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,从而让程序按我们预想的流程去执行。

1、保证特定操作的执行顺序。
2、影响某些数据(或则是某条指令的执行结果)的内存可见性。

编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。插入一条Memory Barrier会告诉编译器和CPU:不管什么指令都不能和这条Memory Barrier指令重排序。

Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

JMM把内存屏障指令分为4类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1的数据的装载先于Load2及所有后续装载指令的装载
StoreStoreBarriers Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储
LoadStore Barriers Load1;LoadStore;Store2 确保Load1的数据的装载先于Store2及所有后续存储指令的存储
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1的数据对其他处理器可见(刷新到内存)先 于Load2及所有后续的装载指令的装载.StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令


LoadLoad屏障:

抽象场景:Load1; LoadLoad; Load2

Load1 和 Load2 代表两条读取指令。在Load2要读取的数据被访问前,保证Load1要读取的数据被读取完毕。

StoreStore屏障:

抽象场景:Store1; StoreStore; Store2

Store1 和 Store2代表两条写入指令。在Store2写入执行前,保证Store1的写入操作对其它处理器可见

LoadStore屏障:

抽象场景:Load1; LoadStore; Store2

在Store2被写入前,保证Load1要读取的数据被读取完毕。

StoreLoad屏障:

抽象场景:Store1; StoreLoad; Load2

在Load2读取操作执行前,保证Store1的写入对所有处理器可见。StoreLoad屏障的开销是四种屏障中最大的。

StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他3个屏障的效果。现代的多处理器大多支持该屏障(其他类型的屏障不一定被所有处理器支持)。

6.临界区


临界区就是锁, 获取锁就是代码进入了临界区,释放锁就是退出了临界区.

06.Java内存模型 - 图28

JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得多线程在这两个时间点按某种顺序执行。

临界区内的代码则可以重排序(但JMM不允许临界区内的代码逃脱出临界区范围,那样会破坏监视器的语义)。

虽然线程A在临界区内做了重排序,但由于监视器互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

回想一下,为啥线程安全的单例模式中一般的双重检查不能保证真正的线程安全?
因为临界区内进行了重排序的操作.