java内存模型JMM
-
现代CPU 架构的形成
-
硬件的效率与一致性
-
Java 内存模型(JMM)
- 主内存与工作内存
- 主内存与工作内存交互协议
- JMM对于volatile型变量的特殊规则
- JMM针对long和double型变量的特殊规则
- 原子性、可见性与有序性
- 先行发生原则
1、概述:现代CPU架构的形成
一切从冯洛伊曼计算机体系开始说起,下图就是经典的 冯洛伊曼体系结构,基本把计算机的组成模块都定义好了,现在的计算机都是以这个体系弄的,其中最核心的就是由运算器和控制器组成的中央处理器,就是我们常说的CPU。
刚才说到冯洛伊曼体系中的CPU,你应该听过摩尔定律吧!就是英特尔创始人戈登·摩尔讲的:
集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。
所以你看到我们电脑CPU 的性能越来越强劲,英特尔CPU 从Intel Core 一直到 Intel Core i7,前些年单核CPU 的晶体管数量确实符合摩尔定律,看下面这张图。
横轴为新CPU发明的年份,纵轴为可容纳晶体管的对数。所有的点近似成一条直线,这意味着晶体管数目随年份呈指数变化,大概每两年翻一番。
后来摩尔定律越来越撑不住了,但是更新换代的程序对电脑性能的期望和要求还在不断上涨,就出现了下面的剧情。
他为其Pentium 4新一代芯片取消上市而道歉, 近几年来,英特尔不断地在增加其处理器的运行速度。当前最快的一款,其速度已达3.4GHz,虽然强化处理器的运行速度,也增强了芯片运作效能,但速度提升却使得芯片的能源消耗量增加,并衍生出冷却芯片的问题。
因此,英特尔摒弃将心力集中在提升运行速度的做法,在未来几年,将其芯片转为以多模核心(multi-core)的方式设计等其他方式,来提升芯片的表现。多模核心的设计法是将多模核心置入单一芯片中。如此一来,这些核心芯片即能以较缓慢的速度运转,除了可减少运转消耗的能量,也能减少运转生成的热量。此外,集众核心芯片之力,可提供较单一核心芯片更大的处理能力。 —《经济学人》
当然上面贝瑞特当然只是在开玩笑,眼看摩尔定律撑不住了,后来怎么处理的呢?一颗CPU 不行,我们多来几颗嘛!这就是现在我们常见的多核CPU,四核8G 听着熟悉不熟悉?当然完全依据冯洛伊曼体系设计的计算机也是有缺陷的!
并发处理的广泛应用由阿姆达尔定律(Amdahl定律) 代替 摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力的最有力武器。
Amdahl定律通过系统中并行化与串行化的比重来描述多处理器系统能获得的运算加速能力,摩尔定律则用于描述处理器晶体管数量与运行效率之间的发展关系。这两个定律的更替代表了近年来硬件发展从追求处理器频率到追求多核心并行处理的发展过程。
2、 硬件的效率与一致性
“让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的因果关系,看起来理所当然,实际上它们之间的关系并没有想象的那么简单,其中一个重要的复杂性来源是绝大多数的运算任务都不可能只靠处理器“计算”就能完成。处理器至少要与内存交互,如读取运算数据、存储运算结果等,这个I/O操作就是很难消除的(无法依靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有着几个运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后,再从缓存中同步到内存中,这样处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好的解决了处理器与内存速度之间的矛盾,但是也给计算机带来了更高的复杂度,它引入一个新问题:缓存一致性(Cache Coherence)。
在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一个主内存(Main Memory),这种系统称为共享内存多核系统(Share Memory Multiprocessors System)。如下图所示,现在主流的多核CPU的硬件架构:
看个例子:
现代操作系统一般会有多级缓存(Cache Line),一般有L1、L2,甚至有L3,个人电脑缓存信息,一共4核,三级缓存,L1 缓存(在CPU核心内),这里L2 缓存后面括号标识了是每个核都有L2 缓存,而L3 缓存没有标识,是因为L3 缓存是4个核共享的缓存:
上面讲的为了解决硬件(cpu和内存)效率不一致问题,增加了高速缓存,来匹配CPU的高效率。
缓存的一致性
先通过一个简单的Demo了解下上面的理论如何运作:数据是怎么在主存、缓存、CPU寄存器之间流转的吗?
这个数据操作逻辑在单线程环境和多线程环境下有什么区别?
如上图所示,再多线程并发操作多个cpu的时候,由于cpu0执行i++操作之后,cpu1的高速缓存可能还是旧值,这样就会导致技术的结果错误了。,这个就是非常著名的缓存一致性问题。
说明:单核CPU 的多线程也会出现上面的线程不安全的问题,只是产生原因不是多核CPU缓存不一致的问题导致,而是CPU调度线程切换,多线程局部变量不同步引起的。
缓存不一致
那CPU 怎么解决缓存一致性问题呢?
早期的一些CPU 设计中,是通过锁总线(总线访问加Lock# 锁)的方式解决的。看下CPU 体系结构图,如下:
CPU内体系结构
因为CPU 都是通过总线来读取主存中的数据,因此对总线加Lock# 锁的话,其他CPU 访问主存就被阻塞了,这样防止了对共享变量的竞争。但是锁总线对CPU的性能损耗非常大,把多核CPU 并行的优势直接给干没了!
后面研究人员就搞出了一套协议:缓存一致性协议。协议的类型很多(MSI、MESI、MOSI、Synapse、Firefly),最常见的就是Intel 的MESI 协议。缓存一致性协议主要规范了CPU 读写主存、管理缓存数据的一系列规范,如下图所示。
缓存一致性协议
例如:MESI这部分内容可以只了解大概思想:MESI 协议的核心思想:
-
定义了缓存中的数据状态只有四种,MESI 是四种状态的首字母。
-
当CPU写数据时,如果写的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态;
-
当CPU读取共享变量时,发现自己缓存的该变量的缓存行是无效的,那么它就会从内存中重新读取。
MESI实现的缓存一致性协议,正是CPU0修改了数据,通知到CPU1的那套通知机制的一种规范,计算机厂商根据这套规范实现了这种通知机制,但是不同的厂商之间实现方式可能稍微不同。
缓存中数据都是以缓存行(Cache Line)为单位存储;MESI 各个状态描述如下表所示:
除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order-Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致,因此,如果存在一个计算任务依赖另一个计算任务的中间结果,那么其顺序性并能靠代码的先后顺序来保证。Java虚拟机的即时编译器中也有对指令重排序(Instruction Reorder)优化。
3、Java内存模型
JMM 全称 Java Memory Model
, 是 Java 中非常重要的一个概念,是Java 并发编程的核心和基础。JMM 是Java 定义的一套协议,用来屏蔽各种硬件和操作系统的内存访问差异,让Java 程序在各种平台都能有一致的运行效果。
定义Java内存模型并非一件容易的事情,这个模型必须定义得足够严谨,才能让Java的并发内存访问操作不会产生歧义;但是也必须定义得足够宽松,使得虚拟机的实现能有足够的自由空间去利用硬件的各种特性来获取更高的执行速度。直到JDK5发布后,java内存模型才终于成熟、完善起来。
3.1、工作内存和主内存
Java内存模型规定了所有的变量都存储在主内存中。每个线程还有自己的工作内存,它保存了被该线程使用到的变量的主内存副本拷贝。线程对这些变量的操作都在自己的工作内存中进行,不能直接操作主内存和其他工作内存中存储的变量或者变量副本。线程间的变量访问需通过主内存来完成。三者关系如下:
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
上面提到每个线程都有自己的工作内存,问个深入一点的问题,线程的工作内存在主存还是缓存中?JMM 中定义的每个线程私有的工作内存是抽象的规范,实际上工作内存和真实的CPU 内存架构如下所示,Java 内存模型和真实硬件内存架构是不同的:
JMM 是内存模型,是抽象的协议。与Java内存区域中的Java堆、栈、方法区等并不是在同一个层次的对内存的划分,这两者基本上是没有任何关系的。
如果两者一定要勉强对应起来,
那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
从更基础的层次上说,主内存直接对应物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问的是工作内存。
首先真实的内存架构是没有区分堆和栈的,这个Java 的JVM 来做的划分,另外线程私有的本地内存线程栈可能包括CPU 寄存器、缓存和主存。堆亦是如此!
具体讲讲JMM 内存模型规范吗?
前面已经讲了线程本地内存和物理真实内存之间的关系,说的详细些:
-
初始变量首先存储在主内存中;
-
线程操作变量需要从主内存拷贝到线程本地内存中;
-
线程的本地工作内存是一个抽象概念,包括了缓存、store buffer(后面会讲到)、寄存器等。
JMM
那JMM 模型中多线程如何通过共享变量通信呢?
线程间通信必须要经过主内存。
线程A与线程B之间要通信的话,必须要经历下面2个步骤:
1)线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2)线程B到主内存中去读取线程A之前已更新过的共享变量
3.2、java内存交互协议
关于主内存与工作内存之间具体的交互协议,java内存模型中定义了以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。
1、Lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
2、unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定
3、read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存变量副本中
4、load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
5、use(使用):作用于工作内存的变量,它把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作
6、assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
7、store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中以便随后的write操作使用
8、write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中
如果要把一个变量从主内存复制到工作内存,那就要顺序地执行read和load操作,
如果把变量从工作内存同步回主内存,就要顺序地执行store和write操作。
Java内存模型只要求上述两个操作必须按顺序执行,而没有保证连续执行。
我们编译一段Java code 看一下。
代码和字节码指令分别为:
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操作)。
(1)工作线程A操作该共享内存变量的时候,执行lock指令,主内存中的这个共享变量;同时告诉线程B这个共享变量我准备修改了,让它失效掉。
(2)B线程的变量副本失效之后,运行时候用到,需要到主内存重新读取(执行read、load操作放入工作内存);发现该主内存的变量被锁定了,读取失败;此时相当于线程A拥有该变量的独享操作
(3)线程A执行i++操作,经历上述说的(read、load、use、assign、store、write)指令之后;操作完成执行unlock释放锁定的这个内存变量
(4)线程B这个时候再去主内存读取的时候,发现未被锁定,就可以重新读取了
3.3、可见性
根据上面的JAVA内存模型可能导致数据不一致的例子:
比如线程A和线程B都执行x++操作(x的初始值是0),线程A执行完了之后将主内存的值更新为1。
但是线程B由于已经将x=0读取进入自己的工作内存了,不知道线程A将x更新为1了,所以还是使用x=0去进行++操作。
像这种,就是典型的可见性问题,就是线程A操作了数据,但是线程B不可见,感知不到。
无论是CPU缓存架构下还是JAVA内存模型都是有可见性的问题。
那如何解决上面的问题呢?先看看有序性
- 指令重排:是指由于JIT动态编译器、操作系统为了给提高程序的执行效率,可能会对按顺序书写好的指令进行重排,线程或者说CPU执行的时候不一定按照程序书写的顺序来执行。
- 原子性:是说某个操作是不可分割的、不可中断的
3.3、对于volatile型变量的特殊规则
关键字volatile是java虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整的理解。以至于有的程序员直接使用Synchronized来进行同步。
当一个变量定义为volatile之后,它将具备两种特性:
第一是保证此变量对所有线程的可见性:这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。而普通变量不能做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性。
1、运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值
2、变量不需要与其他的状态变量共同参与不变约束
使用volatile变量的第二个语义是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获得正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。
3.4、JMM针对long和double型变量的特殊规则
Java内存模型要求lock、unlock、read、load、assign、use、store、write这八种操作都具有原子性,但是对64位的数据类型(long和double),在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这四个操作的原子性,这就是所谓的“long和double的非原子协定”
如果有多个线程共享一个并未声明为volatile的long或者double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值。
为了避免这种风险,JDK9起,HotSpot增加一个实验性参数-XX:+AlwaysAtomicAccesses来约束虚拟机对所有数据类型进行原子性的访问。
在实际开发中,除非该数据有明确可知的线程竞争,否则我们在编写代码时一般不需要因为这个原因刻意把用到的long和double变量专门声明为volatile。
3.5、原子性、可见性与有序性
在回顾JMM的模型特征,可以看出JMM是围绕着在并发过程中如何处理原子性、可见性和有序性的这三个特性的实现展开的。
原子性:
1,原子性:要么都执行要么都不执行。
在数据库中,中间如果执行有问题可以回滚来保证所以操作是一个整体。。
而在并发中有两个意思:
1,代表了原子性的操作(几个步骤是一个原子)在线程执行过程中不会被中断,是一个整体。 2,原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作!
ps:原子性:在其执行过程中,不允许其他并行线程对该变量进行读取和写入的操作。 如果发生竞争,则其他线程必须等待。
补充:
1,线程中断:一段程序其实是很多条指令的集合,多线程的情况,每条线程被处理器分配一定执行时间来执行这些指令,所以执行并非连续的。
2、JMM的原子性
由于Java内存模型直接保证的原子性操作包括:read、load、assign、use、store和write这六个,我们可以认为第一种:基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子协定)。如果应用场景需要一个更大范围的原子性保证,第二种:java内存模型还提供了lock和unlock操作来满足需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用者两个操作。这两个字节码指令反映到java代码中就是同步块--synchronized关键字,因此在synchronized块之间的操作也具备原子性。第三种:原子性主要通过JUC Atomic***包实现,内部使用CAS 指令实现原子性,各个CPU架构有些区别。
synchronized关键字可以实现操作的原子性,其实质是:通过该关键字所包括的临界区(Critical Section)的排他性保证在任何一个时刻只有一个线程能够执行临界区中的代码,这使得临界区中的代码代表了一个原子操作。这一点,大家基本都很清楚。但是,synchronized关键字所起到的另一个作用——保证内存的可见性(Memory Visibility),也是我们值得回顾的地方。
原子性问题解决
原子性主要通过JUC Atomic***包实现,如下图所示,内部使用CAS 指令实现原子性,各个CPU架构有些区别。
看下面的2个例子:
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作。--JMM的原子性第一种
赋值或者return。比如"a = 1;"和 "return a;"这样的操作都具有原子性
非原子性操作:
类似"a += b" 或 “a++” 这样的操作不具有原子性,经过三个步骤:
(1)取出a和b (2)计算a+b (3)将计算结果写入内存
如果有两个线程t1,t2在进行这样的操作。t1在第二步做完之后还没来得及把数据写回内存就被线程调度器中断了,
于是t2开始执行,t2执行完毕后t1又把没有完成的第三步做完。这个时候就出现了错误,相当于t2的计算结果被无视掉了。
原子操作是不能被线程中断机制中断的操作,
一旦操作开始,则它一定在可能的切换到其他线程之前执行完毕。简而言之就是不能被中断的操作,如赋值或return。
1,1,对于读写除long和double之外的基本类型变量的简单操作,可以保证它们的原子性来操作内存,
因为JVM将long和double这样的64位的变量拆分成两个分离的32位来操作,这样很可能在一个读取和写入操作之间切换到其它线程,从而导致错误的结果。
1,2, 类似a+=2的操作不具备原子性,因为在JVM中这个操作需要三个步骤:
1)取出a 2)计算a+2 3)将计算结果写入内存
在上述步骤之间很可能线程调度器中断,转向另一个任务,这个任务可能修改这个域,造成结果错误,所以这个操作不是原子性的。
同样a++也不具备原子性。(注:在C++中以上这两种操作都是原子性的)
2,可见性:
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
如果线程t1与线程t2分别被安排在了不同的处理器上面,那么t1与t2对于变量A的修改时相互不可见,
如果t1给A赋值,然后t2又赋新值,那么t2的操作就将t1的操作覆盖掉了,这样会产生不可预料的结果。(假设有两个线程T1和T2分别被安排到了两个不同的CPU中(cpu1、cpu2),则T1和T2对变量a的修改互不可视,
如T1对a进行修改之后,只是对cpu1缓存中的a运行修改,没有立即被写入到主存中,因此当线程T2再对a进行操作时,操作的并不是被线程T1修改后的新值。此时线程T1和线程T2对于变量a是互不可视的)
可见性问题:如果对象obj 没有使用volatile 修饰,A 线程在将对象count读取到本地内存,从1修改为2,B 线程也把obj 读取到本地内存,因为A 线程的修改对B 线程不可见,这是从Java 内存模型层面看可见性问题(前面从物理内存结构分析的)。
那么总结一下,可见性问题首先可以通过volatile关键字修饰实现可见性,Java中还有2个关键字同样可以实现可见性,它们是synchronized和final。
- volatile修饰
- 同步块的可见性是由“对一个变量执行unlock操作之前,必须先把该变量同步回主内存中(执行store和write操作)”的这条规则获得的。
- final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到一个“初始化一半”的对象),那么在其他线程中就能看见final字段的值。
- Lock
如代码清单12-7所示,变量i与j都具备可见性,它们无须同步就能被其他线程正确访问。
示例:
public static final int i; public final int j; static { i=0; //省后续动作 } { //也可以选择在构造函数中初始化 j=0; //省后续动作 }
上面示例中,变量i和j都具备可见性,它们无需同步就能被其他线程正确访问。
所以,即使有些操作是原子性的,但是如果不具有可见性,那么多个处理器中备份的存在就会使原子性失去意义。
(ps:原子性应该只是读和计算,而没有写入主内存)
不可视性产生的问题:
多个线程对某个变量的操作互不可视,可能造成某些操作被覆盖,产生错误的结果。 如对于变量a,线程T1和线程T2都对其进行a++操作,T1操作a++后并没有及时地将结果写入到主存中去,而是继续执行其它对a的操作, 当T2再执行a++操作后,它并没有发现a的值已被线程T1修改,这样就由于a的值没有被及时更新而产生错误。
PS:
没有原子性产生的问题:当前线程执行中断。其他线程覆盖执行。 不可视性产生的问题 :(有原子性的情况)执行完变量的原子性的部分后,继续执行对变量相关的其他操作。其他线程不知道变量的变化,覆盖执行。
有序性(Ordering)
java内存模型的有序性在前面讲解volatile时讨论过,Java程序中天然的有序性可以总结为一句话:如果再本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延时现象”。
java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序语义,而synchronized则是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
介绍完并发中的三个重要特性,读者是否发现synchronized关键字在需要这3个特性时候都可以作为其中的一种解决方案?看起来很“万能”吧?的确,大部分并发控制可以通过synchronized来完成。但越是“万能”的并发控制,通常会伴随越大的性能影响,在虚拟机锁优化再细谈。
有序性问题:重排序发生的地方有很多,编译器优化、CPU 因为指令流水批处理而重排序、内存因为缓存以及store buffer 而显得乱序执行。如下图所示:
从源码到最终指向的指令的示意图
附一张带store buffer (写缓冲)的CPU 架构图,希望详细了解store buffer 可以看文章最后面的扩展阅读。
每个处理器上的Store Buffer(写缓冲区),仅仅对它所在的处理器可见。这会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序:
下图是各种CPU 架构允许的指令重排序的情况。
3.6、先行发生原则
Java语言中有一个“先行发生”(Happens-Before)的原则。这个原则很重要,它是判断数据是否存在的竞争,线程是否安全的非常有用的手段。依赖这条原则可以通过几条简单的规则一揽子解决并发环境下两个操作之间是否存在冲突的问题,而不需要陷入Java内存模型苦涩难懂的定义之中。
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。
2. 两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。
下面是JAVa内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任务同步器协助就已经存在,可以在编码中直接使用。
-
程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作;
-
锁定规则:一个unLock操作先行发生于后面对同一个锁额lock操作;
-
volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作;
-
传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C;
-
线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作;
-
线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生;
-
线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值手段检测到线程已经终止执行;
-
对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始;
如果两个操作之间的关系不在此列,并且无法从上面的规则中推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。
这8条原则摘自《深入理解Java虚拟机》。
这8条规则中,前4条规则是比较重要的,后4条规则都是显而易见的。
下面我们来解释一下前4条规则:
对于程序次序规则来说,我的理解就是一段程序代码的执行在单个线程中看起来是有序的。注意,虽然这条规则中提到“书写在前面的操作先行发生于书写在后面的操作”,这个应该是程序看起来执行的顺序是按照代码顺序执行的,因为虚拟机可能会对程序代码进行指令重排序。虽然进行重排序,但是最终执行的结果是与程序顺序执行的结果一致的,它只会对不存在数据依赖性的指令进行重排序。因此,在单个线程中,程序执行看起来是有序执行的,这一点要注意理解。事实上,这个规则是用来保证程序在单线程中执行结果的正确性,但无法保证程序在多线程中执行的正确性。
第二条规则也比较容易理解,也就是说无论在单线程中还是多线程中,同一个锁如果出于被锁定的状态,那么必须先对锁进行了释放操作,后面才能继续进行lock操作。
第三条规则是一条比较重要的规则,也是后文将要重点讲述的内容。直观地解释就是,如果一个线程先去写一个变量,然后一个线程去进行读取,那么写入操作肯定会先行发生于读操作。
第四条规则实际上就是体现happens-before原则具备传递性。
-
实现原理:上面说的happens-before原则保障可见性,禁止指令重排保证有序性,如何实现的呢?
Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序,保证共享变量操作的有序性。
内存屏障指令:写操作的会让线程本地的共享内存变量写完强制刷新到主存。读操作让本地线程变量无效,强制从主内存读取,保证了共享内存变量的可见性。
JVM中提供了四类内存屏障指令:
JSR-133 定义的相应的内存屏障,在第一步操作(列)和第二步操作(行)之间需要的内存屏障指令如下:
ava volatile 例子:
以下是区分各个CPU体系支持的内存屏障(也叫内存栅栏),由JVM 实现平台无关(volatile所有平台表现一致)
synchronized 也可以实现有序性和可见性,但是是通过锁让并发串行化实现有序,内存屏障实现可见。
-
一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
-
在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。