Java并发中的“程序顺序规则”解释

15
程序顺序规则指出:“线程中的每个操作都发生在程序顺序中后面出现的该线程中的每个操作之前”。
1.我在另一个线程中阅读到,操作是指:
  • 对变量的读写
  • 监视器的锁定和解锁
  • 启动和加入线程
这是否意味着读写可以按顺序更改,但是不能与第2或第3行中指定的操作更改顺序?
2.“程序顺序”是什么意思?
通过示例进行解释会非常有帮助。 附加相关问题 假设我有以下代码:
long tick = System.nanoTime(); //Line1: Note the time
//Block1: some code whose time I wish to measure goes here
long tock = System.nanoTime(); //Line2: Note the time

首先,这是一个单线程应用程序,为了简化事情。编译器注意到它需要检查时间两次,并且还注意到一段代码与周围的记时行没有依赖关系,因此它看到了重新组织代码的潜力,这可能导致在实际执行期间Block1不被计时调用所包围(例如,考虑这个顺序Line1 -> Line2 -> Block1)。但是,作为程序员的我可以看到Line1,2和Block1之间的依赖关系。Line1应该紧接着Block1,在Block1完成需要有一定的时间,然后立即跟随Line2。
所以我的问题是:我是否正确地测量了块?
  • 如果是,那么是什么阻止编译器重新排列顺序。
  • 如果不是(在经过Enno的回答后我认为这是正确的),我该怎么做才能防止它。

P.S.:我从最近在SO上提出的另一个问题中窃取了这段代码。


2
为什么不去查看Java语言规范呢? - Marko Topolnik
1
我强烈推荐阅读Goetz等人的《Java并发编程实践》。虽然它开始有点过时,但仍然是并发编程参考书的黄金标准。您还可以发现由Jeremy Manson(内存模型的共同作者之一)主讲的GoogleTalk对您有所帮助:http://www.youtube.com/watch?v=WTVooKLLVT8 - Andrew Bissell
@Andrew:实际上,我看过Jeremy Manson的视频,这就是为什么我对学习内存模型更加好奇。看着他博客中的例子(在此处:http://jeremymanson.blogspot.com/2007/05/roach-motels-and-java-memory-model.html),我发现他的例子实际上违反了程序顺序规则,因为他在其他监视器锁和变量访问中移动变量x和y。为什么没有违反这个规则?如果他是正确的,那我错在哪里?他在视频中也简要谈到了蟑螂旅馆的概念,所以我相信他是正确的。 - Abs
1
@wagaboy 我在我的回答中增加了一些关于程序顺序的讨论。PO规则不像你想象的那么严格;它并不意味着线程内的每个操作都必须按顺序进行,而是该线程中所有操作的结果必须与按顺序运行时相同。这被称为“线程内仿佛串行”语义。如果我们从Manson的示例中删除synchronized块(与单线程程序顺序无关),那么由于没有任何操作对其他操作产生影响,它们可以自由地重新排序。 - Andrew Bissell
5个回答

21

可能有助于解释为什么首先存在这样的规则。

Java是一种过程性语言。也就是说,您要告诉Java如何替您完成某些操作。如果Java不按照您编写的顺序执行指令,那么它显然无法正常工作。例如,在下面的示例中,如果Java按照2->1->3的顺序执行,则炖菜将被毁坏。

1. Take lid off
2. Pour salt in
3. Cook for 3 hours

那么,为什么规则不直接说“Java按照你编写的顺序执行所编写的内容”呢?简而言之,因为Java很聪明。请看以下示例:

1. Take eggs out of the freezer
2. Take lid off
3. Take milk out of the freezer
4. Pour egg and milk in
5. Cook for 3 hours
如果 Java 跟我一样,它会按顺序执行。但是 Java 足够聪明,它理解到如果按照 1 -> 3 -> 2 -> 4 -> 5 的顺序执行更有效,并且结果与直接按顺序执行相同(你不必再走一次去冰箱,而且这不会改变食谱)。
那么规则“一个线程中的每个操作都发生在该线程中程序顺序后面的每个操作之前”试图说的是,“在单个线程中,您的程序将按照您编写的确切顺序运行。我们可能会在幕后更改排序,但我们确保不会更改输出。”
到目前为止还好。为什么跨多个线程不这样做?在多线程编程中,Java 自动执行这些操作并不够聪明。对于某些操作(例如加入/启动线程,使用锁(监视器)等),它会自动执行,但对于其他操作,您需要显式告诉它不要重新排序以更改程序输出(例如字段上的 "volatile" 标记、锁的使用等)。
注意:
关于“happens-before 关系”的快速补充说明。这是一种花哨的方式来表达无论 Java 进行什么重排序,A 事情都会在 B 事情发生之前发生。在我们奇怪的后来的炖菜例子中,“步骤 1 和 3 在倒入鸡蛋和牛奶之前发生”。例如,“步骤 1 和 3 不需要‘happens-before’关系,因为它们彼此不依赖”。
关于附加问题和对评论的回答
首先,让我们确定编程世界中“时间”的含义。在编程中,我们有“绝对时间”(现在世界上的时间是多少?)和“相对时间”(自 x 以来经过了多长时间?)的概念。在理想情况下,时间就是时间,但除非我们内置原子钟,否则必须不时地校正绝对时间。另一方面,对于相对时间,我们不希望进行修正,因为我们只关心事件之间的差异。
在 Java 中,System.currentTime() 处理绝对时间,System.nanoTime() 处理相对时间。这就是为什么 nanoTime 的 Javadoc 明确指出:“该方法仅用于测量经过的时间,与系统或挂钟时间的任何其他概念无关”。
实际上,currentTimeMillis 和 nanoTime 都是本地调用,因此编译器无法实际证明重新排序不会影响正确性,这意味着它不会重新排序执行。
但是让我们想象一下,我们想编写一个实际查看本机代码并重新排序一切(只要合法)的编译器实现。当我们查看 JLS 时,它告诉我们可以重新排序任何东西,只要不能被检测到。作为编译器编写者,我们必须决定重新排序是否会违反语义。对于相对时间(nanoTime),如果重新排序执行,显然是无用的(即违反语义)。那么,如果我们为绝对时间(currentTimeMillis)重新排序,是否会违反语义?只要我们可以将与世界时间源(例如系统时钟)的差限制在我们决定的任何值(如“50 毫秒”)之内,我认为不会。针对以下示例:
long tick = System.currentTimeMillis();
result = compute();
long tock = System.currentTimeMillis();
print(result + ":" + tick - tock);

如果编译器可以证明compute()的执行时间少于我们允许的系统时钟最大差异,则可以将其排序如下:

long tick = System.currentTimeMillis();
long tock = System.currentTimeMillis();
result = compute();
print(result + ":" + tick - tock);

既然这样做不违反我们定义的规范,因此也不会违反语义。

您还问为什么这不包含在JLS中。我认为答案是“为了保持JLS简短”。但是我对这个领域了解不多,因此您可能需要单独提出一个问题来询问。

*:在实际实现中,这种差异是平台依赖的。


2
@AndrewBissell:顺便提一句,对于currentTimeMillis,计算可能会受到操作系统对时钟的校正的影响(即当操作系统在该间隔内进行校正时,tick-tock可能变得甚至为负)。因此,即使没有任何重新排序相关的问题,你仍然会遇到这个问题。 - Enno Shioji
@EnnoShioji 经过稍微思考,我不太确定在重排 System.currentTimeMillis() 的情况下是否会对程序结果产生副作用。正如你指出的那样,它本来就不应该用于测量程序执行时间,所以我们幸运地不必过多担心它! - Andrew Bissell
@EnnoShioji,你对我的附加问题的回答确实澄清了很多事情,但仍然没有明确回答currentTimeMillis()或nanoTime()是否可以重新排序。你提到“暗示编译器不会重新排序你的代码”。编译器编写者需要了解这些函数的显式规则吗?如果是这样,为什么它不是像JSR一样的规范的一部分呢? - Abs
@EnnoShioji 根据我们之前的讨论,你可能会发现Martin Thompson在InfoQ上的这个演讲很有趣。大约在45:00处,他指出System.nanoTime()确实不是一个有序的指令,正如你所说的可能是这种情况。对我来说,这是一个很好的教训,不要过于死板地解释规则 - 即使是JMM中的规则也是如此!http://www.infoq.com/presentations/performance-testing-java - Andrew Bissell
“我们可以将世界时间源(比如系统时钟)与我们决定的任何时间差(比如“50毫秒”)限制在一定范围内”,这句话是什么意思? @EnnoShioji - Mohendra Amatya
显示剩余6条评论

8
程序顺序规则保证了编译器引入的重新排序优化不会在单个线程内产生与以串行方式执行程序时不同的结果。它不能保证如果没有同步,其他线程观察到该线程的状态时线程操作出现的顺序。请注意,该规则仅涉及程序的最终结果,而不涉及程序中各个执行的顺序。例如,如果我们有一个方法对一些局部变量进行以下更改:
x = 1;
z = z + 1;
y = 1;

编译器可以自由地重新排序这些操作以提高性能。一个理解方式是:如果你在源代码中重新排序这些操作,仍然可以得到相同的结果,则编译器可以自由地这样做。(事实上,它甚至可以进一步完全丢弃已被证明没有结果的操作,例如调用空方法。)
在第二个项目符号中,监视器锁定规则发挥作用:“对监视器的解锁发生在随后对该主监视器锁的每次锁定之前。” (《Java并发实践》p.341)这意味着获得给定锁的线程将具有在释放该锁之前发生在其他线程中的动作的一致视图。但是,请注意,仅当两个不同的线程释放或获取相同的锁时,此保证才适用。如果线程A在释放锁X之前执行了大量操作,然后线程B获取了锁Y,则不能确保线程B对A的预-X操作具有一致的视图。
如果不破坏线程内程序顺序,并且变量还没有经过其他“happens-before”线程同步语义的应用(比如存储在volatile字段中),则可以重新排序变量的读写和start/join 。
一个简单的例子:
class ThreadStarter {
   Object a = null;
   Object b = null;
   Thread thread;

   ThreadStarter(Thread threadToStart) {
      this.thread = threadToStart;
   }

   public void aMethod() {
      a = new BeforeStartObject();
      b = new BeforeStartObject();
      thread.start();
      a = new AfterStartObject();
      b = new AfterStartObject();

      a.doSomeStuff();
      b.doSomeStuff();
   }
}

由于字段a和b以及方法aMethod()没有以任何方式同步,并且启动线程的操作不会更改对字段的写入结果(或对这些字段进行处理的操作),编译器可以自由地将thread.start()重新排序到方法中的任何位置。它唯一不能对aMethod()的顺序做的事情就是将写入BeforeStartObject之一的顺序移动到在该字段上写入AfterStartObject之后,或者将doSomeStuff()调用之一移动到AfterStartObject被写入该字段之前。 (也就是说,假设这种重新排序会以某种方式改变doSomeStuff()调用的结果。)
在此需要牢记的关键是,在缺乏同步的情况下,aMethod()中启动的线程可能从理论上观察到字段a和b在执行aMethod()期间采取的任何状态(包括null)。
附加问题回答:
如果要实际使用tick和tock的分配来进行任何测量(例如通过计算它们之间的差异并将结果打印为输出),则不能将其与Block1中的代码重新排序。这种重新排序显然会破坏Java的“线程内似乎串行”的语义。它会改变从按指定程序顺序执行指令获得的结果。如果分配不用于任何测量并且对程序结果没有任何副作用,则编译器很可能将它们优化为无操作而不是重新排序。

@wagaboy 这里还有什么是你需要的吗?我在想为什么我的答案还没有被接受。 - Andrew Bissell
我是一个长期潜水者,但是在SO上是一个新的发帖者。所以我不确定赞和选项是如何工作的。我发现你的评论和答案非常有帮助,所以我点了赞并选择了它。我也对Enno的答案做了同样的操作,因为我觉得它很有用。由于某种原因,SO只选择了那个答案(可能是因为那个答案有更多的赞?)。现在是时候去FAQ部分了。 - Abs
@wagaboy,SO 只允许问题提出者选择一个答案作为被采纳的答案。因此,被采纳的答案是你最后选择的那个。 - John Vint
我可能没有完全理解这里的整个情况,但是你关于内部线程中a和b的可见性的陈述如何与语句之间存在的线程内发生-之前关系以及thread.start()和新线程中代码的第一行之间存在的发生-之前关系相关联呢?由于HB关系是传递的,这是否意味着新线程中的代码只能将a和b视为BeforeStartObject,甚至不是null? - I4004
@AndrewBissell,我认为这个说法不正确:“在没有同步的情况下,在aMethod()中启动的线程理论上可以观察到a和b字段在aMethod()执行期间所处的任何状态(包括null)。”,请参见https://dev59.com/0GQo5IYBdhLWcg3wMs4L。 - I4004

1
在我回答这个问题之前,
读取和写入变量
应该是
读取和写入(同一字段)的volatile操作
程序顺序并不能保证这种先于关系,而是先于关系保证了程序顺序
对于你的问题:
这是否意味着读取和写入可以按顺序改变,但是读取和写入不能与第2或第3行中指定的操作改变顺序?
实际上,答案取决于哪个动作先发生,哪个动作后发生。请参考JSR 133编译器编写者手册。其中有一个“可重新排序”表格,列出了允许发生的编译器重新排序。
例如,一个volatile存储可以在普通存储之上或之下重新排序,但是volatile存储不能在volatile加载之上或之下重新排序。这仍然假设线程内语义保持不变。
“程序顺序”是什么意思?
这是来自JLS的内容。
在每个线程t执行的所有跨线程操作中,线程t的程序顺序是反映根据线程t的内部语义执行这些操作的顺序的一个全序。
换句话说,如果可以以一种方式更改变量的写入和加载,使其以与编写时完全相同的方式执行,则它将保持程序顺序。
例如:
public static Object getInstance() {
    if (instance == null) {
         instance = new Object();
    }

    return instance;
}

可以重新排序为

public static Object getInstance() {
     Object temp = instance;
     if (instance == null) {
         temp = instance = new Object();
     }

     return temp;
}

0

它的意思是,尽管线程可能被多路复用,但线程操作/指令的内部顺序将保持不变(相对而言)

线程1:T1op1、T1op2、T1op3... 线程2:T2op1、T2op2、T2op3...

虽然线程中操作(Tn'op'M)的顺序可能会有所变化,但线程内的操作T1op1、T1op2、T1op3将始终按照这个顺序进行,同样适用于T2op1、T2op2、T2op3

例如:

T2op1, T1op1, T1op2, T2op2, T2op3, T1op3

0
Java教程http://docs.oracle.com/javase/tutorial/essential/concurrency/memconsist.html指出,发生在之前的关系只是一个保证,即一个特定语句的内存写入对于另一个特定语句是可见的。以下是一个示例。
int x;

synchronized void x() {
    x += 1;
}

synchronized void y() {
    System.out.println(x);
}

synchronized 创建了一个 happens-before 关系,如果我们将其移除,则不能保证在线程 A 增加 x 后线程 B 将打印 1,它可能会打印 0。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接