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

时间:2013-03-27 08:14:18

标签: java concurrency java-memory-model

程序顺序规则指出“线程中的每个操作都发生在 - 该程序中稍后出现的该线程中的每个操作之前”

1.我在另一个thread中读到操作

  • 读取和写入变量
  • 锁定和解锁显示器
  • 开始并加入线程

这是否意味着可以按顺序更改读取和写入,但读取和写入不能使用在第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 another question偷了这个代码。

5 个答案:

答案 0 :(得分:20)

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

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不够聪明,无法自动完成。它将用于某些操作(例如,加入线程,启动线程,使用锁(监视器)等等)但是对于其他东西,您需要明确告诉它不要进行重新排序以改变程序输出(例如{{1}字段上的标记,锁的使用等)。

注意:
关于“发生在关系之前”的快速补遗。这是一种花哨的说法,无论Java可能做什么重新排序,东西A都会发生在B之前。在我们奇怪的后来的炖菜例子中,“Step 1& 3 发生在第4步之前”Pour鸡蛋和牛奶在“”。例如,“步骤1和3不需要发生在之前的关系,因为它们不以任何方式相互依赖”

关于附加问题&回应评论

首先,让我们确定编程世界中“时间”的含义。在编程中,我们有“绝对时间”的概念(现在世界上的时间是什么?)和“相对时间”的概念(自x以来经过了多长时间?)。在一个理想的世界里,时间就是时间,但除非我们内置了原子钟,否则绝对时间必须不时得到纠正。另一方面,对于相对时间,我们不想要更正,因为我们只对事件之间的差异感兴趣。

在Java中,volatile处理绝对时间和System.currentTime()处理相对时间。这就是nanoTime的Javadoc声明的原因,“此方法只能用于测量经过时间,与系统或挂钟时间的任何其他概念无关。”

实际上,currentTimeMillis和nanoTime都是本机调用,因此编译器实际上无法证明重新排序是否会影响正确性,这意味着它不会重新排序执行。

但是让我们想象一下,我们想要编写一个实际查看本机代码的编译器实现,只要它是合法的就重新排序。当我们查看JLS时,它告诉我们的是“只要无法检测到,您就可以重新排序”。现在作为编译器编写者,我们必须决定重新排序是否会违反语义。对于相对时间(nanoTime),如果我们重新排序执行,它显然是无用的(即违反语义)。现在,如果我们重新排序绝对时间(currentTimeMillis),它会违反语义吗?只要我们能够将世界时间的来源(比如系统时钟)与我们决定的任何东西(如“50ms”)*之间的差异限制,我就说不。对于以下示例:

System.nanoTime()

如果编译器可以证明long tick = System.currentTimeMillis(); result = compute(); long tock = System.currentTimeMillis(); print(result + ":" + tick - tock); 占用的时间少于我们允许的系统时钟的最大差异,那么按以下方式重新排序是合法的:

compute()

由于这样做不会违反我们定义的规范,因此不会违反语义。

您还问过为什么这不包含在JLS中。我认为答案是“保持JLS简短”。但我对这个领域知之甚少,所以你可能想问一个单独的问题。

*:在实际实现中,这种差异取决于平台。

答案 1 :(得分:7)

程序顺序规则保证,在单个线程中,编译器引入的重新排序优化不会产生与程序以串行方式执行时所发生的结果不同的结果。如果线程的动作在没有同步的情况下被观察到状态,则无法保证线程的动作在任何其他线程中出现的顺序。

请注意,此规则仅适用于程序的最终结果,而不适用于该程序中单个执行的顺序。例如,如果我们有一个方法对一些局部变量进行以下更改:

x = 1;
z = z + 1;
y = 1;

编译器可以自由地重新排序这些操作,但它认为最适合提高性能。想到这一点的一种方法是:如果你可以在源代码中重新排序这些操作并仍然获得相同的结果,编译器可以自由地做同样的事情。 (事实上​​,它可以更进一步完全丢弃显示没有结果的操作,例如调用空方法。)

使用第二个项目符号点监视器锁定规则发挥作用:“监视器上的解锁发生在主监视器锁定的每个后续锁定之前。” (实践中的Java并发第341页)这意味着获取给定锁的线程将具有在释放该锁之前在其他线程中发生的操作的一致视图。但请注意,此保证仅适用于两个不同的线程releaseacquire 相同的锁。如果线程A在释放Lock X之前做了很多事情,然后线程B获得了锁定Y,则线程B不能确保对A的前X操作有一致的视图。

对于变量的读取和写入可以使用startjoin进行重新排序,如果a。这样做不会破坏线程内的程序顺序, b。)变量没有应用其他“发生之前”的线程同步语义,比如将它们存储在volatile字段中。

一个简单的例子:

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();
   }
}

由于字段ab以及方法aMethod()未以任何方式同步,因此启动thread的操作不会更改写入的结果对于字段(或使用这些字段的东西),编译器可以自由地将thread.start()重新排序到方法中的任何位置。 唯一可以使用aMethod()的顺序执行的操作是在写BeforeStartObject之后将AfterStartObject之一写入字段的顺序移动转到该字段,或者在doSomeStuff()写入之前在字段上移动AfterStartObject个调用之一。 (也就是说,假设这种重新排序会以某种方式改变doSomeStuff()调用的结果。)

这里要记住的关键是,在没有同步的情况下,aMethod()中开始的线程理论上可以观察到ab中的一个或两个字段他们在执行aMethod()期间所处理的任何州(包括null)。

其他问题的答案

ticktock的分配如果要在Block1中实际用于任何测量,例如通过计算它们之间的差异,则无法对{{1}}中的代码进行重新排序并将结果打印为输出。这样的重新排序显然会破坏Java的内部线程as-if-serial 语义。它改变了通过执行指定程序顺序中的指令所获得的结果。如果分配用于任何测量并且对程序结果没有任何副作用,它们可能会被编译器优化为无操作而不是重新排序。

答案 2 :(得分:1)

在我回答这个问题之前,

  

读取和写入变量

应该是

  

易失性读取和易失性写入(同一字段)

程序顺序并不保证在关系之前发生这种情况,而是发生之前的关系保证程序顺序

问题:

  

这是否意味着可以按顺序更改读取和写入,但读取和写入不能使用在第2行或第3行中指定的操作更改顺序?

答案实际上取决于首先发生的动作以及发生的动作。看看JSR 133 Cookbook for Compiler Writers。有一个可以重新排序网格,列出了可能发生的允许的编译器重新排序。

例如,易爆商店可以在普通商店之上或之下重新排序,但易变商店 不能< / em>在易燃负载之上或之下重新排序。这都是假设内部线程语义仍然存在。

  

“程序顺序”是什么意思?

这来自JLS

  

在每个线程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;
}

答案 3 :(得分:0)

它只是意味着线程可能是多路的,但线程的动作/操作/指令的内部顺序将保持不变(相对)

thread1:T1op1,T1op2,T1op3 ...... thread2:T2op1,T2op2,T2op3 ......

虽然线程中的操作顺序(Tn'op'M)可能会有所不同,但线程中的操作T1op1, T1op2, T1op3将始终按此顺序排列,因此T2op1, T2op2, T2op3

代表:

T2op1, T1op1, T1op2, T2op2, T2op3, T1op3

答案 4 :(得分: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创建一个发生在之前的关系,如果我们删除它将无法保证在线程A增量后x线程B将打印1,它可能会打印0