深入理解Java内存模型(二)——重排序

数据依赖性

如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。数据依赖分下列三种类型:

名称代码示例说明
写后读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语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:

double pi  = 3.14;    //A
double r   = 1.0;     //B
double area = pi * r * r; //C

上面三个操作的数据依赖关系如下图所示:

如上图所示,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同样遵从这一目标。

重排序对多线程的影响

现在让我们来看看,重排序是否会改变多线程程序的执行结果。请看下面的示例代码:

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

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

Public void reader() {
    if (flag) {                //3
        int i =  a * a;        //4
        ……
    }
}
}

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语义允许对存在控制依赖的操作做重排序的原因);但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。

参考文献

  1. Computer Architecture: A Quantitative Approach, 4th Edition
  2. Concurrent Programming on Windows
  3. Concurrent Programming in Java™: Design Principles and Pattern
  4. JSR-133: Java Memory Model and Thread Specification
  5. JSR 133 (Java Memory Model) FAQ


社区评论 Watch Thread

讲的很清晰2013年1月27日 05:20 by li 七仔

讲的很清晰,通俗易懂

请教2013年1月28日 03:44 by 北 夜人

分析了重排序会造成程序语义出错,那接下来是不是讲怎么处理了?

这种问题得靠Memory Barriers同步一下吧2013年1月28日 05:27 by Byers Roger

这种问题得靠Memory Barriers同步一下吧

关于“前一个操作按顺序排在第二个操作之前”的理解2013年1月28日 09:09 by ding liang

”程序顺序规则“这一节有下面这个句话
------------------------------------------------------------------------------------------------------------------------
在第一章提到过,如果A happens- before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操
作可见,且前一个操作按顺序排在第二个操作之前。
------------------------------------------------------------------------------------------------------------------------
这里“前一个操作按顺序排在第二个操作之前”该如何理解?
这里的顺序肯定不是指令执行顺序了,那到底是什么顺序?

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年1月29日 12:29 by 程 晓明

您好,为了说明这个问题,让我们先来看看规范是怎么说的。下面是《JSR-133: JavaTM Memory Model and Thread Specification》第5章的倒数第3段话的原文:
It should be noted that the presence of a happens-before relationship between two actions does not necessarily imply that they have to take place in that order in an implementation. If the reordering produces results consistent with a legal execution, it is not illegal.
上面这段话的大意是说:对于一个java的具体实现来说,当两个操作之间具有happens-before关系时,java的具体实现可以不按照这个happens-before关系指定的顺序来执行。如果重排序之后执行产生的结果,与程序按happens-before指定的顺序执行产生的结果一致。那么,这种重排序行为在java内存模型看来并不非法。
从上面这段话来看,JSR-133规范中的happens-before关系指定的是两个操作之间的执行顺序。但java的具体实现在保证程序正确性(程序的执行结果不被改变)的前提下,可以不按照这个指定的顺序来执行这两个操作。

这里“前一个操作按顺序排在第二个操作之前”该如何理解?
--我个人理解为:前一个操作的执行顺序排在第二个操作之前。

这里的顺序肯定不是指令执行顺序了,那到底是什么顺序?
--我个人认为,这里的顺序指的就是两个操作之间的执行顺序。

Re: 请教2013年1月29日 12:45 by 程 晓明

您好,谢谢您的关注。
在下下章才会开始讲具体怎么处理,下一章主要是把顺序一致性内存模型与java内存模型做一个横向对比。
这样安排章节主要是想在开始讲具体的实现细节之前,先让读者对java内存模型有一个全局性的认识。

Re: 这种问题得靠Memory Barriers同步一下吧2013年1月29日 12:52 by 程 晓明

由于重排序分为编译器重排序和处理器重排序,因此需要两种不同的方式来禁止重排序。
java内存模型定义了一些编译器重排序规则来禁止特定类型的编译器重排序;
编译器会在生成的指令序列中插入特定类型的Memory Barriers来禁止特定类型的处理器重排序。

Re: 这种问题得靠Memory Barriers同步一下吧2013年1月29日 09:08 by Byers Roger

您说的是,我记得在维基上看到过一篇文章是讲内存栅栏的问题的 en.wikipedia.org/wiki/Memory_ordering

额,我以为是什么内容2013年1月29日 09:14 by sen firefly

我以为是什么内容,这不是处理器的流水线优化吗?编译器都会对指令重新排列,最终到了CPU会再次对指令乱序执行。(对,人家叫乱序执行)。怎么和内存有关系的?

Re: 额,我以为是什么内容2013年1月29日 11:30 by 程 晓明

这不是处理器的流水线优化吗?
--您好,在这个系列的前一篇文章提到过重排序分为:编译器重排序,指令级并行的重排序以及内存系统的重排序。当前这篇文章并不是要说明重排序是如何产生的,而是想说明重拍序对单线程程序和多线程程序会造成什么样的影响。

怎么和内存有关系的?
--在多线程环境中,一个线程写一个共享变量(写内存),接下来另一个线程读这个共享变量(读内存),这是一个非常常见的场景。由于重排序,前一个线程对共享变量的写入不一定能对后一个线程可见。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年1月31日 10:12 by ding liang

-----------------------------------------------------------------
这里的顺序肯定不是指令执行顺序了,那到底是什么顺序?
--我个人认为,这里的顺序指的就是两个操作之间的执行顺序。
-------------------------------------------------------------------
那这句话岂不是变成了下面的意思?
如果A happens- before B,JMM并不要求A一定要在B之前执行。JMM仅仅要求前一个操作(执行的结果)对后一个操
作可见,且A按顺序排在B之前(指令执行顺序)。
这样说话不是太令人费解了吗。

太给力了2013年2月1日 01:16 by huang shuihua

太给力了,讲的很不错!

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年2月1日 07:17 by Guo Gem

读后受益匪浅。
还要请教一下我的理解是否正确? 这里“前一个操作按顺序排在第二个操作之前” 是指前一操作(包含很多个指令)开始时间(执行它的第一个指令)happens-before 第二个操作(很多个指令)开始时间(执行它的第一个指令)。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年2月3日 12:34 by 程 晓明

从程序员的角度来理解,确实很绕。
《JSR-133: JavaTM Memory Model and Thread Specification》在第3章中对Happens-Before Relationship做了定义,定义的原文如下:
Two actions can be ordered by a happens-before relationship.If one action happens-before another, then the first is visible to and ordered before the second.It should be stressed that a happens-before relationship between two actions does not imply that those actions must occur in that order in a Java platform implementation.
上面这段话的大意是说:
两个操作能通过一个happens-before关系来排序。如果一个操作happens-before另一个操作,那么前一个操作将对后一个操作可见;而且前一个操作按顺序排在后一个操作之前。两个操作之间具有happens-before关系,并不意味着java平台的实现必须要按这个顺序来执行这两个操作。

我在前面的回复中提到过:如果A happens-before B,那么A的执行顺序排在B之前。但java的具体实现在不改变程序执行结果的前提下,可以违反这个指定的顺序来执行这两个操作(即可以先执行B,然后再执行A)。简而言之,虽然happens-before指定了操作之间的执行顺序,但java的具体实现可以违反这个顺序来执行。
这恰恰说明:JSR-133内存模型从表面上来看,关注的是操作之间的执行顺序(因为happens-before指定的是操作之间的执行顺序);但实际上,它关注的是程序执行时语义的正确性(即程序的执行结果不能被改变)。
这个差异极其关键,JSR-133内存模型的设计者通过这个差异,在程序员与编译器和处理器之间取得了近乎完美的平衡:一方面通过happens-before向程序员提供足够强的内存可见保证,另一方面又尽可能少的束缚编译器和处理器。
以正文中的A happens-before B来说,JMM通过这个 happens-before 关系向程序员承诺:A的执行结果对B可见且A的执行顺序排在B之前。但私底下JMM会允许编译器和处理器对这两个操作做重排序,因为这种重排序没有改变程序的结果。在这里,我们可以把这种阳奉阴违的行为理解为JMM对程序员做了一个善意的欺骗:)

在这个系列文章的最后一篇中,会对这个主题做具体的说明,敬请期待。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年2月3日 01:05 by 程 晓明

谢谢您的关注。

你的理解是有问题的。
你可以把文章中的操作理解为对单个共享变量的访问.
比如假设在一个单线程程序中,下面两行代码读写一个共享变量a:
a = 1; //这是一个写操作A,写共享变量a
int temp = a;//这是一个读操作B,读共享变量a
根据happens-before 的程序顺序规则,这里的A happens-before B。因此JMM向我们承若:A的执行顺序排在B的前面。java的具体实现也会按照这个顺序来执行这两个操作(因为A和B之间有数据依赖性,重排序这两个操作会改变程序的执行结果)。

再比如假设在一个单线程程序中,下面两行代码分别写一个共享变量a和读一个共享变量b:
a = 1; //这是一个写操作A,写共享变量a
int temp = b;//这是一个读操作B,读共享变量b
根据happens-before 的程序顺序规则,这里的A happens-before B。因此JMM向我们承若:A的执行顺序排在B的前面。但java的具体实现可以违反这个顺序来执行这两个操作(因为重排序这两个操作不会改变这个单线程程序的执行结果)。

这两个程序之间存在这种差异的原因,请参阅上面我对其他网友的回复。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年2月5日 09:48 by Li Lee

从程序员的角度来理解,确实很绕。
《JSR-133: JavaTM Memory Model and Thread Specification》在第3章中对Happens-Before Relationship做了定义,定义的原文如下:
Two actions can be ordered by a happens-before relationship.If one action happens-before another, then the first is visible to and ordered before the second.It should be stressed that a happens-before relationship between two actions does not imply that those actions must occur in that order in a Java platform implementation.
上面这段话的大意是说:
两个操作能通过一个happens-before关系来排序。如果一个操作happens-before另一个操作,那么前一个操作将对后一个操作可见;而且前一个操作按顺序排在后一个操作之前。两个操作之间具有happens-before关系,并不意味着java平台的实现必须要按这个顺序来执行这两个操作。

我在前面的回复中提到过:如果A happens-before B,那么A的执行顺序排在B之前。但java的具体实现在不改变程序执行结果的前提下,可以违反这个指定的顺序来执行这两个操作(即可以先执行B,然后再执行A)。简而言之,虽然happens-before指定了操作之间的执行顺序,但java的具体实现可以违反这个顺序来执行。
这恰恰说明:JSR-133内存模型从表面上来看,关注的是操作之间的执行顺序(因为happens-before指定的是操作之间的执行顺序);但实际上,它关注的是程序执行时语义的正确性(即程序的执行结果不能被改变)。
这个差异极其关键,JSR-133内存模型的设计者通过这个差异,在程序员与编译器和处理器之间取得了近乎完美的平衡:一方面通过happens-before向程序员提供足够强的内存可见保证,另一方面又尽可能少的束缚编译器和处理器。
以正文中的A happens-before B来说,JMM通过这个 happens-before 关系向程序员承诺:A的执行结果对B可见且A的执行顺序排在B之前。但私底下JMM会允许编译器和处理器对这两个操作做重排序,因为这种重排序没有改变程序的结果。在这里,我们可以把这种阳奉阴违的行为理解为JMM对程序员做了一个善意的欺骗:)

在这个系列文章的最后一篇中,会对这个主题做具体的说明,敬请期待。


同意作者的部分观点。对于那个顺序的理解,个人认为不是指指令的执行顺序,而是happens-before规则定义的指令操作顺序。指令执行顺序,就是指指令被CPU所执行时的顺序,在不同的平台下,不同的编译器,不同的CPU优化后的指令执行顺序会有不同,甚至同一平台下,同一编译器编译后、同一CPU的两次不同执行过程的指令执行顺序也有可能不同。而JMM只是提出一个happens-before规则,只要保证指令的最终执行遵循这个规则即可,即遵循其所规定的指令操作顺序(但这个操作顺序并不等同于最终的执行顺序),这很好的解耦了具体的平台实现。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年2月6日 12:43 by 程 晓明

谢谢您的关注

对于您的观点,我自己有两点看法:

首先,JSR-133内存模型规范并没有涉及具体的指令。
《JSR-133: JavaTM Memory Model and Thread Specification》的7.1对“操作”作了如下定义(有部分删减):
An action a is described by a tuple <t, k, v, u>, comprising:
t - the thread performing the action
k - the kind of action: volatile read, volatile write, (non-volatile) read, (non-volatile) write, lock, unlock. Volatile reads, volatile writes, locks and unlocks are synchronization .
v - the variable or monitor involved in the action
u - an arbitrary unique identifier for the action
上面这段话的大意是说:
一个操作通过一个元组<t, k, v, u>来描述,包括:
t - 执行操作的线程
k - 操作的种类:volatile读,volatile写,普通读(指非volatile读),普通写(指非volatile写),加锁,解锁。volatile读,volatile写,加锁和解锁是同步操作。
v - 与操作相关的变量或监视器。
u - 操作的任意唯一标识。
------------------------------------------
从上面内存模型规范对操作的定义来看,操作指的是对对普通变量的读/写,对volatile变量的读/写,以及对锁的加锁和解锁。

*******************************************
其次,我在前面的2次回复中已经说过:
1:《JSR-133: JavaTM Memory Model and Thread Specification》的第5章的倒数第3段话已经指明了:happens-before关系指定的是操作之间的执行顺序。
2:《JSR-133: JavaTM Memory Model and Thread Specification》在第3章中对happens-before关系做了定义。在这里我换一种方式来描述这个定义:如果A happens-before B,那么java内存模型将向程序员保证:A操作的结果将对B可见,且A的执行顺序排在B之前。
注意,这仅仅是java内存模型向程序员做出的保证!如果重排序A和B的执行顺序后,程序的结果不被改变,那么java内存模型就允许编译器和处理器对这两个操作做重排序。
这么做的原因是因为:程序员对于这两个操作是否真的被重排序并不关心,程序员关心的是程序执行时的语义不能被改变(即执行结果不能被改变)。
因此,happens-before关系其实本质上和as-if-serial语义是一回事!
as-if-serial语义保证单线程内程序的执行结果不被改变;
happens-before关系保证正确同步的多线程程序的执行结果不被改变。
as-if-serial语义给编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。
happens-before关系给编写正确同步的多线程程序的程序员创建了一个幻觉:正确同步的多线程程序是按happens-before指定的顺序来执行的。


as-if-serial语义和happens-before关系这么做的目的是为了尽可能的开发并行度。</t,></t,>

作者很用心2013年2月6日 10:30 by 冰雪 飞舞

谢谢作者的分享和解答

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年3月7日 03:02 by guo jinfei

我觉得这下确实解释清楚了。非常棒!

重排序一定会发生么2013年3月13日 04:48 by yan an

弱弱的问一句,这样的重排序是不是不确定的,比如你的例子“重排序对多线程的影响”, 1,2,3,4还是有可能按顺序执行的吧?只不过当编译器或处理器对这4个任务性能有优化的需求时,才会打乱这种顺序?

Re: 重排序一定会发生么2013年3月15日 12:09 by 程 晓明

谢谢您的关注。
大家在这里互相交流,互相学习,不需要“弱弱”的问一句:)

对,您的理解是正确的。
这里的重排序是不确定的(有可能发生,也有可能不发生)。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年3月15日 02:06 by tian jason

想请教下,是不是可以这样来理解happens-before关系,表面上他要求具有这种关系的两个操作(A happens-before B),A一定要在B前执行,这样A的结果也就是对B可见的,但在JMM具体实现的时候他会不严格遵守这个要求,而是只要AB两个操作的执行结果一样,就不管两个谁先执行谁后执行。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年3月15日 10:34 by 程 晓明

可以这么理解。

可以把happens-before理解为:是java内存模型向程序员做出的保证--如果A happens-before B,那么内存模型将保证A在B之前执行,且A的执行结果对B可见。

但实际执行时,如果重排序A和B不会改变程序的执行结果,那java内存模型就会允许这种重排序。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年3月26日 02:27 by 王 辉

对happens-before理解更深刻了!

关于执行重排序,有没有办法写程序看执行结果来测试下?2013年4月6日 02:34 by Jim Alan

重排序,有没有办法写程序看执行结果来测试下?还是说只是一个规范,大家遵守即可?谢谢!

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年4月9日 03:47 by 黄 春

这种讨论很有意义。赞。

Re: 关于执行重排序,有没有办法写程序看执行结果来测试下?2013年4月16日 02:15 by 程 晓明

重排序的发生是不确定的,因此写测试程序不一定总能观察的到。
只要是单线程程序或正确同步的多线程程序,程序员就不需要担心会受到重排序的干扰。

Re: 关于“前一个操作按顺序排在第二个操作之前”的理解2013年4月18日 07:43 by keven c

赞 (~ o ~)~zZ
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值