深入理解Java内存模型(三)——顺序一致性

数据竞争与顺序一致性保证

当程序未正确同步时,就会存在数据竞争。java内存模型规范对数据竞争的定义如下:

  • 在一个线程中写一个变量,
  • 在另一个线程读同一个变量,
  • 而且写和读没有通过同步来排序。

当代码中包含数据竞争时,程序的执行往往产生违反直觉的结果(前一章的示例正是如此)。如果一个多线程程序能正确同步,这个程序将是一个没有数据竞争的程序。

JMM对正确同步的多线程程序的内存一致性做了如下保证:


  • 顺序一致性内存模型
    如果程序是正确同步的,程序的执行将具有顺序一致性(sequentially consistent)--即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同(马上我们将会看到,这对于程序员来说是一个极强的保证)。这里的同步是指广义上的同步,包括对常用同步原语(lock,volatile和final)的正确使用。

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,它为程序员提供了极强的内存可见性保证。顺序一致性内存模型有两大特性:

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

顺序一致性内存模型为程序员提供的视图如下:

在概念上,顺序一致性模型有一个单一的全局内存,这个内存通过一个左右摆动的开关可以连接到任意一个线程。同时,每一个线程必须按程序的顺序来执行内存读/写操作。从上图我们可以看出,在任意时间点最多只能有一个线程可以连接到内存。当多个线程并发执行时,图中的开关装置能把所有线程的所有内存读/写操作串行化。

为了更好的理解,下面我们通过两个示意图来对顺序一致性模型的特性做进一步的说明。

假设有两个线程A和B并发执行。其中A线程有三个操作,它们在程序中的顺序是:A1->A2->A3。B线程也有三个操作,它们在程序中的顺序是:B1->B2->B3。

假设这两个线程使用监视器来正确同步:A线程的三个操作执行后释放监视器,随后B线程获取同一个监视器。那么程序在顺序一致性模型中的执行效果将如下图所示:

现在我们再假设这两个线程没有做同步,下面是这个未同步程序在顺序一致性模型中的执行示意图:

未同步程序在顺序一致性模型中虽然整体执行顺序是无序的,但所有线程都只能看到一个一致的整体执行顺序。以上图为例,线程A和B看到的执行顺序都是:B1->A1->A2->B2->A3->B3。之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见。

但是,在JMM中就没有这个保证。未同步程序在JMM中不但整体的执行顺序是无序的,而且所有线程看到的操作执行顺序也可能不一致。比如,在当前线程把写过的数据缓存在本地内存中,且还没有刷新到主内存之前,这个写操作仅对当前线程可见;从其他线程的角度来观察,会认为这个写操作根本还没有被当前线程执行。只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。在这种情况下,当前线程和其它线程看到的操作执行顺序将不一致。

同步程序的顺序一致性效果

下面我们对前面的示例程序ReorderExample用监视器来同步,看看正确同步的程序如何具有顺序一致性。

请看下面的示例代码:

class SynchronizedExample {
int a = 0;
boolean flag = false;

public synchronized void writer() {
    a = 1;
    flag = true;
}

public synchronized void reader() {
    if (flag) {
        int i = a;
        ……
    }
}
}

上面示例代码中,假设A线程执行writer()方法后,B线程执行reader()方法。这是一个正确同步的多线程程序。根据JMM规范,该程序的执行结果将与该程序在顺序一致性模型中的执行结果相同。下面是该程序在两个内存模型中的执行时序对比图:

在顺序一致性模型中,所有操作完全按程序的顺序串行执行。而在JMM中,临界区内的代码可以重排序(但JMM不允许临界区内的代码“逸出”到临界区之外,那样会破坏监视器的语义)。JMM会在退出监视器和进入监视器这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图(具体细节后文会说明)。虽然线程A在临界区内做了重排序,但由于监视器的互斥执行的特性,这里的线程B根本无法“观察”到线程A在临界区内的重排序。这种重排序既提高了执行效率,又没有改变程序的执行结果。

从这里我们可以看到JMM在具体实现上的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能的为编译器和处理器的优化打开方便之门。

未同步程序的执行特性

对于未同步或未正确同步的多线程程序,JMM只提供最小安全性:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false),JMM保证线程读操作读取到的值不会无中生有(out of thin air)的冒出来。为了实现最小安全性,JVM在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致。因为未同步程序在顺序一致性模型中执行时,整体上是无序的,其执行结果无法预知。保证未同步程序在两个模型中的执行结果一致毫无意义。

和顺序一致性模型一样,未同步程序在JMM中的执行时,整体上也是无序的,其执行结果也无法预知。同时,未同步程序在这两个模型中的执行特性有下面几个差异:

  1. 顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)。这一点前面已经讲过了,这里就不再赘述。
  2. 顺序一致性模型保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。这一点前面也已经讲过,这里就不再赘述。
  3. JMM不保证对64位的long型和double型变量的读/写操作具有原子性,而顺序一致性模型保证对所有的内存读/写操作都具有原子性。

第3个差异与处理器总线的工作机制密切相关。在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的,这一系列步骤称之为总线事务(bus transaction)。总线事务包括读事务(read transaction)和写事务(write transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字。这里的关键是,总线会同步试图并发使用总线的事务。在一个处理器执行总线事务期间,总线会禁止其它所有的处理器和I/O设备执行内存的读/写。下面让我们通过一个示意图来说明总线的工作机制:

如上图所示,假设处理器A,B和C同时向总线发起总线事务,这时总线仲裁(bus arbitration)会对竞争作出裁决,这里我们假设总线在仲裁后判定处理器A在竞争中获胜(总线仲裁会确保所有处理器都能公平的访问内存)。此时处理器A继续它的总线事务,而其它两个处理器则要等待处理器A的总线事务完成后才能开始再次执行内存访问。假设在处理器A执行总线事务期间(不管这个总线事务是读事务还是写事务),处理器D向总线发起了总线事务,此时处理器D的这个请求会被总线禁止。

总线的这些工作机制可以把所有处理器对内存的访问以串行化的方式来执行;在任意时间点,最多只能有一个处理器能访问内存。这个特性确保了单个总线事务之中的内存读/写操作具有原子性。

在一些32位的处理器上,如果要求对64位数据的读/写操作具有原子性,会有比较大的开销。为了照顾这种处理器,java语言规范鼓励但不强求JVM对64位的long型变量和double型变量的读/写具有原子性。当JVM在这种处理器上运行时,会把一个64位long/ double型变量的读/写操作拆分为两个32位的读/写操作来执行。这两个32位的读/写操作可能会被分配到不同的总线事务中执行,此时对这个64位变量的读/写将不具有原子性。

当单个内存操作不具有原子性,将可能会产生意想不到后果。请看下面示意图:

如上图所示,假设处理器A写一个long型变量,同时处理器B要读这个long型变量。处理器A中64位的写操作被拆分为两个32位的写操作,且这两个32位的写操作被分配到不同的写事务中执行。同时处理器B中64位的读操作被拆分为两个32位的读操作,且这两个32位的读操作被分配到同一个的读事务中执行。当处理器A和B按上图的时序来执行时,处理器B将看到仅仅被处理器A“写了一半“的无效值。

参考文献

  1. JSR-133: Java Memory Model and Thread Specification
  2. Shared memory consistency models: A tutorial
  3. The JSR-133 Cookbook for Compiler Writers
  4. 深入理解计算机系统(原书第2版)
  5. UNIX Systems for Modern Architectures: Symmetric Multiprocessing and Caching for Kernel Programmers
  6. The Java Language Specification, Third Edition

作者简介

程晓明,Java软件工程师,国家认证的系统分析师、信息项目管理师。专注于并发编程,就职于富士通南大。个人邮箱:asst2003@163.com


感谢张龙对本文的审校。

给InfoQ中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ)或者腾讯微博(@InfoQ)关注我们,并与我们的编辑和其他读者朋友交流。

告诉我们您的想法

社区评论 Watch Thread

深入理解Java内存模型(三)——顺序一致性2013年2月1日 01:34 by zou chun

如果把硬件的内存屏障讲一些,可能理解就更好了,特别内核中的处理

Re: 深入理解Java内存模型(三)——顺序一致性2013年2月3日 01:12 by 程 晓明

您好
在下一章中,大部分内容都是在讲内存屏障。

非常期待后续大作,多写点哈,写多了就可以整理成一本书了2013年2月4日 02:24 by rao aaron

非常期待后续大作,多写点哈,写多了就可以整理成一本书了

期待后续大作2013年2月5日 09:54 by chen spirit

如何避免long在多处理器下,可能变成无效值呢?

Re: 期待后续大作2013年2月6日 12:56 by 程 晓明

如果是在32位的处理器上运行,且是一个多线程程序,才可能遇到这种问题。
这种情况下,有两种方法解决:
1:可以把这个long/double变量声明为volatile
2:或者对这个long/double变量的读/写,使用同步原语(lock,volatile和final)来正确同步。

Re: 非常期待后续大作,多写点哈,写多了就可以整理成一本书了2013年2月6日 12:56 by 程 晓明

谢谢您的关注,呵呵。

Re: 非常期待后续大作,多写点哈,写多了就可以整理成一本书了2013年2月19日 11:38 by 杨 亮

感谢作者的分享,我正在努力学习中,好文章

因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了2013年3月12日 02:31 by sun bo

这句话能具体解释一下吗?

Re: 因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了2013年3月13日 12:24 by 程 晓明

假设在已清零的内存空间分配对象,那么这个对象的基础数据类型自动设置成了默认值,引用被设置成了null。
比如对象有一个int型成员域。那么这个int型成员域的默认初始化值就会为0.

在《java编程思想》第四版的5.7.2章节,对对象初始化的细节有详细的说明,可以参照。

Re: 期待后续大作2013年3月18日 08:45 by tian jason

关于这点我有个疑问,我们现在使用的大部分window系统都是32位的,至少我现在使用的是这样。那不就是说如果我在多线程中使用到long型,而没有显示做处理,我的程序就可能不正确?可为什么平常使用都是直接定义一个long型,然后来用,却没有发现过有这种bug呢?还是JVM做了处理?

深入理解Java内存模型(三)——顺序一致性2013年3月18日 05:45 by 成武 李

写得很好,持续关注

Re: 期待后续大作2013年3月20日 01:16 by 程 晓明

《JSR-133: JavaTM Memory Model and Thread Specification》对这个问题的说明如下(第12章的最后一段话):
VM implementors are encouraged to avoid splitting their 64-bit values where possible. Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.
大意是说:
内存模型规范鼓励JVM的实现,如果可能的话尽量不要把64位的long/double型变量分割为2个32位操作。
内存模型鼓励程序员把共享的64位的long/double型变量声明为volatile,或者对程序进行正确同步来避免这个问题。
从上面这段话来看,JVM确实在尽可能的避免这个问题。
即使真的出现这种BUG,应该也是低概率事件,就像现实生活中我们也并不是每天都能碰到倒霉的事:)

Re: 期待后续大作2013年3月26日 05:39 by 王 辉

那当我们遇到long类型的变量时,需要加lock或者volatile关键字来修饰吗?

Re: 期待后续大作2013年3月31日 12:02 by 程 晓明

这要看具体情况。

只有:“多线程程序 + 在32位的处理器上运行 + 64位共享变量(long/double)”才有可能出现这种问题。
《JSR-133: JavaTM Memory Model and Thread Specification》--这个规范产生于2004年,它在“第12章的最后一段话”中,对多线程程序员的建议也是基于当时的硬件环境。
而我们现在使用的处理器好像都是64位的了,这种问题现在应该没机会出现了。

对long和double需要声明为volatile的道理终于明白了2013年4月9日 10:40 by Shupeng Li

非常感谢分享,对32位机器上的long和double可能会被分割而导致无效值的原因终于清楚了!规范确实不是凭空想象出来的,真的是结合了实际硬件环境的。

顺序一致性2013年4月9日 04:01 by Sean Xj

对于JMM模型 和 顺序一致性模型 是同时存在的吗

Re: 顺序一致性2013年4月14日 11:49 by 程 晓明

顺序一致性内存模型是一个被计算机科学家理想化了的理论参考模型,并不真实存在。
语言级的内存模型(如JMM)和处理器内存模型在设计时,通常会以顺序一致性内存模型为参照。

有些疑惑请指教2013年8月13日 05:17 by Chang Joel

顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)

你好,我对文章中对这一概念总结有些疑问:
JMM如果单线程的操作也不是按顺序执行,那么代码是怎么保证正确编译执行的呢?
比如:
int i=0;
int b=i+1;
如果这两行重排序后代码正确性就打乱了,还是对以上的理解有偏差,请指教

Re: 有些疑惑请指教2013年8月18日 04:46 by 伊 开堂

这两个操作存在数据依赖性, 所以编译器和处理器不会改变这两个操作的执行顺序.
可以看该系列文章的第二篇 深入理解Java内存模型(二)——重排序

Re: 期待后续大作2013年12月29日 01:24 by 孟 衡

JMM定义的只是规范,要看具体采用的虚拟机是否保证long/double类型变量读/写操作的原子性。HotSpot是能做这个保证的,所以不会出现这样的问题。

关于“为了实现最小安全性”部分的问题2013年12月29日 01:32 by 孟 衡

“为了实现最小安全性,JVM在堆上分配对象时,首先会清零内存空间,然后才会在上面分配对象(JVM内部会同步这两个操作)。因此,在以清零的内存空间(pre-zeroed memory)分配对象时,域的默认初始化已经完成了。”
我记得创建一个对象有三个步骤:1、分配内存;2、将这块内存分配给变量;3、执行构造函数。这里2、3两步的顺序是无序的,所以会产生“双重检查锁定”的问题,在JDK5及之后的版本可以把对象声明为volatile避免,因为volatile语义规定了对volatile变量的读操作必须在写操作之后执行(前提是写操作在时间上是在读操作之前),以避免无序产生不一致行为。
如果对象变量不声明为volatile,则在多线程环境下也会出现一个“凭空、无效”的引用,JMM保证不了这个安全性。

关于“JMM不保证单线程内的操作会按程序的顺序执行”的问题2013年12月29日 01:38 by 孟 衡

文中提到“顺序一致性模型保证单线程内的操作会按程序的顺序执行,而JMM不保证单线程内的操作会按程序的顺序执行(比如上面正确同步的多线程程序在临界区内的重排序)”有误。JMM本身是严格保证在单线程内,程序按照编写的顺序执行,参见JSR133的17.4.3的Programs and Program Order部分,这是JMM保证执行结果正确性的基础。你所说的“临界区内的重排序”这也是客观存在的,但这不是JMM的重排序,而是CPU的重排序。CPU的重排序需要保证符合as-if-serial语义,所以如你例子中的重排序是允许的。

Re: 期待后续大作2014年3月15日 09:26 by 程 晓明

回复晚了,不好意思。

JMM定义的是规范,程序员根据JMM规范来编写代码。

本文只讲JMM规范提供的保证,并不涉及具体java实现提供的额外“保障”。
因为当你更换java的实现时,这些额外的“保障”可能就没有了。

Re: 关于“为了实现最小安全性”部分的问题2014年3月15日 09:35 by 程 晓明

关于“双重检查锁定”的问题,可以参考我写的另一篇文章: www.infoq.com/cn/articles/double-checked-lockin...

-----------------------------

关于“最小安全性”问题,我在这个系列的最后一篇中回复过,这里复述一下:

最小安全性保证对象默认初始化之后(设置成员域为0,null或false),才会被任意线程使用。
最小安全性“发生”在对象被任意线程使用之前。
最小安全性保证线程读取到的值,要么是之前某个线程写入的值,要么是默认值(0,null,false)。
但最小安全性并不保证线程读取到的共享变量的值,一定是某个线程写完后的值。
最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的。

Re: 关于“JMM不保证单线程内的操作会按程序的顺序执行”的问题2014年3月15日 09:59 by 程 晓明

参见JSR133的17.4.3的Programs and Program Order部分
--我估计您指的应该是《The Java Language Specification Third Edition》的17.4.3
在这个章节的末尾有如下说明:
If we were to use sequential consistency as our memory model, many of the compiler and processor optimizations that we have discussed would be illegal.

在《JSR-133: Java Memory Model and Thread Specification》的“2 Incorrectly Synchronized Programs Exhibit Surprising Behaviors”中,有如下说明:
However, compilers are allowed to reorder the instructions in either thread, when this does not affect the execution of that thread in isolation.
...... The just-in-time compiler and the processor may rearrange code. In addition, the memory hierarchy of the architecture on which a virtual machine is run may make it appear as if code is being reordered.

在《JSR 133 (Java Memory Model) FAQ》的“What is meant by reordering?”中,有如下说明:
The compiler, runtime, and hardware are supposed to conspire to create the illusion of as-if-serial semantics, which means that in a single-threaded program, the program should not be able to observe the effects of reorderings.
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值