【java多线程系列】java内存模型与指令重排序

在多线程编程中,需要处理两个最核心的问题,线程之间如何通信及线程之间如何同步,线程之间通信指的是线程之间通过何种机制交换信息,同步指的是如何控制不同线程之间操作发生的相对顺序。很多读者可能会说这还不简单,java中的同步采用的是锁机制或volatile来完成的,的确,在应用层,java中的同步的确是通过加锁来完成的,但是锁机制是如何实现的呢?这就涉及到java中的内存模型的相关知识。本博客将带领大家了解java内存模型的相关知识。


如果读者觉得本博客写的不错,记得小手一抖,点个赞哦!另外欢迎大家关注我的博客账号哦,将会不定期的为大家分享技术干货,福利多多哦!


我们知道java中多线程通信采用的是共享内存模型,即多个线程之间共享某块内存,通过写-读内存中的公共状态进行隐式通信,整个通信过程对于程序员完全透明,因此理解java内存模型将帮助我们理解这种隐式通信的原理,从而更好的写出java多线程程序。


一java内存模型的抽象结构:

我们知道在java中,对象实例域,静态域和数组元素存储在堆内存中,堆内存在线程之间共享,我们称对象实例域,静态域和数组元素为共享变量,而局部变量,方法定义的参数和异常处理器参数不会在线程之间共享,它们不存在内存可见性的问题,因此不受java内存模型的影响。

java线程之间的通信受java内存模型(Java Memory Model,简称JMM)的控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见,从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(Main Memory)中,而每个线程各自拥有属于自己的本地内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。注意本地内存是一个抽象概念,在物理设备上不存在,它通常包含缓存,写缓冲区,寄存器以及其他的硬件和编译器优化等。java内存模型的抽象示意图如下:


从图可知,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

1首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
2然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

即java线程之间的通信必须经过主内存,JMM通过通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。


二指令序列的重排序:

前面说过每个线程拥有自己的本地内存(一个抽象的概念,多个物理设备内存的抽象),其中一种就是硬件和编译器优化。在执行程序时,为了提高性能,编译器和处理器通常会对指令做重排序,之所以把这个拿出来讲,是因为我们知道CPU将按照指令序列执行指令,如果指令被重排序,那么对线程的读写会产生影响,这就会影响我们前面提到的java内存模型。所以接下来就介绍一下重排序,重排序包括3种类型

1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对          应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行

其中第一种很好理解,这是保证程序顺序执行最基本的原则,第二条中的如果数据不存在依赖关系这点给大家解释一下,示例代码如下:

int x=1;
int y=x+1;
int z=1;

因为第二行语句中y=x+1,即y的结果依赖于x的值,那么y与x存在依赖关系,而z与x与y不存在依赖关系,所以在指令重排序后x必须始终在y的前面出现,而z与x与y之间的关系可以乱序,即重排序后结果可以为:

int z=1;
int x=1;
int y=x+1;
但不能为:

int z=1;
int y=x+1;
int x=1;//x赋值必须在y之前
上述3种重排序可能会导致多线程程序出现内存可见性问题,对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序,对于处理器,JMM的处理器重排序规则会要求java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers,intel称之为memory fence)指令,通过内存屏障指令来禁止特定类型的处理器重排序



三java内存模型内存屏障指令

前面说过,常见的处理器都会对程序指令进行重排序,而这在多线程中很可能导致内存可见性问题,而java内存模型确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。这也是java内存模型根本作用。而禁止重排序的方法就是插入内存屏障指令,为了更好的理解为何需要禁止重排序,我们先来看一个例子:


假设处理器A和处理器B按程序的顺序并行执行内存访问,最终却可能得到x = y = 0的结果。具体的原因如下图所示:


这里对这个图稍作一下解释,因为写缓冲区仅对自己的处理器可见,所以虽然处理器A已经在缓冲区A中更新了a的值,但是处理器B不能感知到,因此处理器B从内存中读取a的值赋给y时,如果此时处理器A还未将a的值刷新到内存中,那么此时内存中a的值仍然为0,这样y的值就为0,同理x的值可能为0,而这显然不是我们所期望的结果,


之所以出现上述结果是因为现代的处理器都会使用写缓冲区来临时保存向内存写入的数据,这相信大家在计算机组成原理这么课中都学过,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特性会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致

我们知道对内存的操作包括读-写两种,那么多线程访问同一个共享变量则两两组合共四种情况,现代常见处理器的重排序对这四种组合允许情况如下所示:


上图中“N”表示处理器不允许两个操作重排序,“Y”表示允许重排序。我们可以看出:常见的处理器都允许Store-Load重排序;常见的处理器都不允许对存在数据依赖的操作做重排序。(注意上图所说的x86包括x64及AMD64。)

与上图对应java内存模型定义了四种禁止重排序的四种指令屏障,如下图所示:


java内存模型通过这四种内存屏障指令来保证了前面我们所举的例子的情况不会出现,仍然以上述例子来说明,Java内存模型通过在适当位置插入内存屏障指令,如StoreLoad Barriers指令,则可以保证Store1数据对其他处理器是可见的(即将缓存中的内容刷新到内存),这样在处理器A将a的值=1写入缓冲区A后将及时保证处理器B在从内存中读取a的值之前会将处理器A缓存中的值刷新到内存中。从而保证内存可见性。


注意:StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。


以上就是本博客的主要内容,java内存模型主要解决多线程程序中的内存可见性问题,该内容是理解java多线程编程的理论基础。



如果读者觉得本博客写的不错,记得小手一抖,点个赞哦!另外欢迎大家关注我的博客账号哦,将会不定期的为大家分享技术干货,福利多多哦!



版权声明:本文为htq__原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接和本声明。