多线程中的Java内存模型, 重排序与happens-before

Java内存模型基础知识

JVM

如果你熟悉JVM,那么你对这张图绝不陌生。对于每一个线程来说,栈都是私有的,而堆是共有的。

也就是说,在栈中的变量(局部变量,方法定义参数,异常处理器参数)不会在线程间共享,也就不会有内存可见性的问题;

而所有实例域,静态域和数组元素都存储在堆中,这些变量是共享的,我们称为共享变量。

 

Java内存模型JMM

Java虚拟机规范定义了Java内存模型JMM,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。

JMM规范了Java虚拟机与计算机内存是如何协同工作的:规定了一个线程如何 以及 何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。

在JMM中:

(1)调用栈和本地变量存放在线程栈上,对象放在堆上。

(2)当一个本地变量是原始类型时,它在线程栈上;当它是指向一个对象的一个引用时,它仍存放在线程栈中,而被指向的对象本身存放在堆上。

(3)一个对象可能包含方法,这些方法可能包含本地变量,这些本地变量仍存放在线程栈上,而对象存放在堆上。

(4)一个对象的成员变量可能随着这个对象自身存放在堆上。不管这个成员变量是原始类型还是引用类型。

(5)存放在堆上的对象可以被所有持有对这个对象引用的线程访问。当一个线程可以访问一个对象时,它也可以访问这个对象的成员变量。如果两个线程同时调用同一个对象上的同一个方法,它们将会都访问这个对象的成员变量,但是每一个线程都拥有这个成员变量的私有拷贝。

 

硬件内存架构

现代计算机硬件架构如下图所示:

(1)多CPU:一个现代计算机通常由两个或者多个CPU,其中一些CPU有多核。

(2)CPU Registers寄存器:每个CPU都包含一系列的寄存器,CPU在寄存器上执行操作的速度远大于在主存上执行的速度。

(3)高速缓存:由于计算机的存储设备 与 处理器的运算速度之间有着几个数量级的差距,因此现代计算机系统都加入入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲。它的相关工作流程为:

CPU将运算需要使用到的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。

CPU访问缓存层的速度快于访问主存的速度,但通常比访问内部寄存器的速度还要慢一点。每个CPU可能有一个CPU缓存层,一些CPU还有多层缓存。

运作原理

当一个CPU需要读取主存时,它会将主存的部分读到CPU缓存中,甚至可能将缓存中的部分内容读到它的内部寄存器中,然后在寄存器中执行操作。当CPU需要将结果写回到主存中去时,它会将内部寄存器的值刷新到CPU缓存中,然后在某个时间点将值刷新回主存。

 

访问共享变量

现代计算机为了高效,往往会在高速缓存区中缓存共享变量,这是因为CPU访问缓存区要比访问内存快得多。

线程之间的共享变量放在主内存中,每个线程都有一个私有本地内存,存储着该线程读、写共享变量的副本。

私有本地内存包含上图的程序计数器、本地方法栈、虚拟机栈。

下图中的主内存包含堆和方法区。

当线程A要与线程B通信时,需要经历2个步骤:

  • 线程A将本地内存A中更新过的共享变量刷新到主内存中去。
  • 线程B到主内存中去读取线程A之前已经更新过的共享变量。

Java内存模型(简称JMM)规定:线程对共享变量的所有操作都必须在自己的本地内存进行,不能直接从主内存中读取。这样线程A无法直接访问线程B的工作内存,它们只能通过 经过主内存 来进行通信。

也就是说,线程B不是直接从主内存中读取共享变量的值,而是先在本地内存中找到这个共享变量,发现这个变量已被更新,然后就到主内存读取信值,并拷贝到本次内存B中。最后线程B在读取本地内存的新值。

如何知道共享变量被其他线程更新,这是JMM存在的必要之一。JMM通过控制主内存和每个本地内存之间的交互,来提供可见性保障。

 


重排序

什么是重排序

在执行程序时,为了提高性能,编译器和处理器通常会对指令做重排序。重排序分3种类型:

  • 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  • 指令级并行的重排序:现代处理器通过采用该技术,将多条指令重叠执行。若不存在数据依赖性,处理器可以改变语句对应的机器执行的执行顺序。
  • 内存系统的重排序:由于处理器采用缓存 和 读/写缓存区,使得加载和存储操作看上去是在乱序执行。

数据依赖性

若两个操作访问同一变量,且两个操作中有一个为写操作,则这两个操作存在数据依赖性。此处的数据依赖性仅针对单处理器中执行的指令序列和单线程中执行的操作, 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑。

为什么指令重排序可以提高性能?

每个指令包含若干个步骤,每个步骤可能使用不同的硬件。因此便有了流水线技术:指令1还未执行完,可以开始执行指令2,而不是等到指令1执行结束后才执行指令2。

以下面代码为例,先加载b,c,由于加载是需要一定时间的,这就增加了停顿,后面的指令也会依次有停顿,这会降低执行效率。为了减少停顿,我i们可以先加载e,f,然后再加载b,c,这样不仅对(串行)程序没有影响的,还可以减少停顿。

a = b + c;
d = e - f ;

从Java源代码到最终执行的指令序列,会经历上面3种重排序。指令重排可以保证串行语义一致,但对于多线程程序的语义没有保证一致。因此,JMM的编译器重排序规则会禁止特定类型的编译器重排序;同时也要求Java编译器在生成指令序列时,插入特定的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序。

 

as-if-serial语义

该语义的意思是:不管怎么重排序,(单线程)程序执行的结果不能被改变。为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序。但如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

 

内存可见性问题

现代处理器使用写缓存区 来临时保存向内存写入的数据,而每个处理器上的写缓存区 仅对它所在的处理器可见。这一特性使得处理器对内存的读/写操作的执行顺序,可能与 内存实际发送的读/写顺序不一致。

如下图所示,处理器A和处理器B按程序的顺序并行执行内存访问,本希望得到的结果是x = 2, b = 1,但最终可能得到x = y = 0。

具体的原因为:处理器A和处理器B分别执行完A1,B1操作后,先将结果写入自己的缓存区,然后从内存中分别读取变量b,a。最终才把自己的缓存区保存的脏数据刷新到内存中。

为了保证内存可见性,Java编译器在生成指令序列的适当位置插入内存屏障指令来禁止特定类型的处理器重排序。JMM把内存屏障指令分为下图4类。现代的多处理器大多支持StoreLoad Barriers屏障,不过执行该屏障开销很大,这是因为当前处理器通常要把写缓存区里的数据全部刷新到内存中。

 

顺序一致性模型

顺序一致性内存模型是一个理论参考模型,内存模型在设计的时候都会以顺序一致性内存模型作为参考。

该模型有两个特性:

  • 一个线程中的所有操作必须按照程序的顺序(即Java代码的顺序)来执行。
  • 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。即在顺序一致性模型中,每个操作必须是原子性的,且立刻对所有线程可见

假设有两个线程A和B并发执行 并且 使用锁来正确同步,A,B线程都有3个操作。A线程的3个操作执行后释放锁,B获取同一个锁。

下图是顺序一致性模型的执行效果:

而如果两个线程没有同步,则在顺序一致性模型中的执行效果为:

但JMM没有这样的保证:未同步的程序在JMM中不但整体执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。

比如说,当前线程当写过数据放入本地内存,在没有刷新到主内存前,这个写操作仅对当前线程可见,而其他线程会认为这个写操作没有被执行。只有等到当前线程将写过的数据刷新到主内存后,这个写操作才对其他线程可见。在这种情况下,当前线程和其他线程看到的操作执行顺序是不一致的。

 

JMM中同步程序的顺序一致性

在JMM中,临界区内(同步块或同步方法中)的代码可以发生重排序,但不允许临界区内的代码“逃逸”到临界区之外,因为会破坏锁的内存语义。

线程A在临界区做了重排序,但是因为锁的特性,线程B无法观察到线程A在临界区的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。同时,JMM会在退出临界区和进入临界区做特殊的处理,使得在临界区内程序获得与顺序一致性模型相同的内存视图。

由此可见,JMM的具体实现方针是:在不改变(正确同步的)程序执行结果的前提下,尽量为编译期和处理器的优化打开方便之门

 

JMM中未同步程序的顺序一致性效果

对于未同步的多线程程序,JMM只提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有。为了实现这个安全性,JVM在堆上分配对象时,首先会对内存空间清零,然后才会在上面分配对象

JMM没有保证未同步程序的执行结果与该程序在顺序一致性中执行结果一致。因为如果要保证执行结果一致,那么JMM需要禁止大量的优化,对程序的执行性能会产生很大的影响。

由此可见,未同步程序在 JMM 和 顺序一致性内存模型 中的执行特性有如下差异:

(1)顺序一致性保证单线程内的操作会按程序的顺序执行;JMM不保证单线程内的操作会按程序的顺序执行。(但由于重排序,JMM保证单线程下的重排序不影响执行结果)。

(2)顺序一致性模型保证所有线程只能看到一致的操作执行顺序;而JMM不保证所有线程能看到一致的操作执行顺序。

(3)JMM不保证对64位的long型和double型变量的写操作具有原子性;而顺序一致性模型保证对所有的内存读写操作都具有原子性。

 


happens-before

什么是happens-before?

对于编译器和处理器来说,只要不改变程序(单线程程序和正确同步了的多线程程序)的执行结果,编译器和处理器怎么优化都行

对于程序员,JMM提供了happens-before规则,它简单易懂,并且提供了足够强的内存可见性保证。只要程序员遵循happens-before规则,那他写的程序就能保证在JMM中具有强的内存可见性。

JMM使用happens-before的概念来定制两个操作之间的执行顺序。这两个操作可以在一个线程以内,也可以是不同的线程之间。这个关系的定义如下:

(1)如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

(2)两个操作之间存在happens-before关系,并不意味着Java平台的具体实现必须要按照happens-before关系指定的顺序来执行。如果重排序之后的执行结果,与按happens-before关系来执行的结果一致,那么JMM也允许这样的重排序。

总的来说,如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程

 

天然的happens-before关系

在Java中,有以下天然的happens-before关系:

(1)一个线程中的每一个操作,总是happens-before于该线程中的任意后续操作。

(2)对一个锁的解锁,happens-before于随后对这个锁的加锁。

(3)对一个volatile域的写,happens-before于任意后续对这个volatile域的读。

(4)如果A happens-before B,且B happens-before C,那么A happens-before C。

(5)如果线程A通过ThreadB.start()来启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作。

(6)如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。

 


参考资料

http://concurrent.redspider.group/article/02/6.html

https://zhuanlan.zhihu.com/p/29881777

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值