搜文章
推荐 原创 视频 Java开发 iOS开发 前端开发 JavaScript开发 Android开发 PHP开发 数据库 开发工具 Python开发 Kotlin开发 Ruby开发 .NET开发 服务器运维 开放平台 架构师 大数据 云计算 人工智能 开发语言 其它开发
Lambda在线 > 点融黑帮 > 闲话Java内存模型

闲话Java内存模型

点融黑帮 2018-06-29

01

 前言

Java 内存模型(Java Memory Model, 后文简称为JMM)是Java语言规范(Java Language Spec, 后文简记为JLS)中相对较为复杂的一部分内容,定义和描述了在多线程环境下不同线程与内存之间交互的模型。


目前其最新的官方定义存在于JLS的‘’线程与锁‘’章节中,这部分内容上一次显著的修正定稿于2004年8月。


这次修订的成果就是JSR(Java Spec Request)-133,它所定义的规范在于同年十月正式发布的J2SE 1.5中被正式使用,距今已经有十余年,然而仍然时常可以看到有程序员未能正确理解JMM提供的语义保证,在开发实践中犯各种错误。究其根源,大约是由于以下几方面的原因,导致较多程序员对JMM没有更多的了解。


· Java程序员对现代的CPU和编译器、Runtime环境等可能会进行的语句与代码的转换与重排不太了解;


· JLS的描述更多使用了形式化的表达方式,在更加通俗易懂的解释和详细的例子下学习难度较大;


· JMM的正规描述中很多内容更多地是针对Java编译器开发者、JVM实现者的,对于开发出安全、正确的应用来说并不是必备的,这也会影响到学习的热情与积极性。


本文将尝试从前面提到的几个方面来入手,对这一版本JMM的定义作出一个相对通俗的解释,希望大家在看完本文之后可以理解为什么需要定义JMM,在这一模型中又为大家提供了什么样的一内存语义保证,作为普通程序员又需要了解些什么等问题。


而关于这一版本做出了什么样的变化以及为什么会有这些变化,大家可以在文末所附的参考资料中找到相应的答案,文中就不再赘述。当然本文内容受个人知识所限,可能存在一定误差,也欢迎大家来讨论和指正。


闲话Java内存模型


02

代码重排与乱序执行

在现代的计算机世界中,程序的执行与实际代码的书写所体现的顺序经常会不太一致,从而使得关于一个程序以什么样的方式运行所得到的结果算是正确的这样一个问题的答案常常是模糊的。但为什么会出现这种顺序的不致?我们通常可以从下面两方面找到答案。


2.1 CPU中的指令重排与内存操作排序


基于指令集的不同,不同CPU实际有着不同的指令周期划分,在这里我们将CPU的指令周期简单分解为如下的几个阶段(虽然实际不同的指令架构下CPU的指令执行周期具体划分会有不同,但这种不同对于我们的分析并不会有实质影响):


1.指令预取(Instruction Pre-fetch)

2.指令解码 (Instruction Decode), 这里可能会需要从内存中加载涉及到的操作数等

3.指令执行 (Instruction Execution)

4.结果回写 (Result Write-back), 这里需要将运算结果写回到主存


CPU可以串行连续执行一条指令的各个阶段,但在更为常见的情况下是尽量并行地执行多个独立指令的不同阶段,从而提高指令的整体吞吐量。在指令并行执行的情况下,我们可以作如下图所示的一种形象描述:指令流由cpu一侧进入而从另一侧产生流出产生结果,为此这种结构得名为“指令流水线”(Instruction Pipeline)。

闲话Java内存模型

图1 指令流水线


图中横坐标方向展示了指令的四个周期(也可认为是cpu中的四个部件),纵向为时间轴。一条指令执行会先后通过指令的四个周期。本图描绘了一种理想的状况:随着时间增长,指令1~6顺次串行通过了指令流水。


为了提高CPU指令流水线的吞吐率,改进整体性能,现代CPU的设计师们必然会从目前性能最低的部分入手来做出调整,涉及到内存操作的解码和回写部件就成为了被关注的重点。通常设计师们会使用寄存器,高速缓冲和队列等机制来改善这两部分的性能。


例如,在指令解码部件中就通常会引入指令解码队列:当一条指令的解码过程因为需要访问主存而被挂起时,队列中的后续指令在与前面的指令没有逻辑依赖关系的情况下将可能被先行解码完成并被推进到流水线中先执行;


对于写回模块,经常采用的手段是只把一个存储更新请求提交给缓存管理机制并不等待其最终完成,这个存储请求会依CPU的缓存架构设计制定的规则最终异步刷新到主存(具体的策略,视CPU架构的不同存在着不同的差异。以Intel的Core系列CPU为例,从第一代的Nehalem 到第八代的Coffee Lake, 各代间都通常有一些Cache体系上的调整,会带来相应的存储更新方面的差异,感兴趣的同学可以另行深入了解), 这会让向内存的写操作体现出来一定的异步性。


这些机制的存在,会导致程序在多线程条件下运行时出现一些“反直觉”(counter-intuitive)的结果。例如下面的这一段伪代码清单中所示的内容(请先忽略这段伪代码所示程序在Java多线程环境下存在的其他问题,稍后我们会再讲到)。

闲话Java内存模型

 代码清单1 CPU与内存重排演示


假定机器指令的顺序与代码所示伪代码所呈现的逻辑行为顺序一致, 程序员一般会有如下预期: 由于有语句3的存在,无论线程1,2哪 一个先被执行,都会因为线程1中的语句1先于语句2执行,从而在线程2中观察到b为true之后a一定为10,语句4中的断言一定成立。


然而,事实可能并非如此。一方面,由于1,2语句不直接关联,在引入了指令解码队列的架构上,解码环节上可能会出现2语句的指令先进入pipeline的场景;另一方面,即使1, 2对应的机器指令按顺序发出了写操作,仍然可能会因为缓存引入的写入异步性而使a, b实际写入主存的顺序不确定,可能会b被先写入,而a被后写入。这两种情况下,线程2作为观察者,看到的a, b的变更顺序都可能与代码书写所体现的顺序不一致。


我们把依原始代码的先后顺序体现的操作的排序称为程序顺序(Program Order), 而实际的读写等操作发生的顺序称为执行顺序。由于指令管线中的流水重排,高速缓存,缓冲区和预测执行(见下文)等诸多因素会为一条指令的完成时间带来很多不确定的异步因素,导致代码的执行顺序不一定与其程序顺序相同,这就是CPU中的指令与内存访问重排问题。


不过,由于CPU中的指令与内存访问的重排,基本上遵循一个原则,即当前后的指令或数据存在依赖关系的情况下,是禁止重排的,这会给在单线程环境下执行的代码带来“看似串行”(as-if-serial)的印象,这使得程序员们在单线程环境下并不用担心重排的问题,因为这些重排并不会给程序的执行带来可以感知的结果。


然而在多线程环境下,如果在已经有一个线程会对某变量写的情况下,有其他的线程再访问(无论读还是写)该变量时, 这样的重排就将导致从外部的观察者的角度看到的结果不同,这种情况称之为数据竞争(Data Race)条件。换言之,当存在着数据竞争条件时,CPU与内存的重排将会带来不确定的执行结果。


另一种会带来程序顺序与执行顺序不致的情况由编译器与运行时环境带来。


2.2 编译器指令重排


在Java的世界里面存在着两种编译器:将源代码编译成为字节码的编译器(Javac)和将Java字节码转换成为机器指令的compiler (如Oracle的JIT/HotSpot Compiler),这两种编译器都被允许在在不违背程序的语义的前提下,选择各种优化手段对代码进行物理或逻辑重新排序,例如下面的一些常见方式:


2.2.1 指令调度(Instruction Schedule)


简单地说来,指令调度是一种通过将独立的指令集中在一起来避免或减少管道延迟的方式。

如下面的代码中:

闲话Java内存模型

代码清单2 指令调度演示


明显语句2对于语句1有逻辑依赖,而语句3独立于1,2,编译器将可能将语句3提前到2之前执行,使独立指令集更大从而获得更好的流水线性能。


2.2.2 前向替换


前向替换是一种用重复使用已经计算或加载的结果,并将其存储起来,在后续再使用同样表达式时直接重用而不是重新计算或重新加载它们的处理方法。

闲话Java内存模型

代码清单3 前向替换问题示意


在这个例子中,如果我们不考虑thread2中运行的代码可能造成的影响,我们可以看到在thread1中的代码内,r5与r2都是取得到r1.x,且在当前线程内没有对r1.x的写操作发生在语句1,2之间, 这种情况下将允许编译器优化后面的自r1.x加载数据为直接重用r2, 即r5= r2。假设t2中的语句3实际发生时间在t1中的1, 2语句之间,也及时写入到了主存, 原本预期看到r2 = 0, r5 = 3, 但由于前向替换的存在,我们观察到的结果将看上去好象各语句是以1->2->3的顺序发生, 留下语句2, 3的执行顺序被重新编排过的印象。


同样的,我们可以看到,仅就一个线程内部而言,编译器的语句重排也不会违背as-if-serial的规则,但在多线程环境下,存在数据竞争条件时,程序将无法得到可靠的执行结果保障。为了得到明确的程序顺序主义的保障,我们需要借助Java的同步行为。


闲话Java内存模型


03

Java 的同步语义

CPU和编译器为代码执行增加了很多不确定性, 在没有通过显式的同步机制调整的情况下,在多线程环境中, 一个程序里的每一个具体行为将在时间顺序上交错混杂。我们把这种互相交织的代码行为的实际执行顺序称为执行轨迹(Execution Trace),一个程序可以有多个,甚至无穷多个执行轨迹。


在Java语言中,我们认为下面的一些行为具有同步语义,称之为同步活动(Synchronization Actions),他们为程序员们提供了对执行轨迹加以约束的手段。


3.1 加锁与解锁


在JAVA中,每一个同步方法或同步块都与一个具体的同步对象关联(在同步块上是指定的锁对象,同步方法上则与this对象或class实例关联),在进入同步方法或同步块后,对同步对象的Monitor有一个锁定动作; 相应地在退出当前同步块时有解锁动作。另外,在锁对象上的wait操作,是一次释放锁和一次重新获取锁的行为的组合:在进入时释放当前的锁,在以未被中断的方式返回的情况下重新获取到锁。


加锁操作由一个设置锁对象上的加锁状态的CAS操作与一个紧接着的内存屏障动作共同组成,这个内存屏障将确保加锁前后的数据访问活动不会出现跨屏障的重排,同时还将使缓存失效以确保后续本线程内的内存读都会得到主存中的最新的状态。如果当前的锁对象被其他线程持有,CAS操作未能成功设置锁状态,加锁操作还会将当前线程置于这个锁的等待集上,等待当前锁的所有者在后续释放锁时被唤醒起来再继续尝试。


解锁动作的顺序则刚好相反,会先设置内存屏障 再通过CAS 清除当前锁的持有状态。这个内存屏障同样也有确保不会有跨同步块边界的重排的作用,同时还会确保缓存的数据都刷新到主存。完成这些动作之后解锁动作将从该锁对象的等待集中选择一个唤醒继续操作。


现在我们尝试用加锁的方式来对清单1中的代码进行一下改造。

闲话Java内存模型

代码清单4 加锁演示


之前我们无法确保对a, b的写入在线程2中被观察到的顺序一致;现在,由于lock/unlock动作中分别带有清空缓存与强制刷新缓存到主存的逻辑, 我们会知道在语句2结束后,一定会观察到b == true && a == 10的结果, 无论这两个写是以何种顺序发生。


此刻线程2再执行我们将毫无争议地知道我们的断言将会成功。而如果线程2中语句3~5先被执行,我们会在语句4时释放出对于锁对象的占用,允许线程1中代码先行完成后再由语句4的点上继续,此时b已经被设置,我们将成功得到预期中的断言结果。


前面我们演示Cpu与内存重排的时候的代码片断,我们曾经称它们实际是有问题的。其原因在于对应的代码片断中不存在任何的lock/unlock的操作,从而无法确保在线程2当中,在线程1中对标志变量b进行的修改可以及时地被检测到(甚至而言,可能会由于编译器的优化,在线程2中对b进行了初次的读取后,完全不再尝试从主内存中获取新的内容而永远不再被检测到)而造成我们的代码失效。


清单4当中我们通过加锁与解锁带来缓存清理和内存强制刷新语义解决了这个问题,Java中还提供了另一种方式,将字段申明成为易变(volatile)的 来解决这个问题。


3.2 易变字段 (volatile field)


当一个字段申明中加上了volatile关键字后,我们称这个字段是易变字段。易变字段有下面的语义要求:一方面会要求易变字段不得被缓存,另一方面还要求禁止一切涉及到易变字段的重排(即,不仅仅易变字段的访问间不得重排, 易变字段与普通之段的访问间也不得重排)。


由于易变字段的每一次读写都是通过内存直接进行,且不允许与其他的内存操作重排,易变字段的访问也就有了某种同步的意味:读易变字段清空了对该字段的缓存,写易变字段完成后强制写回了主存;没有跨越易变字段访问前后的指令与内存访问重排。


我们可以把易变字段的访问理解为一种仅包含对该易变字段的访问的简化的同步块。 在这样的保障之下,我们看看对清单2中代码的另一种改造方式:

闲话Java内存模型

代码清单5 易变字段


在这个清单中,语句2,3形成了事实上的同步关系, 语句3无论首次执行是发生在语句2之前或之后,都只会在语句2执行完成之后才能完成;而依据易变字段与普通字段的访问不得重排的原则,我们知道语句1先于语句2发生,语句3先于语句4发生, 而2, 3之间有同步关系,这样我们就可以轻易推导出来,1将会先于4发生。


 这样我们就可以确信,无论线程1,2以何种顺序先后启动执行,我们的这段程序中的各个行为都有明确的先后顺序,尽管语句1,4中执行的是相互冲突的行为,由于他们之间有着明确的先后顺序,我们的程序也将得到明确的执行顺序和结果。


3.3 同步活动(Synchronization Actions),同步顺序(Synchronization Order), ”与同步“关系(Synchronize-with)关系


除开前面我们已经提到的加锁、解锁, 易变字段的访问之外, 在JMM当中还定义了下面的一些同步活动:

· 线程的首活动(First Action )与尾活动(Last/Final Action), 它们并不是对应于一条具体的语句,而是对线程的开始与结束中必须进行的一组操作的合成而来的一个概念。


· 启动线程活动 与检测线程结束活动

一个程序的每一次具体的执行中,所有的同步活动都会形成一个明确的执行先后顺序, 即一个同步活动不会与其他的同步活动同时发生(或先,或后,但不允许同时发生,即使是在多核的机器上也是如此),我们称这个顺序为这次执行上的同步顺序。


同时在一个程序的所有的同步活动的集合上,还存在着一种“与同步”(synchronize-with)的关系(后续简写为SW关系),它们的定义相当明确:


· 在一个monitor m上的解锁动作与在同步顺序上的同一个monitor上的后续加锁动作同步


· 在一个易变字段上的写操作与在同步顺序上同一个字段的后续读操作同步


· 启动线程活动与被它启动的线程的首活动同步


· 线程的尾活动与检测该线程结束的活动同步


· 一个线程t1 Interrupt 另一个线程t2的活动与检测t2线程被中断的活动同步

在SW关系中,我们称同步的源点的活动为释放动作, 终点的活动为获取动作。由这些带有SW关系的活动,再结合上程序顺序,我们可以得到线程间的基本逻辑顺序要求。


3.4 "先于...发生“(Happens-Before)关系与”先于...发生"顺序(Happens-Before Order)


在前面分析清单4中代码时, 我们使用了一种称为“先于...发生”(Happens-before)的关系(简称为HB关系)来进行讨论。非正式地,一个活动先于另一个活动发生,就是指这个活动的执行的结果将被另一个活动可见。


但由于两个活动可能是完全互相独立的,JMM并不要求两个有HB关系的活动的实际执行顺序一定一致;同时在与这两个活动间不存在明确的HB关系的其他活动看来,这两个活动的执行顺序可能会看起来被重排过。

闲话Java内存模型

图2 HB关系与HB顺序


图2中 绿色箭头表示HB关系。 变量A与B的赋值有HB关系, 但由于它们并不相关,实际执行顺序在JMM中并不要求一定会有A的赋值发生于B之前, 但对于C的赋值就必须要有发生在A, B的赋值之后的保障, 因为它与A, B的赋值有依赖关系(我们先排除掉编译将C的赋值转换为C=3的情况)。 


但在另一组与A, B的赋值间没有HB关系的操作来看, 我们是否会允许看到r1=2, 但r2= 0的情况呢?可能我们会认为, r1的赋值在r2之前,如果我们看到了B=2, 那么A一定应该已经被赋值为1, 从而不允许这种情况发生。


但实际上,由于r1, r2的赋值操作是无关的,我们会允许它们被重排, 同样A, B的赋值也可能会重排,无论哪一种重排,都可能产生r1=2, r2=0的情况, 这个也是目前的JMM所允许的。


在同步顺序的基础上,我们再辅助以下面的一次基本逻辑和规则定义的HB关系,我们将会得到程序在一次执行过程中的所有活动间的一个偏序集:

· 线程内的每一个活动都发生在依代码顺序排在其后其他活动之前;


· 一个对象的构造函数结束发生在该对象的析构函数开始之前;


· 如果活动a与活动b同步,则a先于b发生;


· 一个线程的首活动发生在线程内的其他活动之前, 线程内的其他活动都发生在线程的尾活动之前;


· HB关系可以传递 (记a先于b发生为 hb(a, b), 有hb(a,b)且hb(b,c) 则, hb(a,c))

这个偏序集称为HB顺序。我们之所以称这是一个偏序集合,是因为在HB顺序的排序准则中,会允许存在着两个活动间没有明确的顺序指定的情况, 如前面图2中的 A,B,C的赋值与r1, r2的赋值之间就没有明确的HB顺序。


在有了HB关系定义之后,我们还有另一个HB一致的概念: 如果对某一变量的读取操作(r)读到了某个对该变量的写操作(w)的结果,那么w既不能在HB关系中排在r之后 (hb(r, w)), 也不能有另一个对该变量的写操作(w'),在HB关系上介于r与w之间(hb(w, w') 且 hb(w', r)。


 但是对于与之没有直接HB关系的写操作,是否允许看到,未作定义。JMM要求JVM在运行时提供HB一致的保证, 不得产生违背上述原则的行为。在不违背HB一致要求的前提下,JVM可以使用任意的方式来进行程序的优化。


3.5 一个示例


我们用一个例子来形像地说明把前面的概念放到一起,让我们来看清单6的代码, 它是对我们在前面已经反复看到的例子的一个扩展,引入了一些额外的内存操作。

闲话Java内存模型

程序清单6 扩展HB关系演示代码

清单六中的代码的各个线程内的不同action将会产生如下图3的HB关系、同步关系和同步顺序,在下图中,红色的箭头为SW关系,绿色箭头为HB关系。蓝色的结点为同步活动, 绿色的是普通的内存操作。


闲话Java内存模型

图3 同步顺序,SW关系,HB关系


第一步中,我们可以看到这三个线程各自的HB顺序,这其实就是对应线程的代码的程序顺序。


第二步中,我们将所有的同步活动放在了同一水平线上,从左至右的顺序表明了某一种执行轨迹下的所有同步活动的排序,这就是我们的一个同步顺序。我们可以简单地把程序中的所有同步活动理解为必须串行化执行的,两个同步活动间即使没有HB关系存在,在一次具体的执行中仍一定有明确的执行先后顺序。如t1的首操作和启动t2的操作间没有明确的HB关系,但在一个同步顺序中却有其明确的SO顺序排序。


对于有着一对存在着SW关系的同步活动结点,我们要求释放操作一定在前, 获取操作一定在后;在同一个线程内的同步活动必须依程序顺序进行排序。在满足这两条规则的前提下,同步活动结点间的顺序在不同的执行轨迹中可以不同。


最后我们把代码中的非同步活动也引入进来,依据各线程内的HB顺序排进来,就得到了最终的完整的HB顺序图。


闲话Java内存模型


04

正确同步与安全的多线程

尽管JMM实现起来相当复杂,而且底层的重新排序问题也常常会令人感到难以理解,不过, 得益于JMM对于对于线程与内存模型的交互行为的明确定义,只要我们的程序做到了正确同步,在正确支持了JMM的虚拟机实现上运行,就将是线程安全的。那么什么是正确同步呢?


4.1数据竞争(Data Race)和正确同步(Correctly Synchronized)


对于同一变量的两个并行的访问中如果至少有一个是写入操作,则称它们是相互冲突的。相互冲突的两个访问操作之间,如果没有明确的HB顺序定义,在JMM定义下的虚拟机中执行时就有可以以任意的顺序先后,甚至是同时进行,带来不确定的结果,这就是数据竞争(Data Race)。


在JMM当中对正确同步的定义是这样的: “A program is correctly synchronized if and only if all sequentially consistent executions are free of data races”,一个程序如果在它的所有的串行一致的执行当中都不存在数据竞争就是正确同步。


正确同步的代码不一定需要包含同步行为——只要我们的程序没有包含冲突的数据访问就一定是正确同步的。


在线程间不共享数据的程序, 或者是数据仅被初始化一次,后续都只包含读操作的程序,很明显都是正确同步的。


不过,在我们选择了在线程间共享数据,并且读写会交错进行的情况下, 就需要仔细分析才能确定是否有数据竞争了。当代码的行为分析过于复杂的情况下,给这些数据的访问添加上合适的同步行为把代码块划分得更小将会有助于我们化简分析的过程。


通常而言,当我们注意到 我们的程序的正确执行会依赖于在一个线程当中互相独立的语句的执行结果在另一个线程中被观察到的顺序时,我们就需要考虑使用同步行为来为程序的执行顺序定下基本的规则以实现正确同步。


例如我们在代码1中,字段a和b在两个线程中各有读写,所以a,b的访问操作是冲突的。又因为没有任何的同步行为来建立合理的HB关系,程序的任何执行轨迹都可能有a和b上的数据竞争。因而这个程序是没有被正确同步的。


而在后面的的两个分别基于加锁解锁和易变字段的改造版本当中,都通过引入的同步关系而明确了a,b变量在不同线程中的读写操作的HB顺序,消除了数据竞争,从而是正确同步的。


4.2同时进行的读取和写入访问


一些开发人员可能会认为他们只需要在写入时进行同步,因为同步会将新值刷新到主内存中,稍后的读取就可以一定找到它。但当我们用缓存管理的视角来看待同步时就会发现,这种做法仅保证了写线程将数据及时存入主存,并不保证读线程不会使用缓存中已经过时的内容。


为实现正确的同步,一定需要在读和写场景下均加入同步操作。 同样的结论也 可以基于HB关系分析出来:如果仅在写时有同步行为,将并不能在对一个变量的写数据与后续的对应读取之间建立起来SW关系,从而导致读写之间仍然没有明确的HB关系,就一定会存在数据竞争而不能正确同步。


4.3操作的原子性问题


JMM保证访问所有Java基元类型和引用(不是被引用的对象),除了double和long以外,都是原子的。同时,虽然单次写入普通的long或double值不是原子性的,但对易变的long和double值的访问总是原子的(因为同步行为的排它性确保了不会在一个易变变量操作中被插入其他的读写)。


由于有了这个保障,有时程序员们会判断由于所有的读写都发生在基本类型变量上,操作都是原子性的,而自身业务对于读写顺序不同带来差异并不敏感(在代码清单1中,如果并不一定预期a需要是10,就属于这类情况)时,会认为无需要再引入同步行为。


但正如我们前面在加锁解锁的代码分析中提到的那样,很可能语句3永远使用着一个缓存的过时的结果,导致后续的从a的加载完全不能到达。在这里他们忽略了同步器对于程序执行顺序的保证作用,也忽略了cpu和编译优化对于指令转换重排的影响程度。


4.4因果率(Causality)要求


因果率是JMM的规范定义当中最为晦涩和难于理解的部分(好在它不是为普通程序员们设计)。从前面的描述,大家已经可以了解到JMM总体上是一种基于HB一致性的内存模型,但是在某些情况下,仅有HB一致性的保障,可能会带来一些称为“无源结果”的问题(原术语为"Out-Of-Thin-Air",凭空产生),其产生原因主要源自于现代计算机系统可能会出现由预测执行带来的“自我证实”问题。如下面的这个JLS中列出的例子:

闲话Java内存模型

清单7 自我证实的因果关系


很明显如果以串行一致的方式来执行这份代码,语句1, 2都决不会发生,不会有数据冲突,从而它明显是正确同步的(这就是一个没有任何的同步语义,仍然正确同步的例子)。


然而,在一种可能会进行的预测执行方式(事实上目前应该没有会有这种做法的架构,但JLS做了预防性处理以确保之后也不会有类似的可能发生)下,可能会将1, 2语句中的y =1, x = 1的数据写入尝试提前,再当流水执行到条件判断时依据条件来查看是否要废弃对应的写入。在这种情况下,就可能会有下面演示的执行顺序:

thread 1

thread 2

y = 1;

 r1 = x;

x = 1; 
r2 = y;

由于代码是正确同步的,我们允许的行为必须是顺序一致的行为。很明显这里的结果是需要被禁止的, 然而,我们这里的演示的执行顺序仍然是HB一致的 (每一个线程内的两次写操作本身是无关的而允许重排的;而r1=x的行为与thread2中的x = 1行为间也没有明确的HB关系,读到x=1的写入也不违背HB一致的要求 ),将有可能发生。


如果这种重排得以发生,将导致原本不可能达成的r1 >0和r2 > 0的条件成真,导致在指令流水中预先执行的行为不会被取消。换句话讲就是, 因为我们猜测稍后可能会把x 设置成1, y设置成1,而导致最终需要把x 设置成1, y设置成1。这成了一条“自我证实”的假定,很明显是不应该被许可的。


一个好消息是由于清单8中的代码是正确同步的, JMM的规范要求JVM保证不会产生出来不顺序一致的行为。但这需要JVM的实现者们除了在保证执行顺序HB一致之外,额外进行一些判定,以确定是否允许按某个未被HB关系所禁止的内存操作顺序进行提交。


一般来讲一个非正式的规则是不允许重排后的操作顺序带来了额外的数据冲突条件 。上例中的重排,使得一个原本没有数据冲突的场景产生了发生在x 和y上的数据竞争,所以这个结果是无法接受的,对于JMM而言是非法的 。


而在下面的这个例子中我们允许重排,是因为原本的代码就有数据冲突,重排没有带来新的数据竞争,因而在JMM中实际被允许。

闲话Java内存模型

清单8被允许重排

且读取到了未在HB关系中约定的数据的例子


或者我们的一些小伙伴会敏锐地注意到, 在线程1中的条件语句中,条件部分是依赖于前面的读取的啊? 明显逻辑依赖,不应该允许重排啊? 问题的原因是通常这个条件语句可能会被翻译成为类似下面的伪指令序列:

闲话Java内存模型

对于y 的赋值成为一个条件执行指令而在流水中被预先尝试执行。不过通常情况下,多数的指令体系并不会允许预测执行产生的写操作在被确认有效前提交到内存, 这也是为什么我们说目前这个因果率的case更多地是一种预防性的定义。


4.5Final 字段语义与安全构造


JMM的另一个重要部分是关于Final字段的语义保障的。当前对Final 字段的保障如下:“当一个对象的构造函数完成后,一个对象被认为已经被完全初始化。在对象完全初始化后仅能通过对象的引用来访问该对象的线程将可以保证正确地看到对象的final字段的初始化过的值”。


从这个保证来看,我们会需要注意到:如果对象的引用在对象完全初始化之前被泄漏到其他线程中,或一个线程被允许以反射等常规方式访问对象时,JLS并不保证读取final 字段时一定会读到预期中的初始化后的值, 这种情况下可能会看到对应字段的默认初始值。


另一个需要注意的问题是,如果在构造函数中包含着对于普通字段的初始化,即使该对象已经完全初始化,仅通过引用访问该对象的线程中也不必一定可以看到这些普通字段被初始化的值 (虽然实际情况中并不一定会有这类状况,但由于在对象构造函数结束时没有内存屏障的存在,JMM并不保证其他线程一定看到构造函数中向各普通字段写入的值)。


为了对象的安全构造和使用, 我们需要注意确保在构造函数未完成之前,不得以任意方式将当前构造的对象引用发布到其他线程可见的环境中,如下面的几个例子将极不可取。

闲话Java内存模型

清单9 不安全的对象构造


同时,对于预期会在构造函数中进行初始化的成员,或者将其申明成为Final字段,或者在后续的使用中不假定对应字段已经完成了初始化,一定用同不的方式对其访问进行保护。


4.6线程安全的其他思路


JMM 本质上是一种基于共享内存和加锁的线程间协作交互模型,也是对于传统的经典计算机体系架构的一个模拟, JMM作为第一个在语言层面被定义且广泛接受的内存模型, 在已经过去的年代里面取得了相当的成功。


然而伴随着越来越多的并行计算需求,多核, SIMD指令体系等的更多应用,在并行程序开发中我们越来越要求能更多地发掘和利用程序内在的并行性。 但在基于共享内存与加锁的体系下,线程间共享的数据越多,对于正确同步的要求无疑会需要引入更多的同步行为,限制编译器与CPU发掘程序的内在并行性的可能,不利于提高程序的并发性能。因而现在大家更多地把并行程序设计的思路转向使用不同的并行程序设计的范式以求用不同的思路来解决线程安全问题。


一种方式是利用基于函数式的程序设计,从而利用代码的重入性保证不会实际在不同线程间共享内容;另一种方式是秉承以”sharing with communication"的思想,从而减少在共享信息过程中对于锁的依赖,如基于Actor Model的设计,将处理过程与待对理对象进行绑定,避免同时大量的不同处理过程需要同时处理同一对象的场景从而实现无锁的高效处理, 参考链接中关于Actor Model的链接可以给你一个相对完整的印象 。


闲话Java内存模型


05

总结

JMM以形式化的方式描述了什么样的执行轨迹是被许可的, 他的核心是确保正确同步的线程都能得到预期中的执行结果。


正确同步的程序需要确保在串行一致的代码执行当中不发生数据竞争,如果有某些数据操作是冲突的,我们需要通过同步机制来为这些冲突的数据操作间建立起来恰当的HB关系,以避免数据竞争。使用同步机制时需要注意必须同时为读与写操作都 应用上同步机制才可以保障行为的正确性。


使用final字段来定义不可变的对象是另一种重要的实现线程安全的手段,但必须要在正确初始化的基础上final的才能得到保障。但如果final字段所在对象没有初始化完成就被暴露给了其他线程,或是通过了非常规手段访问该对象时,final 语义将得不到保障。另外,从构造函数返回之后, 对于非final字段的初始化未必已经完成。


闲话Java内存模型


06

资料引用

1. Wikipedia上的的Out-of-order 页,https://en.wikipedia.org/wiki/Out-of-order_execution,从此处出发有更多可用链接可供全面了解计算机领域中的乱序执行问题。


2. 内存屏障的一篇文章:http://www.rdrop.com/users/paulmck/scalability/paper/whymb.2010.06.07c.pdf


3. Java Language Spec Chapter 17, Threads and Locks , 这是目前的JMM官方描述的最新版本(虽然和JDK1.8中并无可见差异)https://docs.oracle.com/javase/specs/jls/se10/html/jls-17.html


4. JSR133(Java内存模型)的最终版本文档https://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf

与JLS进行一些交叉对比,可为某些不易理解的部分提供另外的理解思路。


5. William Pugh(Bill Pugh), JSR133 Spec Leader的Java Memory Modal问题页面, 如果需要了解现在的JMM为什么是这样,这是最值得推荐的一个入口 

http://www.cs.umd.edu/~pugh/java/memoryModel/


6. Doug Lea 的JSR 133 Cookbook,从实现者实角度对于JSR133的理解


7. Aleksey Shipilёv的JMM Presentation,

https://shipilev.net/blog/2014/jmm-pragmatics/


8. JEP 188, 对于JSR133的改进信息入口 主要价值在于给出了改进的动因和到有具体的mail list链接:

http://openjdk.java.net/jeps/188


9. 并发之痛,

https://my.oschina.net/tjt/blog/906204 

一篇较有深度关于并发的综述性文章,虽然语言并非java, 但仍有借鉴价值


10. Actor Model,

https://en.wikipedia.org/wiki/Actor_model


11. Actor in Akka,

https://doc.akka.io/docs/akka/2.5/index-actors.html


闲话Java内存模型


12

往期精彩

闲话Java内存模型

· 

· 

· 

· 

版权声明:本站内容全部来自于腾讯微信公众号,属第三方自助推荐收录。《闲话Java内存模型》的版权归原作者「点融黑帮」所有,文章言论观点不代表Lambda在线的观点, Lambda在线不承担任何法律责任。如需删除可联系QQ:516101458

文章来源: 阅读原文

相关阅读

关注点融黑帮微信公众号

点融黑帮微信公众号:DianrongMafia

点融黑帮

手机扫描上方二维码即可关注点融黑帮微信公众号

点融黑帮最新文章

精品公众号随机推荐

下一篇 >>

nginx常见架构