JMM基础
内存模型:在特定的操作协议(应该时指缓存一致性协议把)下,对特定内存或高速缓存进行读写访问地过程抽象
Java内存模型(JMM)试图屏蔽各种硬件和OS间的内存访问差异,以实现Java程序在各种平台下都能达到一致的内存访问效果。
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性而建立的
JMM
JMM规范了java虚拟机与计算机内存如何协同工作,规定了一个线程如何和何时能看到其他线程修改过的共享变量的值,以及在必须时如何同步地访问共享变量
在JMM中,堆区域是线程间会共享的数据(实例字段、静态字段、构成数组对象的元素等),Java内存模型的主要目标时定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。
并且为了获得较好的执行性能,JMM没有限制执行引擎使用CPU的特定寄存器或缓存来和主内存进行交互,也没有限制即时编译器进行调整代码执行顺序这类优化措施
内存模型
JMM当中,所有变量都必须存储在主内存当中,每条线程还有自己的工作内存,线程中的工作内存保存了被该线程使用到的变量的主内存副本拷贝。当线程中对对象的引用,引用了在堆上的对象,调用了对象的方法,访问了对象的数据,这个时候他们拥有的是对象成员变量的私有拷贝。
而线程对变量的所有修改都必须在工作内存中进行,不能直接读写主内存中的变量,不同的线程间也无法直接访问对方工作内存中的变量,即线程间的变量值传递均需要通过主内存来完成。
Java内存区域的堆栈
工作内存、主内存与Java堆栈不是一个层次的内存划分,即它们没有直接关联。
若非要对应的话,则主内存主要对应与Java堆内的对象实例数据部分,而工作内存主要对应虚拟机栈内的部分区域。
CPU寄存器CPU registers,访问缓存的速度最快,其次是高速缓存。它们都属于线程的本地内存
内存间交互操作
关于主内存与工作内存间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节。
Java内存模型定义了8种操作,并且虚拟机实现时必须保证每一种操作都是原子的,不可再分的(long和double的load、store、read、write在某些平台上允许例外)
- lock锁定:作用于主内存的变量,把一个变量标识为一条线程独占状态
- unlock解锁:作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
- read读取:作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后load动作使用
- load载入:作用于工作内存的变量,把read操作从主内存得到的变量值放入工作内存的变量副本中
- use使用:作用于工作内存的变量,把工作内存的一个变量值传递给执行引擎(线程)
- assign赋值:作用于工作内存的变量,把一个从执行引擎接收到的值赋值给工作内存的变量
- store存储:作用于工作内存的变量,把工作内存的一个变量的值传递到主内存中,以便随后的write操作
- write写入:作用于主内存的变量,把store操作从工作内存中一个变量的值传送到主内存的变量中
而具体操作间的顺序是有一定的规则的,较为简单的即happens-before原则判断
同步规则
- 不允许read、load与store、write操作单一出现,但是不必连续执行,中间可以插入其他指令
- 不允许一个线程丢弃掉它最近的assign操作,必须将变化同步给主内存
- 不允许一个线程无原因地(没有assign操作)将数据同步给主内存。
- 一个新的变量只能从主内存中诞生,不允许在工作内存直接使用一个未初始化(load或assign)的变量,即在use与store前,必须先assign
- 一个变量在同一时刻只允许一个线程进行lock,lock可以被同一线程执行多次。
- 如果一个变量执行了lock操作,将清空工作内存中次变量的值,在执行引擎使用这个变量前需要重新load或assign操作初始化变量
- 如果一个变量没有lock,则不允许unlock操作,也不能unlock其他线程lock的变量
- 对变量unlock前,必须先将变量同步到主内存
long和double型变量的特殊规则
对于64位数据类型,允许虚拟机将没有被volatile修饰的64位数据的读写操作划分位两次32位的操作来进行,允许虚拟机实现选择可以不保证64位数据类型的load、store、read、write这4个操作的原子性。即long和double的非原子性协定
如果多个线程共享一个未声明为volatile的long或double类型的变量并且同时进行读取和修改,那么线程可能读到一个既非原值也非其他线程修改值的代表了半个变量的数值(商用JVM不会出现,因为虚拟机选择将其实现了原子操作)。
happens-before
JDK5开始,Java使用新的JSR-133内存模型,JSR-133使用happens-before的概念来阐述操作之间的内存可见性
如果两个操作的执行次序无法从happens before推导出来,则JVM可以对它进行随意的重排序。
概述
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
happens-before规则
- 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作
- 虽然由于指令重排序,即使先行发生也可能再时间上后发生,但是在一个线程中无法感知到这一点,因此并没有关系
- 衡量并发安全问题时不要受到时间顺序的干扰,一切以先行发生原则为准。
- 锁定操作:一个unlock操作先行发生于后面对同一个锁的lock操作
- volatile变量规则:对一个变量的写操作先行发生于后面对于这个变量的读操作
- 传递规则;如果操作A先行发生于操作B,而操作B又先行发生于操作C,则操作A先行发生于操作C
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
- 线程终结规则:线程中所有的操作都先行发生于线程的终止检测
- 对象终结规则:一个对象的初始化完成先行发生于它的finalize()方法的开始
优点
happens-before规则避免Java程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这些规则的具体实现方法
JMM的抽象结构
- Java中,所有实例域、静态域和数组元素都存放在堆内存中,堆内存在线程间共享。即共享变量
- 局部变量和异常处理器参数不会在线程间共享,不会有内存可见性问题,不受内存模型影响。
Java线程间通信由JMM控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。JMM定义了线程和主内存间的抽象关系。
JMM通过控制主内存与每个线程的本地内存间的交互,为程序员提供内存可见性保证。
CPU
从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3种类型。
- 编译器重排序:编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
- 处理器重排序:
- 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-LevelParallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
- 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
对于Java源代码到最终执行的指令序列,会经历三种重排序,可能导致内存可见性问题:
JMM属于语言级别的内存模型,确保在不同的编译器和处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,保证一致性的内存可见性。
- 对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序
- 对于处理器重排序。JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,禁止特定类型的处理器重排序
并发编程模型的分类
现代处理器的写缓冲区:
- 临时保存向内存写入的数据。
- 优点:保证指令流水线的持续运行,避免由于处理器停顿下来等待向内存写入数据而产生的延迟。以批处理方式刷新写缓冲区,合并对同一内存地址的多次写,减少对内存总线的占用
- 缺点:写缓冲区只对其所在处理器可见。对内存操作的执行顺序产生重要影响。导致处理器对内存的读、写操作的执行顺序,不一定与内存实际发生的读、写顺序一致
示例:在并行执行下
现代处理器都会允许对写-读操作重排序
内存屏障指令
对于只有一个CPU访问内存时,并不需要内存屏障。但是如果有两个或更多个CPU访问同一块内存,并且其中有一个正在观测另一个,就需要内存屏障保证一致性。
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序
- StoreLoad Barriers
- 全能型屏障,具有其他3个屏障的效果。现代处理器大多数支持该屏障
- 开销很高,要把写缓冲区的数据全部刷新到内存中
三个特性
原子性
数据类型级别:Java内存模型直接保证的原子性变量操作包括read、load、assign、use、store、write。大致可以认为基本数据类型的访问读写时具备原子性的。
synchronized:若应用场景需要一个更大范围的原子性保证,则提供了lock和unlock来满足这种需求,虽然虚拟机未将lock、unlock操作开放给用户使用,但提供了更高层次的字节码指令monitorenter
和monitorexit
来隐式使用这两个操作,而反映到Java代码中即synchronized
,即synchronized
块间操作具备原子性
可见性
可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。
导致共享变量在线程间不可见的原因
- 线程交叉执行
- 重排序结合线程交叉执行
- 共享变量更新后的值没有在工作内存与主内存间及时更新
在没有同步地情况下,编译器、处理器以及运行时等都可能对操作地执行顺序进行一些意想不到的调整。在缺乏足够同步的多线程程序中,要想对内存操作的执行顺序进行判断,几乎无法得到正确的结论。
JMM是提供在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式实现可见性的。
- volatile的特殊规则保证了新值能够立即同步到主内存,并且每次使用前立即从主内存刷新
- synchronized是由“对一个变量执行unlock操作前,必须先把此变量同步回主内存中”这条规则获得的
- final字段在构造器中一旦初始化完成,并且构造器没有将this引用传递出去(this引用逃逸可能让其他线程访问到初始化了一半的对象),那再其他线程中就能看到final字段的值。
示例
1 | public class NoVisibility{ |
由于线程交叉执行,在创建单例对象时可能创建两个对象
由于重排序,可能先执行了ready=true
导致此时依然number=0
,因此ReadThread输出0。
由于共享变量没有及时更新,ReadThread可能永远执行下去。
失效数据
失效数据时指当读线程查看ready变量时,可能得到一个已经失效的值。除非每次访问变量时都使用同步,否则很可能得到该变量的一个失效值。而且失效值可能不会同时出现:一个线程可能获得某个变量的最新值,而获得另一个变量的失效值。
非原子的64位操作
当线程在没有同步的情况下读取该变量时可能会得到一个失效值,但至少这个值时由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性
最低安全性适用于大多数变量,但是long和double不属于该类型,因此读取该变量时可能读取到某个值的高32位与另一个值的低32位。
加锁与可见性
加锁可以确保某个线程以一种可预测的方式来查看另一个线程的执行结果。在同步代码块M上调用unlock之前的所有操作结果,对于在M上调用lock之后的线程都是可见的。
即为什么要求在访问某个共享且可变的变量时要求所有线程在同一个锁上同步,是为了确保某个线程写入该变量的值对于其他线程来说都是可见的。
volatile
volatile是一种稍弱的同步机制,用来确保将变量的更新操作通知到其他线程,当变量声明为volatile后,编译器与运行时都会注意到这个变量是共享的,因此不会讲该变量上的操作与其他内存操作一起重排序。volatile变量不会被缓存在寄存器或其他处理器不可见的地方,因此读取volatile变量总会返回最新的值。
仅当volatile变量能够简化代码的实现以及对同步策略的验证时,才应该使用它们。如果在验证正确性时需要对可见性进行复杂的判断,那么就不要使用volatile变量。
volatile的正确使用方式:
- 确保它们自身状态的可见性。确保它所引用对象的状态的可见性
- 标识一些重要的程序生命周期事件的发生。
加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性
当且仅当满足以下所有条件时才应该使用volatile变量
- 对变量的写入操作不依赖变量的当前值,或者你能够确保只有单个线程更新变量的值
- 该变量不会与其他状态变量一起纳入不变性条件中
- 在访问变量时不需要加锁。
有序性
线程内表现为串行的语义:如果在本线程内观察,所有的操作都是有序的。
指令重排序与工作内存与主内存同步延迟:如果在一个线程中观察另一个线程,所有操作都是无序的。
Java提供volatile与synchronized保证线程间操作的有序性
- volatile本身禁止指令重排序的语义
- synchronized是由“一个变量在同一个时刻只允许一条线程对其进行lock操作”这条规则获得的。这决定了持有同一个锁的两个同步块只能串行进入。
顺序一致性
顺序一致性内存模型是一个理论参考模型,在设计的时候,处理器的内存模型和编程语言的内存模型都会以顺序一致性内存模型作为参照。
数据竞争与顺序一致性
当程序未正确同步时,就可能存在数据竞争。
数据竞争:在一个线程中写一个变量,在另一个线程中读一个编程,而且写和读没有通过同步来排序。
JMM对正确同步的多线程程序的内存一致性做了如下保证
如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。
顺序一致性内存模型
顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性。
- 一个线程中的所有操作必须按照程序的顺序来执行
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见
而在JMM当中是没有这个保证的
- 未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。
- 比如,在当前线程把写过的数据缓存在本地内存中,在没有刷新到主内存之前,这个写操作仅对当前线程可见;
- 从其他线程的角度来观察,会认为这个写操作根本没有被当前线程执行。
- 只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序将不一致。
同步程序的顺序一致性效果
示例代码:
1 | class SynchronizedExample{ |
根据JMM规范,该程序的执行结果将与程序在顺序一致性模型中的执行结果相同。
- JMM中,临界区内的代码可以重排序(但是临界区的代码不能逸出到临界区外,那样会破坏监视器的语义),JMM会在退出、进入临界区这两个时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图
- 虽然线程A在临界区做了重排序,但由于监视器互斥执行的特性。线程B无法观察到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。
未同步程序的执行特性
对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,Null,False),JMM保证线程读操作读取到的值不会无中生有(Out Of Thin Air)的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会对内存空间进行清零,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在已清零的内存空间(Pre-zeroed Memory)分配对象时,域的默认初始化已经完成了。