遇到一条面试题“简述Java内存模型”,今天了解一下java内存模型。
参考自:《深入理解Java虚拟机》第三版 周志明著(有需要此书的pdf版可私信或评论回复)
此笔记仅供自己参考,如有错误请指正
Java内存模型涉及某些硬件知识以及并发知识(多线程知识),在此先给出一些需要提前掌握好的知识点,方便后面阐述JMM。
并发是同一时间段内,多个线程运行起来;并行是同一时刻,多个线程运行起来。如下图所示:
因为CPU的运算速度远远高于内存的速度,因此为了弥补这一数量级的差异,我们在CPU和内存之间引入了一个高速缓存(Cache)。这样每个CPU从内存中读取运算需要用到的数据到高速缓存中,运算结束后,再将结果同步回到内存中。如下图:
虽然高速缓存解决了CPU与内存的运算速度差异,但是却引入了一个新的问题:缓存一致性(Cache Coherence)。由于每个CPU都有自己的高速缓存,那么假如多个CPU的运算任务都涉及内存里面同一块区域,各自读取数据到各自的高速缓存中,那么此时运算完,这几个高速缓存的运算结果可能会不同,那么将运算结果同步回内存时,到底以哪个高速缓存的结果为标准呢?
为了解决缓存一致性问题,设计者们在Cache和内存之间设计了一个缓存协议。在读写操作时,需要根据协议的具体内容去进行读写数据。 缓存一致性协议有Intel提出的优秀实现,详情见MESI协议。如下图:
总结: 缓存一致性协议,与Java内存模型里面的某些解决方案很相似。后面可以类比学习
为了最大利用CPU内的运算单元,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)。其内容是指乱序执行时,输入代码的顺序与执行的顺序可能并不一致。
Java虚拟机的JIT即时编译器也有相似的指令重排序(Instruction Reorder) 优化
Java虚拟机规范试图定义一种 Java内存模型(Java Memory Model,JMM) 来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存到内存中以及从内存中取出变量 这样的底层细节。(此处的变量是指实例字段、静态字段和构成数组对象的元素,但是不包括局部变量以及方法参数,因为这是线程私有的,并不是共享的)。
总结:Java内存模型定义一套规则从内存取出共享数据或将共享数据存到内存中。
JMM规定所有变量都存在主内存中(Main Memory),此主内存只是虚拟机内存的一部分而已。
每个线程都有自己的工作内存,工作内存中保存了该线程需要用到的变量的主内存副本拷贝。线程对变量的所有操作(读取,赋值等)都在工作内存中进行,而不能直接读写主内存中的变量。
不同线程之间无法访问对方的工作内存中的变量,唯有通过主内存充当中介角色来完成线程间的值传递
本节讲述上图中的工作内存与主内存之间的交互协议,即从主内存拷贝变量到工作内存、从工作内存同步回主内存的实现细节。
lock(锁定): 作用于主内存的变量,把一个变量标识为一条线程独占的状态
read(读取): 作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用
load(载入): 作用于工作内存的变量,把read操作从主内存中得到的值放入工作内存的变量副本中
use(使用): 作用于工作内存的变量,把工作变量的值传递给执行引擎。每当虚拟机遇到需要用到变量的值的字节码指令时将会执行此操作
assign(赋值): 作用于工作内存的变量,把从执行引擎中收到的值赋值给工作内存中的变量。每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store(存储): 作用于工作内存的变量,把工作内存中变量的值传递到主内存中,以便后面的write操作使用
write(写入): 作用于主内存的变量,把从工作内存得到的变量的值存入主内存的变量中
unlock(解锁): 作用于主内存的变量,把一个处于锁状态的变量释放出来,释放后的变量才可以被其他线程锁定
注意:如果要把一个变量从主内存赋值到工作内存,那就要按顺序执行read、load操作;同理如果要把变量同步回到主内存中,那就要按顺序执行store、write操作。只要求按顺序执行,并不要求连续地执行,如readA->readB->loadB->loadA
,是允许的。
JMM还规定执行上述操作时还应满足下述8个规则:
不允许read和load、store和write单一出现。即不允许从主内存读进来一个变量但工作内存却不接受它,或者不允许从工作内存发起回写但主内存却不接受
不允许线程丢弃它最近的assign操作。即变量在工作内存中改变了之后必须把该变化回写到主内存。
不允许一个线程无原因地(没有发生任何assign操作)把数据从工作内存同步回主内存
一个变量只能在主内存诞生,不允许在工作内存中直接使用一个未被初始化(load和assign)的变量。即发生use和store操作之前必须执行load和assign操作。
一个变量在同一时刻只允许一条线程对其进行lock操作。但是lock操作能被同一线程执行多次。多次执行lock后,只有执行相同次数的unlock,变量才会被解锁
如果对一个变量进行lock操作,将会清空工作内存此变量的值。在执行引擎使用此变量之前,需要重新执行load或assign操作初始化变量的值。
如果一个变量事先没有被lock锁定,则不允许对他执行unlcok操作;也不允许去unlock被其他线程锁住的对象。
对一个对象执行unlock之前,必须先把此变量同步回主内存(执行store和write操作)
关键字volatile可以说是最轻量级的同步机制,JVM对volatile定义了一些特殊的访问规则。
一个变量被volatile定义之后,会有2种特性: 对所有线程的可见性;禁止指令重排序优化
保证此变量对所有线程的可见性。可见性:意思是一个线程对此变量做了修改,新值对其他线程来说是可以立即得知的。很多人错误地认为”基于volatile变量的运算在并发下是安全的“,正确答案是不安全的,因为Java里面的运算并非是原子操作。
如下的例子证明非原子操作:
开启20个线程,每个线程运行10000次加1操作,理想的答案是20 * 10000 = 200000
,但实际得到的结果是小于200000
发生小于200000的原因:
总结:由于volatile只保证可见性,不保证原子性,所以不符合以下2条规则的运算场景中,我们仍需要通过加锁(synchronized或使用java.util.concurrent包下的原子类)来保证原子性。
规则1: 运算结果不依赖变量的当前值,或能确保只有单一线程修改当前的值。
规则2: 变量不需要与其他状态变量共同参与不变约束
普通变量仅仅保证程序运行过程中依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的顺序一致。
以下是例子:
use和load必须是连续的。此规则要求工作内存中,每次使用volatile变量前必须从主内存刷新最新的值,用于保证能看见其他线程对volatile变量修改后的值
assign和store必须是连续的。此规则要求工作内存中,每次修改volatile变量后都必须同步回主内存。用于保证其他线程可以看到自己对volatile变量的修改
volatile变量不会被指令重排序优化。保证代码的执行顺序与程序的顺序相同。
JVM要求 lock、read、load、use、assign、store、write、unlock是原子性操作 。而对于是64位的数据类型(long、double),jvm允许没有被volatile修饰的64位数据的读写操作(即read、load、store、write)划分为2次32位的操作来进行,即jvm不保证64位的read、load、store、write操作的原子性。
但在实际开发中,商用虚拟机几乎把64位数据的读写操作作为原子性操作来对待,所以一般不需要将long、double变量专门声明为volatile。
JMM围绕着并发过程中如何处理原子性、可见性和有序性这3个特征来建立的。我们来看哪些操作实现了这3个特性。
原子性(Atomicity)。由JMM直接保证的原子性变量操作包括read、load、use、assign、store、write,我们大致可以认为基本数据的 访问读写是具备原子性 的。如果应用场景需要更大范围的原子性保证,JMM还提供了lock和unlock,虽然这2个操作不直接开放给用户使用,但是jvm却提供了更高层次的字节码monitorenter和monitorexit来隐式使用这2个操作。这2个字节码反映到代码中就是同步块——synchronized关键字,因此synchronized块之间的操作也具备原子性。
可见性(Visibility)。JMM通过2条内存交互规则(不允许丢弃最近的assign;一个变量只能从主内存诞生)依赖主内存作为传递媒介来实现可见性,无论是普通变量还是volatile变量都是如此。普通变量和volatile变量的区别是:volatile的特殊规则保证了新值能立即同步回主内存,以及每次使用前立即从主内存刷新。因此volatile保证了多线程操作时变量的可见性。而普通变量不能保证这一点。 除了volatile,java还有synchronized关键字和final关键字实现可见性。同步块(synchronized)是由“对一个变量unlock之前必须把此变量同步回主内存中(执行store、write)”这条规则获得的。final的可见性:被final修饰的字段一旦被构造器初始化完并且构造器没有把this引用传递出去(即其他线程不可能通过这个引用访问到初始化一半的对象),那么其他线程可以看见final字段的值。
有序性(Ordering)。volatile和synchronized保证线程之间操作的有序性。volatile本身禁止指令重排序。synchronized则由“一个变量同一时刻只允许一条线程对其进行lock操作”这个规则获得的,此规则决定了持有同一个锁的2个同步块只能串行地进入。
如果JMM中所有的有序性都只靠volatile和synchronized,那么有一些操作会变得很啰嗦。其实我们在编程过程并没察觉到啰嗦,是因为Java有一套 “先行发生”(happen-before)原则。这个原则是判断数据是否存在竞争,线程是否安全的主要依据。通过这个原则,我们可以解决并发环境下两个操作之间是否存在冲突的问题。
以下8个天然的先行发生关系,如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对他们进行重排序。
程序次序规则(Program Order Rule)。在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。(准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构)
管程锁定规则(Monitor Lock Rule)。一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,“后面”是指时间上的先后顺序。
volatile变量规则(Volatile Variable Rule)。对一个变量的写操作先行发生于后面对这个变量的读操作。“后面”是指时间上的先后顺序。
线程启动规则(Thread Start Rule)。Thread对象的start()方法先行发生于此线程的每一个动作。
线程终止规则(Thread Termination Rule)。线程中所有的操作先行发生于对此线程的终止检测。我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
线程中断规则(Thread Interruption Rule)。对线程interrupt()方法先行发生于被中断线程的代码检测到中断事件的发生。可通过Thread.intterrupted()方法检测到是否有中断发生。
对象终结规则(Finalizer Rule)。一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
传递性(Transitivity)。如果A操作先行发生于B操作,B操作先行发生于C操作,那么A操作先行发生于C操作。
总结:时间上的先后顺序与先行发生原则之间基本没有太大的关系。所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。