Java内存模型

基础

并发编程模型的分类
并发编程中需要处理的两个关键的问题: 线程和线程之间是如何 通信 线程和线程之间是如何 同步 的。

通信
通信是指线程之间通过什么机制交换信息。在命令式编程中,线程通信有两种机制: 共享内存消息传递
共享内存的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的 公共状态进行 隐式通信
消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过 明确的发送消息来进行 显式通信

同步
同步是指程序用于控制不同线程之间操作发生相对顺序的机制。
共享内存并发模型里,同步是 显示进行的。程序员必须指定某个方法或者某段代码需要在线程之间 互斥执行
消息传递的并发模型里,由于消息的发送必须是在消息的接收之前,因此同步是 隐式进行的。
Java的并发采用的是 共享内存模型,Java线程之间通信总是 隐式的,整个通信过程对程序员完全透明。

Java内存模型的抽象
在Java中,所有的实例域、静态域和数组元素存储在 (demp)内存中,堆内存在线程之间共享(共享变量指代实例域、静态域和数组元素)。
局部变量,方法定义参数和异常处理参数不会在线程之间共享,它们不会有内存可见性的问题,也不受JMM的影响。

Java线程之间的通信由Java内存模型(简称JMM)控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象角度看,JMM定义了线程和主内存之间的抽象关系:线程之间共享变量存储在主内存(main memory)中,每个线程都有一个本地内存(local memory),本地内存中存储了该线程读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不是真实存在的。它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器的优化。
从上图可以看到,A、B两个线程需要通信的话,必须经历下面两个步骤:
1.首先,线程A把本地内存A更新过的共享变量副本刷新到主内存里面。
2.然后,线程B去主内存读取线程A更新过的共享变量。

如上图所示,本地内存A和B有主内存中的共享变量X的副本,假设初始时,三个内存中的X的值都为0。线程A在执行时,把更新过的X的值(假设为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信的时候,线程A首先会把已经修改后的X值刷新到主内存中去,此时主内存中的X值也变为了1。随后线程B去主内存中读取已经被A更新后的X值,此时线程B的本地内存X值也变为了1。

重排序

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

这些重排序都可能会导致多线程出现内存可见性的问题。
对于 编译器重排序,JMM编译器重排序功能会禁止特定类型的编译器重排序(不是所有的编译器重排序都要禁止)。
对于 处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序(不是所有的处理器重排序都要禁止)。
JMM属于语言级的内存模型,它确保在不同编译器,不用处理器平台上。通过禁止特别类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

处理器重排序
现代的处理器使用 写缓冲区来临时保存向内存写入数据。写缓冲区可以保证指令流水线持续运行,它可以避免处理器由于停顿下来等待向内存写入数据的延迟。同时通过以批处理的方式刷新写缓冲区,以及合并写缓冲区对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅只对它所在的处理器可见。这个操作会对内存操作的执行顺序产生重要的影响:处理器对内存读/写的执行顺序,不一定与内存实际发生的读/写操作顺序一致,请看下图示例:

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

处理器A、处理器B同时把共享变量写入自己的写缓冲区(A1、B1),然后从内存中读取另一个共享变量(A2,B2),最后才把自己写缓存区保存的脏数据刷新到内存中(A3,B3),当以这种时序执行时,程序就可以得到x=y=0。
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存区,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1->A2,但内存操作实际发生的顺序却是:A2->A1。此时,处理器A的内存操作顺序被重排序了(处理器B的情况和处理器A一样,这里就不赘述了)。
这里的关键是,由于 写缓冲区 仅对自己的处理器可见,它会 导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致 。由于现代的处理器都会使用 写缓冲区 ,因此现代的处理器都会允许对写-读操做重排序。

内存屏障指令
为了保证内存可见性,Java编译器在生成序列的适当位置会插入 内存屏障指令来禁止特定类型的处理器重排序。JMM把 内存屏障指令分为以下四类:
屏障类型
指令示例
说明
LoadLoad Barriers
Load1; LoadLoad; Load2
确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
StoreStore Barriers
Store1; StoreStore; Store2
确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers
Load1; LoadStore; Store2
确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
StoreLoad Barriers
Store1; StoreLoad; Load2
确保Store1数据对其他处理器变得可见(指刷新到内存),之前于Load2及所有后续装载指令的装载。
StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。
StoreLoad Barriers是一个“全能型”的屏障,它同时具有其他三个屏障的效果。现代的多处理器大都支持该屏障(其他类型的屏障不一定被所有处理器支持)。执行该屏障开销会很昂贵,因为当前处理器通常要把写缓冲区中的数据全部刷新到内存中(buffer fully flush)。

happens-before
从JDK1.5开始,java开始使用JSR-133内存模型。JSR-133内存模型提出了happens-before的概念,通过这个概念来阐述操作之间的内存可见性。
如果一个操作执行的操作结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。这里提到的两个操作既可以在一个线程内,也可以在不同的线程之间。heppens-before规则如下:
  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens-before于之后对这个监视器的锁加锁。
  • volatile变量规则:对一个volitile域的写,happens-before于任意后续对这个volatile域的读。
  • 传递性:如果A happens-before B,B happens-before C, 那么 A happens-before C。
注意,两个操作具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!happens-before仅仅只要求前一个操作(执行结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。



数据依赖性
如果两个操作访问同一个变量,且这两操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分为下列三种类型:
名称
代码示例
说明
写后读
a=1;b=a;
写一个变量之后,再读这个位置
写后写
a=1;a=2;
写一个变量之后,在写这个变量
读后写
a=b;b=1;
读一个变量之后,再写这个变量
上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会改变。
前面提到过,编译器和处理器可能会对操作重排序。编译器和处理器在重排序的时候,会遵循数据依赖性, 编译器和处理器不会改变存在数据依赖性的两个操作的执行顺序
注意:这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作。 不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器所考虑

as-if-serial语义
as-if-serial语义指: 不管怎么重排序(编译器和处理器为了提高并行度),(单线程) 程序的执行结果不能被改变。编译器,runtime和处理器都必须遵守as-if-serial语义。
为了遵守as-if-serial语义,处理器和编译器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被处理器和编译器重排序,如下图所示:
如上图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面(C排到A和B的前面,程序的结果将会被改变)。但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。下图是该程序的两种执行顺序:

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

程序顺序规则
根据happens- before的程序顺序规则,上面计算圆的面积的示例代码存在三个happens- before关系:
  1. A happens- before B;
  2. B happens- before C;
  3. A happens- before C;
这里的第3个happens- before关系,是根据happens- before的传递性推导出来的。这里A happens- before B,但实际执行时B却可以排在A之前执行(看上面的重排序后的执行顺序)。在第一章提到过,如果A happens- before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。这里操作A的执行结果不需要对操作B可见;而且重排序操作A和操作B后的执行结果,与操作A和操作B按happens- before顺序执行的结果一致。在这种情况下,JMM会认为这种重排序并不非法(not illegal),JMM允许这种重排序。
在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能的开发并行度。编译器和处理器遵从这一目标,从happens- before的定义我们可以看出,JMM同样遵从这一目标。

重排序对多线程的影响
如下图所示:
flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?
答案是:不一定能看到。
由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?请看下面的程序执行时序图:
如上图所示,操作1和操作2做了重排序。程序执行时,线程A首先写标记变量flag,随后线程B读这个变量。由于条件判断为真,线程B将读取变量a。此时,变量a还根本没有被线程A写入,在这里多线程程序的语义被重排序破坏了!
下面再让我们看看,当操作3和操作4重排序时会产生什么效果(借助这个重排序,可以顺便说明控制依赖性)。下面是操作3和操作4重排序后,程序的执行时序图:

在程序中,操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。
从图中我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!
在单线程程序中,对存在控制依赖的操作重排序,不会改变执行结果(这也是as-if-serial语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

顺序一致性

数据竞争与顺序一致性保证
如果程序是正确同步的,程序的执行将具有顺序一致性,即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同。

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

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读/写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。
为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。
假设有两个线程A和B并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。
假设这两个线程使用监视器来正确同步:A线程的三个操作执行后释放监视器,随后B线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:
现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。
但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

volatile

Java语言规范第3版中对 volatile的定义 如下:
Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。 如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的

volatile的特性
把对volatile变量的单个读/写,看成是使用同一个监视器锁对这些单个读/写操作做了同步。请看如下示例:
假设有多个线程分别调用上面程序的三个方法,这个程序在语意上和下面程序等价:

如上面示例程序所示,对一个volatile变量的单个读/写操作,与对一个普通变量的读/写操作使用同一个监视器锁来同步,它们之间的执行效果相同。
监视器锁的happens-before规则保证释放监视器和获取监视器的两个线程之间的内存可见性,这意味着对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
监视器锁的语义决定了临界区代码的执行具有原子性。这意味着即使是64位的long型和double型变量,只要它是volatile变量,对该变量的读写就将具有原子性。如果是多个volatile操作或类似于volatile++这种复合操作,这些操作整体上不具有原子性。
简而言之,volatile变量自身具有下列特性:
  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值