Java内存模型总结

Java内存模型

内存模型可以理解为在特定的操作协议下,对特定的内存或者高速缓存进行读写访问的过程抽象,不同架构下的物理机拥有不一样的内存模型,Java虚拟机也有自己的内存模型,即Java内存模型(Java Memory Model, JMM)

在C/C++语言中直接使用物理硬件和操作系统内存模型,导致不同平台下并发访问出错。而JMM的出现,能够屏蔽掉各种硬件和操作系统的内存访问差异,实现平台一致性,是的Java程序能够“一次编写,到处运行”。

一、主内存和工作内存

Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样底层细节此处的变量指包括了实例字段、静态字段和构成数组对象的元素,但是不包括局部变量与方法参数,后者是线程私有的,不会被共享。

Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面讲的处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝,线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同线程之间无法直接访问对方工作内存中的变量,线程间变量值的传递均需要在主内存来完成。

线程、主内存和工作内存的交互关系如下图所示。

注意:这里的主内存、工作内存与Java内存区域的Java堆、栈、方法区不是同一层次内存划分,这两者基本上没有关系。

二、内存交互操作

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

·        lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。

·        unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。

·        read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用

·        load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。

·        use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。

·        assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。

·        store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。

·        write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

 

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

·        不允许readloadstorewrite操作之一单独出现

·        不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中。

·        不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中。

·        一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量。即就是对一个变量实施usestore操作之前,必须先执行过了assignload操作。

·        一个变量在同一时刻只允许一条线程对其进行lock操作,lockunlock必须成对出现

·        如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前需要重新执行loadassign操作初始化变量的值

·        如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量。

·        对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行storewrite操作)。

8种内存访问操作很繁琐,后文会使用一个等效判断原则,即先行发生(happens-before)原则来确定一个内存访问在并发环境下是否安全。

三、内存模型三大特性

3.1原子性

JMM要求lock、unlock、read、load、assign、use、store、write这8个操作都必须具有原子性,但对于64位的数据类型(long和double,具有非原子协定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为2次32位操作进行。(与此类似的是,在栈帧结构的局部变量表中,long和double类型的局部变量可以使用2个能存储32位变量的变量槽(Variable Slot)来存储的,

如果多个线程共享一个没有声明为volatile的long或double变量,并且同时读取和修改,某些线程可能会读取到一个既非原值,也不是其他线程修改值的代表了“半个变量”的数值。不过这种情况十分罕见。因为非原子协议换句话说,同样允许long和double的读写操作实现为原子操作,并且目前绝大多数的虚拟机都是这样做的。

3.2可见性

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

3.3有序性

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

as-if-serial语义(语义)

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

as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器,runtime 和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题

 

重排序

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

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

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

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

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以及所有后续装载指令

·        StoreStoreBarriers:确保Store1的数据对其他处理器可见(会使缓存行无效,并刷新到内存中)先于Store2及所有后续存储指令的装载

·        LoadStoreBarriers:确保Load1数据装载先于Store2及所有后续存储指令刷新到内存

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

数据依赖性

数据依赖的准确定义是:如果两个操作同时访问一个变量,其中一个操作是写操作,此时这两个操作就构成了数据依赖。

常见的具有这个特性的如i++、i—。如果改变了具有数据依赖的两个操作的执行顺序,那么最后的执行结果就会被改变。这也是不能进行重排序的原因。例如:

·        写后读:a = 1; b = a;

·        写后写:a = 1; a = 2;

·        读后写:a = b; b = 1;

重排序遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。但是这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

对于final域,编译器和处理器要遵守两个重排序规则。
1
)在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用
变量,这两个操作之间不能重排序。
2
)初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

 

四、先行发生原则(happens-before)

 

我们编写的程序都要经过优化后(编译器和处理器会对我们的程序进行优化以提高运行效率)才会被运行,优化分为很多种,其中有一种优化叫做重排序,重排序需要遵守happens-before规则,不能说你想怎么排就怎么排,如果那样岂不是乱了套。

 

如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。

 

 

happens-before原则定义如下:

1. 如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。 
2.
两个操作之间存在happens-before关系,并不意味着一定要按照happens-before原则制定的顺序来执行。如果重排序之后的执行结果与按照happens-before关系来执行的结果一致,那么这种重排序并不非法。

 

与程序员密切相关的默认happens-before原则规则如下:

·        程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。

·        监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。

·        volatile变量规则:对一个volatile域的写,happens-before 于任意后续对这个volatile域的读。

·        传递性:如果A happens- before B,且B happens-before C,那么A happens- before C

happen-before原则是JMM中非常重要的原则,它是判断数据是否存在竞争、线程是否安全的主要依据,保证了多线程环境下的可见性。是JMM提供给程序员的表明视图和保证。

 

 

五、解析volatile

 

volatile变量规则

Synchronized是一个比较重量级的操作,对系统的性能有比较大的影响,而volatile Java中提供的更轻量级的同步机制,只保证可见性,不能保证原子性。

volatile变量具有2种特性:

·        保证变量的可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入,这个新值对于其他线程来说是立即可见的。

·        屏蔽指令重排序:指令重排序是编译器和处理器为了高效对程序进行优化的手段,下文有详细的分析。

 

volatile语义并不能保证变量的原子性。对任意单个volatile变量的读/写具有原子性,但类似于i++、i–这种复合操作不具有原子性

只有满足下面2条规则时,才能使用volatile来保证并发安全,否则就需要加锁

·        运算结果不依赖当前变量值,或者只有单一的线程修改变量的值(比如计数器的情况)

·        变量不需要与其他的状态变量共同参与不变约束

如:volatile static int start = 3;

volatile static int end = 6;

线程A执行如下代码:

while (start < end){

//do something

}

线程B执行如下代码:

start+=3;

end+=3;

这种情况下,一旦在线程A的循环中执行了线程Bstart有可能先更新成6,造成了一瞬间 start == end,从而跳出while循环的可能性。

 

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

 

 

 

 

原理:

1.      可见性实现:

线程本身并不直接与主内存进行数据的交互,而是通过线程的工作内存来完成相应的操作。这也是导致线程间数据不可见的本质原因。因此要实现volatile变量的可见性,直接从这方面入手即可。对volatile变量的写操作与普通变量的主要区别有两点:

1volatile写的内存语义如下。
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存

2volatile读的内存语义如下。
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

通过这两个操作,就可以解决volatile变量的可见性问题。

 

2.      有序性实现:

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。

 

Volatile语义的增强:

在旧的内存模型中,当Avolatile的操作)和B(非volatile的操作)之间没有数据依赖关系时,AB之间就可能被重排序。增强后没有数据依赖关系也不能重排。

 

 

参考 

1、http://blog.csdn.net/u011080472/article/details/51337422
2、周志明,深入理解Java虚拟机:JVM高级特性与最佳实践,机械工业出版社 
3、AlphaWang博客,http://blog.csdn.net/vking_wang/article/details/8574376

版权声明:本文为博主原创文章,转载请注明作者和出处。

 

posted @ 2018-03-16 12:44  词汇族  阅读(274)  评论(0编辑  收藏  举报